Skip to content

Commit

Permalink
feat(query)[TS-5058]: support AUTO OFFSET in INTERVAL clause
Browse files Browse the repository at this point in the history
Add the AUTO keyword, which allows automatic determination of the
INTERVAL OFFSET based on the WHERE condition. It simplifies usage by
allowing users to rely on the system to infer the correct offset
without manual specification.
  • Loading branch information
JinqingKuang committed Dec 3, 2024
1 parent a1f4532 commit 2fa114f
Show file tree
Hide file tree
Showing 34 changed files with 3,435 additions and 57 deletions.
14 changes: 14 additions & 0 deletions docs/en/14-reference/03-taos-sql/12-distinguished.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,25 @@ The sliding time of SLIDING cannot exceed the time range of one window. The foll
SELECT COUNT(*) FROM temp_tb_1 INTERVAL(1m) SLIDING(2m);
```

The INTERVAL clause allows the use of the AUTO keyword to specify the window offset. If the WHERE condition provides a clear applicable start time limit, the required offset will be automatically calculated, dividing the time window from that point; otherwise, it defaults to an offset of 0. Here are some simple examples:

```sql
-- With a start time limit, divide the time window from '2018-10-03 14:38:05'
SELECT COUNT(*) FROM meters WHERE _rowts >= '2018-10-03 14:38:05' INTERVAL (1m, AUTO);

-- Without a start time limit, defaults to an offset of 0
SELECT COUNT(*) FROM meters WHERE _rowts < '2018-10-03 15:00:00' INTERVAL (1m, AUTO);

-- Unclear start time limit, defaults to an offset of 0
SELECT COUNT(*) FROM meters WHERE _rowts - voltage > 1000000;
```

When using time windows, the following should be noted:

- The width of the aggregation time window is specified by the INTERVAL keyword, with a minimum time interval of 10 milliseconds (10a); it also supports offsets (the offset must be less than the interval), i.e., the window division is compared to "UTC time 0". The SLIDING statement is used to specify the forward increment of the aggregation time period, i.e., how long the window slides forward each time.
- When using the INTERVAL statement, unless in very special circumstances, it is required that the `timezone` parameters in the taos.cfg configuration files of both the client and server be set to the same value to avoid severe performance impacts caused by frequent time zone conversions in time processing functions.
- The returned results have a strictly monotonically increasing time series.
- When using AUTO as the window offset, if the slide width unit is d (day), n (month), w (week), y (year), such as: INTERVAL(10d, AUTO) SLIDING(7d), INTERVAL(3w, AUTO) SLIDING(1w), the TSMA optimization cannot take effect. If TSMA is manually created on the target table, the statement will report an error and exit; in this case, you can explicitly specify the Hint SKIP_TSMA or not use AUTO as the window offset.

### State Windows

Expand Down
14 changes: 14 additions & 0 deletions docs/zh/14-reference/03-taos-sql/12-distinguished.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,25 @@ SLIDING 的向前滑动的时间不能超过一个窗口的时间范围。以下
SELECT COUNT(*) FROM temp_tb_1 INTERVAL(1m) SLIDING(2m);
```

INTERVAL 子句允许使用 AUTO 关键字来指定窗口偏移量,此时如果 WHERE 条件给定了明确可应用的起始时间限制,则会自动计算所需偏移量,使得从该时间点切分时间窗口;否则不生效,即:仍以 0 作为偏移量。以下是简单示例说明:

```sql
-- 有起始时间限制,从 '2018-10-03 14:38:05' 切分时间窗口
SELECT COUNT(*) FROM meters WHERE _rowts >= '2018-10-03 14:38:05' INTERVAL (1m, AUTO);

-- 无起始时间限制,不生效,仍以 0 为偏移量
SELECT COUNT(*) FROM meters WHERE _rowts < '2018-10-03 15:00:00' INTERVAL (1m, AUTO);

-- 起始时间限制不明确,不生效,仍以 0 为偏移量
SELECT COUNT(*) FROM meters WHERE _rowts - voltage > 1000000;
```

使用时间窗口需要注意:

- 聚合时间段的窗口宽度由关键词 INTERVAL 指定,最短时间间隔 10 毫秒(10a);并且支持偏移 offset(偏移必须小于间隔),也即时间窗口划分与“UTC 时刻 0”相比的偏移量。SLIDING 语句用于指定聚合时间段的前向增量,也即每次窗口向前滑动的时长。
- 使用 INTERVAL 语句时,除非极特殊的情况,都要求把客户端和服务端的 taos.cfg 配置文件中的 timezone 参数配置为相同的取值,以避免时间处理函数频繁进行跨时区转换而导致的严重性能影响。
- 返回的结果中时间序列严格单调递增。
- 使用 AUTO 作为窗口偏移量时,如果滑动宽度的单位是 d (天), n (月), w (周), y (年),比如: INTERVAL(10d, AUTO) SLIDING(7d), INTERVAL(3w, AUTO) SLIDING(1w),此时 TSMA 优化无法生效。如果目标表上手动创建了TSMA,语句会报错退出;这种情况下,可以显式指定 Hint SKIP_TSMA 或者不使用 AUTO 作为窗口偏移量。

### 状态窗口

Expand Down
17 changes: 9 additions & 8 deletions include/common/tmsg.h
Original file line number Diff line number Diff line change
Expand Up @@ -1242,14 +1242,15 @@ typedef struct {
} STsBufInfo;

typedef struct {
int32_t tz; // query client timezone
char intervalUnit;
char slidingUnit;
char offsetUnit;
int8_t precision;
int64_t interval;
int64_t sliding;
int64_t offset;
int32_t tz; // query client timezone
char intervalUnit;
char slidingUnit;
char offsetUnit;
int8_t precision;
int64_t interval;
int64_t sliding;
int64_t offset;
STimeWindow timeRange;
} SInterval;

typedef struct STbVerInfo {
Expand Down
4 changes: 4 additions & 0 deletions include/common/ttime.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ extern "C" {
#define TIME_UNIT_MONTH 'n'
#define TIME_UNIT_YEAR 'y'

#define AUTO_DURATION_LITERAL "auto"
#define AUTO_DURATION_VALUE -1

/*
* @return timestamp decided by global conf variable, tsTimePrecision
* if precision == TSDB_TIME_PRECISION_MICRO, it returns timestamp in microsecond.
Expand Down Expand Up @@ -78,6 +81,7 @@ int64_t taosTimeAdd(int64_t t, int64_t duration, char unit, int32_t precision);
int64_t taosTimeTruncate(int64_t ts, const SInterval* pInterval);
int64_t taosTimeGetIntervalEnd(int64_t ts, const SInterval* pInterval);
int32_t taosTimeCountIntervalForFill(int64_t skey, int64_t ekey, int64_t interval, char unit, int32_t precision, int32_t order);
void calcIntervalAutoOffset(SInterval* interval);

int32_t parseAbsoluteDuration(const char* token, int32_t tokenlen, int64_t* ts, char* unit, int32_t timePrecision);
int32_t parseNatualDuration(const char* token, int32_t tokenLen, int64_t* duration, char* unit, int32_t timePrecision, bool negativeAllow);
Expand Down
5 changes: 3 additions & 2 deletions include/libs/executor/executor.h
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ void qProcessRspMsg(void* parent, struct SRpcMsg* pMsg, struct SEpSet* pEpSet);

int32_t qGetExplainExecInfo(qTaskInfo_t tinfo, SArray* pExecInfoList);

void getNextTimeWindow(const SInterval* pInterval, STimeWindow* tw, int32_t order);
void getInitialStartTimeWindow(SInterval* pInterval, TSKEY ts, STimeWindow* w, bool ascQuery);
TSKEY getNextTimeWindowStart(const SInterval* pInterval, TSKEY start, int32_t order);
void getNextTimeWindow(const SInterval* pInterval, STimeWindow* tw, int32_t order);
void getInitialStartTimeWindow(SInterval* pInterval, TSKEY ts, STimeWindow* w, bool ascQuery);
STimeWindow getAlignQueryTimeWindow(const SInterval* pInterval, int64_t key);

SArray* qGetQueriedTableListInfo(qTaskInfo_t tinfo);
Expand Down
2 changes: 2 additions & 0 deletions include/libs/nodes/plannodes.h
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ typedef struct SWindowLogicNode {
int64_t sliding;
int8_t intervalUnit;
int8_t slidingUnit;
STimeWindow timeRange;
int64_t sessionGap;
SNode* pTspk;
SNode* pTsEnd;
Expand Down Expand Up @@ -682,6 +683,7 @@ typedef struct SIntervalPhysiNode {
int64_t sliding;
int8_t intervalUnit;
int8_t slidingUnit;
STimeWindow timeRange;
} SIntervalPhysiNode;

typedef SIntervalPhysiNode SMergeIntervalPhysiNode;
Expand Down
13 changes: 7 additions & 6 deletions include/libs/nodes/querynodes.h
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,13 @@ typedef struct SSessionWindowNode {
} SSessionWindowNode;

typedef struct SIntervalWindowNode {
ENodeType type; // QUERY_NODE_INTERVAL_WINDOW
SNode* pCol; // timestamp primary key
SNode* pInterval; // SValueNode
SNode* pOffset; // SValueNode
SNode* pSliding; // SValueNode
SNode* pFill;
ENodeType type; // QUERY_NODE_INTERVAL_WINDOW
SNode* pCol; // timestamp primary key
SNode* pInterval; // SValueNode
SNode* pOffset; // SValueNode
SNode* pSliding; // SValueNode
SNode* pFill;
STimeWindow timeRange;
} SIntervalWindowNode;

typedef struct SEventWindowNode {
Expand Down
1 change: 1 addition & 0 deletions include/util/taoserror.h
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ int32_t taosGetErrSize();
#define TSDB_CODE_TSMA_MUST_BE_DROPPED TAOS_DEF_ERROR_CODE(0, 0x3110)
#define TSDB_CODE_TSMA_NAME_TOO_LONG TAOS_DEF_ERROR_CODE(0, 0x3111)
#define TSDB_CODE_TSMA_INVALID_RECURSIVE_INTERVAL TAOS_DEF_ERROR_CODE(0, 0x3112)
#define TSDB_CODE_TSMA_INVALID_AUTO_OFFSET TAOS_DEF_ERROR_CODE(0, 0x3113)

//rsma
#define TSDB_CODE_RSMA_INVALID_ENV TAOS_DEF_ERROR_CODE(0, 0x3150)
Expand Down
45 changes: 33 additions & 12 deletions source/common/src/ttime.c
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,7 @@ int64_t taosTimeTruncate(int64_t ts, const SInterval* pInterval) {
news += (int64_t)(timezone * TSDB_TICK_PER_SECOND(precision));
}

start = news;
if (news <= ts) {
int64_t prev = news;
int64_t newe = taosTimeAdd(news, pInterval->interval, pInterval->intervalUnit, precision) - 1;
Expand All @@ -828,7 +829,7 @@ int64_t taosTimeTruncate(int64_t ts, const SInterval* pInterval) {
}
}

return prev;
start = prev;
}
} else {
int64_t delta = ts - pInterval->interval;
Expand Down Expand Up @@ -881,8 +882,8 @@ int64_t taosTimeTruncate(int64_t ts, const SInterval* pInterval) {
while (newe >= ts) {
start = slidingStart;
slidingStart = taosTimeAdd(slidingStart, -pInterval->sliding, pInterval->slidingUnit, precision);
int64_t slidingEnd = taosTimeAdd(slidingStart, pInterval->interval, pInterval->intervalUnit, precision) - 1;
newe = taosTimeAdd(slidingEnd, pInterval->offset, pInterval->offsetUnit, precision);
int64_t news = taosTimeAdd(slidingStart, pInterval->offset, pInterval->offsetUnit, precision);
newe = taosTimeAdd(news, pInterval->interval, pInterval->intervalUnit, precision) - 1;
}
start = taosTimeAdd(start, pInterval->offset, pInterval->offsetUnit, precision);
}
Expand All @@ -892,17 +893,37 @@ int64_t taosTimeTruncate(int64_t ts, const SInterval* pInterval) {

// used together with taosTimeTruncate. when offset is great than zero, slide-start/slide-end is the anchor point
int64_t taosTimeGetIntervalEnd(int64_t intervalStart, const SInterval* pInterval) {
if (pInterval->offset > 0) {
int64_t slideStart =
taosTimeAdd(intervalStart, -1 * pInterval->offset, pInterval->offsetUnit, pInterval->precision);
int64_t slideEnd = taosTimeAdd(slideStart, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
int64_t result = taosTimeAdd(slideEnd, pInterval->offset, pInterval->offsetUnit, pInterval->precision);
return result;
} else {
int64_t result = taosTimeAdd(intervalStart, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
return result;
return taosTimeAdd(intervalStart, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
}

void calcIntervalAutoOffset(SInterval* interval) {
if (!interval || interval->offset != AUTO_DURATION_VALUE) {
return;
}

interval->offset = 0;

if (interval->timeRange.skey == INT64_MIN) {
return;
}

TSKEY skey = interval->timeRange.skey;
TSKEY start = taosTimeTruncate(skey, interval);
TSKEY news = start;
while (news <= skey) {
start = news;
news = taosTimeAdd(start, interval->sliding, interval->slidingUnit, interval->precision);
if (news < start) {
// overflow happens
uError("%s failed and skip, skey [%" PRId64 "], inter[%" PRId64 "(%c)], slid[%" PRId64 "(%c)], precision[%d]",
__func__, skey, interval->interval, interval->intervalUnit, interval->sliding, interval->slidingUnit,
interval->precision);
return;
}
}
interval->offset = skey - start;
}

// internal function, when program is paused in debugger,
// one can call this function from debugger to print a
// timestamp as human readable string, for example (gdb):
Expand Down
3 changes: 3 additions & 0 deletions source/libs/command/src/explain.c
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,9 @@ static int32_t qExplainResNodeToRowsImpl(SExplainResNode *pResNode, SExplainCtx
EXPLAIN_ROW_APPEND_SLIMIT(pIntNode->window.node.pSlimit);
EXPLAIN_ROW_END();
QRY_ERR_RET(qExplainResAppendRow(ctx, tbuf, tlen, level + 1));
EXPLAIN_ROW_NEW(level + 1, EXPLAIN_TIMERANGE_FORMAT, pIntNode->timeRange.skey, pIntNode->timeRange.ekey);
EXPLAIN_ROW_END();
QRY_ERR_RET(qExplainResAppendRow(ctx, tbuf, tlen, level + 1));
uint8_t precision = qExplainGetIntervalPrecision(pIntNode);
int64_t time1 = -1;
int64_t time2 = -1;
Expand Down
34 changes: 16 additions & 18 deletions source/libs/executor/src/executil.c
Original file line number Diff line number Diff line change
Expand Up @@ -2289,7 +2289,9 @@ SInterval extractIntervalInfo(const STableScanPhysiNode* pTableScanNode) {
.slidingUnit = pTableScanNode->slidingUnit,
.offset = pTableScanNode->offset,
.precision = pTableScanNode->scan.node.pOutputDataBlockDesc->precision,
.timeRange = pTableScanNode->scanRange,
};
calcIntervalAutoOffset(&interval);

return interval;
}
Expand Down Expand Up @@ -2409,13 +2411,14 @@ void getInitialStartTimeWindow(SInterval* pInterval, TSKEY ts, STimeWindow* w, b

int64_t key = w->skey;
while (key < ts) { // moving towards end
key = taosTimeAdd(key, pInterval->sliding, pInterval->slidingUnit, pInterval->precision);
key = getNextTimeWindowStart(pInterval, key, TSDB_ORDER_ASC);
if (key > ts) {
break;
}

w->skey = key;
}
w->ekey = taosTimeAdd(w->skey, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
}
}

Expand All @@ -2428,14 +2431,12 @@ static STimeWindow doCalculateTimeWindow(int64_t ts, SInterval* pInterval) {
}

STimeWindow getFirstQualifiedTimeWindow(int64_t ts, STimeWindow* pWindow, SInterval* pInterval, int32_t order) {
int32_t factor = (order == TSDB_ORDER_ASC) ? -1 : 1;

STimeWindow win = *pWindow;
STimeWindow save = win;
while (win.skey <= ts && win.ekey >= ts) {
save = win;
win.skey = taosTimeAdd(win.skey, factor * pInterval->sliding, pInterval->slidingUnit, pInterval->precision);
win.ekey = taosTimeAdd(win.ekey, factor * pInterval->sliding, pInterval->slidingUnit, pInterval->precision);
// get previous time window
getNextTimeWindow(pInterval, &win, order == TSDB_ORDER_ASC ? TSDB_ORDER_DESC : TSDB_ORDER_ASC);
}

return save;
Expand All @@ -2448,7 +2449,6 @@ STimeWindow getActiveTimeWindow(SDiskbasedBuf* pBuf, SResultRowInfo* pResultRowI
STimeWindow w = {0};
if (pResultRowInfo->cur.pageId == -1) { // the first window, from the previous stored value
getInitialStartTimeWindow(pInterval, ts, &w, (order == TSDB_ORDER_ASC));
w.ekey = taosTimeGetIntervalEnd(w.skey, pInterval);
return w;
}

Expand All @@ -2471,19 +2471,17 @@ STimeWindow getActiveTimeWindow(SDiskbasedBuf* pBuf, SResultRowInfo* pResultRowI
return w;
}

void getNextTimeWindow(const SInterval* pInterval, STimeWindow* tw, int32_t order) {
int64_t slidingStart = 0;
if (pInterval->offset > 0) {
slidingStart = taosTimeAdd(tw->skey, -1 * pInterval->offset, pInterval->offsetUnit, pInterval->precision);
} else {
slidingStart = tw->skey;
}
TSKEY getNextTimeWindowStart(const SInterval* pInterval, TSKEY start, int32_t order) {
int32_t factor = GET_FORWARD_DIRECTION_FACTOR(order);
slidingStart = taosTimeAdd(slidingStart, factor * pInterval->sliding, pInterval->slidingUnit, pInterval->precision);
tw->skey = taosTimeAdd(slidingStart, pInterval->offset, pInterval->offsetUnit, pInterval->precision);
int64_t slidingEnd =
taosTimeAdd(slidingStart, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
tw->ekey = taosTimeAdd(slidingEnd, pInterval->offset, pInterval->offsetUnit, pInterval->precision);
TSKEY nextStart = taosTimeAdd(start, -1 * pInterval->offset, pInterval->offsetUnit, pInterval->precision);
nextStart = taosTimeAdd(nextStart, factor * pInterval->sliding, pInterval->slidingUnit, pInterval->precision);
nextStart = taosTimeAdd(nextStart, pInterval->offset, pInterval->offsetUnit, pInterval->precision);
return nextStart;
}

void getNextTimeWindow(const SInterval* pInterval, STimeWindow* tw, int32_t order) {
tw->skey = getNextTimeWindowStart(pInterval, tw->skey, order);
tw->ekey = taosTimeAdd(tw->skey, pInterval->interval, pInterval->intervalUnit, pInterval->precision) - 1;
}

bool hasLimitOffsetInfo(SLimitInfo* pLimitInfo) {
Expand Down
4 changes: 3 additions & 1 deletion source/libs/executor/src/streamintervalsliceoperator.c
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,9 @@ int32_t createStreamIntervalSliceOperatorInfo(SOperatorInfo* downstream, SPhysiN
.intervalUnit = pIntervalPhyNode->intervalUnit,
.slidingUnit = pIntervalPhyNode->slidingUnit,
.offset = pIntervalPhyNode->offset,
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision};
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision,
.timeRange = pIntervalPhyNode->timeRange};
calcIntervalAutoOffset(&pInfo->interval);

pInfo->twAggSup =
(STimeWindowAggSupp){.waterMark = pIntervalPhyNode->window.watermark,
Expand Down
6 changes: 5 additions & 1 deletion source/libs/executor/src/streamtimewindowoperator.c
Original file line number Diff line number Diff line change
Expand Up @@ -1932,7 +1932,9 @@ int32_t createStreamFinalIntervalOperatorInfo(SOperatorInfo* downstream, SPhysiN
.intervalUnit = pIntervalPhyNode->intervalUnit,
.slidingUnit = pIntervalPhyNode->slidingUnit,
.offset = pIntervalPhyNode->offset,
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision};
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision,
.timeRange = pIntervalPhyNode->timeRange};
calcIntervalAutoOffset(&pInfo->interval);
pInfo->twAggSup = (STimeWindowAggSupp){
.waterMark = pIntervalPhyNode->window.watermark,
.calTrigger = pIntervalPhyNode->window.triggerType,
Expand Down Expand Up @@ -5342,7 +5344,9 @@ static int32_t createStreamSingleIntervalOperatorInfo(SOperatorInfo* downstream,
.slidingUnit = pIntervalPhyNode->slidingUnit,
.offset = pIntervalPhyNode->offset,
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision,
.timeRange = pIntervalPhyNode->timeRange,
};
calcIntervalAutoOffset(&pInfo->interval);

pInfo->twAggSup =
(STimeWindowAggSupp){.waterMark = pIntervalPhyNode->window.watermark,
Expand Down
12 changes: 9 additions & 3 deletions source/libs/executor/src/timewindowoperator.c
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,9 @@ int32_t createIntervalOperatorInfo(SOperatorInfo* downstream, SIntervalPhysiNode
.intervalUnit = pPhyNode->intervalUnit,
.slidingUnit = pPhyNode->slidingUnit,
.offset = pPhyNode->offset,
.precision = ((SColumnNode*)pPhyNode->window.pTspk)->node.resType.precision};
.precision = ((SColumnNode*)pPhyNode->window.pTspk)->node.resType.precision,
.timeRange = pPhyNode->timeRange};
calcIntervalAutoOffset(&interval);

STimeWindowAggSupp as = {
.waterMark = pPhyNode->window.watermark,
Expand Down Expand Up @@ -2122,7 +2124,9 @@ int32_t createMergeAlignedIntervalOperatorInfo(SOperatorInfo* downstream, SMerge
.intervalUnit = pNode->intervalUnit,
.slidingUnit = pNode->slidingUnit,
.offset = pNode->offset,
.precision = ((SColumnNode*)pNode->window.pTspk)->node.resType.precision};
.precision = ((SColumnNode*)pNode->window.pTspk)->node.resType.precision,
.timeRange = pNode->timeRange};
calcIntervalAutoOffset(&interval);

SIntervalAggOperatorInfo* iaInfo = miaInfo->intervalAggOperatorInfo;
SExprSupp* pSup = &pOperator->exprSupp;
Expand Down Expand Up @@ -2462,7 +2466,9 @@ int32_t createMergeIntervalOperatorInfo(SOperatorInfo* downstream, SMergeInterva
.intervalUnit = pIntervalPhyNode->intervalUnit,
.slidingUnit = pIntervalPhyNode->slidingUnit,
.offset = pIntervalPhyNode->offset,
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision};
.precision = ((SColumnNode*)pIntervalPhyNode->window.pTspk)->node.resType.precision,
.timeRange = pIntervalPhyNode->timeRange};
calcIntervalAutoOffset(&interval);

pMergeIntervalInfo->groupIntervals = tdListNew(sizeof(SGroupTimeWindow));

Expand Down
Loading

0 comments on commit 2fa114f

Please sign in to comment.