You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SAMPLE BY puts a NULL designated timestamp that isn't in the first row into the wrong bucket on the SAMPLE BY cursor path #7834
TIMESTAMP(col) can designate a column that holds NULL. On the SAMPLE BY cursor path, a row with a NULL timestamp that follows a row with a timestamp is counted in the bucket in progress. Under a time zone west of UTC it opens a bucket labelled in year 294247 instead, and with FILL(LINEAR) the cursor builds buckets up to year 294247. No error is raised. The GROUP BY path sorts its input and returns the row as a NULL bucket.
CREATETABLEorders (ts TIMESTAMP, filled_at TIMESTAMP, sym SYMBOL, qty DOUBLE) TIMESTAMP(ts) PARTITION BY DAY;
INSERT INTO orders VALUES
('2024-01-01T00:00:00Z', '2024-01-01T00:05:00Z', 'a', 1),
('2024-01-01T01:00:00Z', NULL, 'b', 2),
('2024-01-01T02:00:00Z', '2024-01-01T02:30:00Z', 'a', 4);
-- 1. GROUP BY pathSELECT filled_at, count() c, sum(qty) s FROM orders TIMESTAMP(filled_at) SAMPLE BY 1h;
-- 2. cursor pathSELECT filled_at, count() c, sum(qty) s FROM orders TIMESTAMP(filled_at) SAMPLE BY 1h ALIGN TO FIRST OBSERVATION;
-- 3. cursor path, zone west of UTCSELECT filled_at, count() c, sum(qty) s
FROM ((SELECT filled_at, qty FROM orders) TIMESTAMP(filled_at))
SAMPLE BY 1h ALIGN TO CALENDAR TIME ZONE 'America/New_York';
-- 4. FILL(LINEAR)SELECTcount() n, min(filled_at) lo, max(filled_at) hi
FROM (SELECT filled_at, sum(qty) s FROM orders TIMESTAMP(filled_at) SAMPLE BY 100d FILL(LINEAR));
-- 5. keyed, year stride, zone west of UTCSELECT filled_at, sym, count() c, sum(qty) s
FROM ((SELECT filled_at, sym, qty FROM orders) TIMESTAMP(filled_at))
SAMPLE BY 1y ALIGN TO CALENDAR TIME ZONE 'America/New_York';
2024-01-01T00:05Z 2 3.0, 2024-01-01T02:05Z 1 4.0: the first bucket counts the NULL row
3
2024-01-01T00:00Z 1 1.0, 294247-01-10T04:00Z 2 6.0: the NULL row opens a bucket in year 294247, which also takes the row after it
4
1,067,324 rows from three, from 2023-12-09T00:00Z to 294247-01-01T08:01:49.551616Z
5
one row, 2023-01-01T05:00Z a 1 1.0: the result ends after the first bucket
Statement 4 uses a 100d stride to stay small; a shorter stride means more buckets, and it hasn't been run.
Cause
The cursors read the timestamp of every row after the first without a NULL check.
AbstractNoRecordSampleByCursor.getBaseRecordTimestamp() returns baseRecord.getTimestamp(timestampIndex) + tzOffset (AbstractNoRecordSampleByCursor.java:209-211). For a NULL, Long.MIN_VALUE plus a zero or positive offset is below the end of the bucket in progress, so notKeyedLoop() (line 289) and SampleByFillNoneRecordCursor.buildMap() (SampleByFillNoneRecordCursor.java:146-147) add the row to that bucket. With a negative offset the sum wraps to a value near Long.MAX_VALUE, and the row opens a bucket there.
SampleByInterpolateRecordCursorFactory.fillMap() rounds the timestamp of each row with the sampler (SampleByInterpolateRecordCursorFactory.java:649), and the cursor then interpolates over the range between the lowest and the highest bucket.
SqlCodeGenerator.generateSampleBy() requires a forward scan direction from the base (SqlCodeGenerator.java:9043-9054), but it doesn't check or enforce the order of an explicit TIMESTAMP(col) on the cursor path.
A timestamp that is out of order but not NULL is merged into the bucket in progress in the same way. Only a NULL gives the year 294247 label and the FILL(LINEAR) range.
Scope
Affected: the SAMPLE BY cursor path over a base that isn't sorted by the designated column. That is FROM t TIMESTAMP(col) with ALIGN TO FIRST OBSERVATION or FILL(LINEAR), and ((sub-query) TIMESTAMP(col)) without an ORDER BY on that column. A sub-query that takes its timestamp from the right-hand side of an ASOF JOIN ... TOLERANCE gives such a base with otherwise ascending values, because the unmatched rows carry NULL.
Not affected: the GROUP BY path.
The documentation of the dynamic TIMESTAMP(col) clause asks for data sorted by that column, so these statements are outside the documented contract. The problem is the failure mode: wrong counts, a bucket in year 294247 and a FILL(LINEAR) range up to year 294247, with no error.
Two ways to resolve it: reject a NULL or out-of-order timestamp in the row loops with an error, or sort an explicit TIMESTAMP(col) base on the cursor path as the GROUP BY path does.
To reproduce
TIMESTAMP(col)can designate a column that holds NULL. On the SAMPLE BY cursor path, a row with a NULL timestamp that follows a row with a timestamp is counted in the bucket in progress. Under a time zone west of UTC it opens a bucket labelled in year 294247 instead, and with FILL(LINEAR) the cursor builds buckets up to year 294247. No error is raised. The GROUP BY path sorts its input and returns the row as a NULL bucket.NULL 1 2.0,2024-01-01T00:00Z 1 1.0,2024-01-01T02:00Z 1 4.02024-01-01T00:05Z 2 3.0,2024-01-01T02:05Z 1 4.0: the first bucket counts the NULL row2024-01-01T00:00Z 1 1.0,294247-01-10T04:00Z 2 6.0: the NULL row opens a bucket in year 294247, which also takes the row after it2023-12-09T00:00Zto294247-01-01T08:01:49.551616Z2023-01-01T05:00Z a 1 1.0: the result ends after the first bucketStatement 4 uses a 100d stride to stay small; a shorter stride means more buckets, and it hasn't been run.
Cause
The cursors read the timestamp of every row after the first without a NULL check.
AbstractNoRecordSampleByCursor.getBaseRecordTimestamp()returnsbaseRecord.getTimestamp(timestampIndex) + tzOffset(AbstractNoRecordSampleByCursor.java:209-211). For a NULL,Long.MIN_VALUEplus a zero or positive offset is below the end of the bucket in progress, sonotKeyedLoop()(line 289) andSampleByFillNoneRecordCursor.buildMap()(SampleByFillNoneRecordCursor.java:146-147) add the row to that bucket. With a negative offset the sum wraps to a value nearLong.MAX_VALUE, and the row opens a bucket there.SampleByInterpolateRecordCursorFactory.fillMap()rounds the timestamp of each row with the sampler (SampleByInterpolateRecordCursorFactory.java:649), and the cursor then interpolates over the range between the lowest and the highest bucket.SqlCodeGenerator.generateSampleBy()requires a forward scan direction from the base (SqlCodeGenerator.java:9043-9054), but it doesn't check or enforce the order of an explicitTIMESTAMP(col)on the cursor path.A timestamp that is out of order but not NULL is merged into the bucket in progress in the same way. Only a NULL gives the year 294247 label and the FILL(LINEAR) range.
Scope
FROM t TIMESTAMP(col)withALIGN TO FIRST OBSERVATIONor FILL(LINEAR), and((sub-query) TIMESTAMP(col))without an ORDER BY on that column. A sub-query that takes its timestamp from the right-hand side of anASOF JOIN ... TOLERANCEgives such a base with otherwise ascending values, because the unmatched rows carry NULL.TIMESTAMP(col)clause asks for data sorted by that column, so these statements are outside the documented contract. The problem is the failure mode: wrong counts, a bucket in year 294247 and a FILL(LINEAR) range up to year 294247, with no error.SAMPLE BY designated timestamp cannot be NULL, and statements without FILL return the leading NULL rows as a NULL bucket.AssertionErroratSampleByFillNoneRecordCursor.buildMap(), which fix(sql): breaking change 💥 - fix wrong results, failures, hangs and a crash in HORIZON JOIN, WINDOW JOIN, SAMPLE BY, ORDER BY and dateadd() queries #7681 adds, where master ends the result early.Two ways to resolve it: reject a NULL or out-of-order timestamp in the row loops with an error, or sort an explicit
TIMESTAMP(col)base on the cursor path as the GROUP BY path does.QuestDB version
master at 6c63556
OS, in case of Docker specify Docker and the Host OS
Linux
File System, in case of Docker specify Host File System
ext4
Additional context
Found while reviewing #7681. Reproduced through the test harness (
AbstractCairoTest) with assertions enabled.