Skip to content

Commit 0daaa70

Browse files
andinuxclaude
andcommitted
feat(sqlite): cap the window cloudsync_payload_chunks prepares
Preparation is unbounded. #75 removed the quadratic term, so a drain is now linear in the stream, but linear still means a large enough tenant cannot finish inside the job deadline -- and because nothing is persisted until the loop reaches is_final, every attempt restarts from chunk 0 and keeps no progress. max_window_bytes ends the scan once that many payload bytes have been emitted, reporting an ordinary complete stream over a smaller window: is_final with the watermark lowered to the last db_version emitted. The caller checkpoints there and asks again, so preparation is bounded with no new resumable state and no protocol change. Unset (the default) is byte-identical to today. Two conditions are load-bearing. A window may not end mid-value or mid-db_version: the receive cursor must land on a complete db_version or the next request skips the unapplied remainder, since it resumes with db_version > since and no seq. And because that boundary test is what stops the scan, a db_version larger than the whole budget is still emitted in full, so a window can never come out empty and stall the drain. window_capped is an output column, so it does not move the hidden columns a table-valued call binds positionally; max_window_bytes is declared last, taking argument 8 and leaving 1..7 as they were. Also: an explicit NULL for resume_db_version now means "not given", as it already did for site_id. Callers must pass NULLs to reach a later argument, and reading one as a resume point of 0 silently restarted the scan at the start of the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b75146e commit 0daaa70

2 files changed

Lines changed: 153 additions & 7 deletions

File tree

‎src/sqlite/cloudsync_sqlite.c‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,12 @@ typedef struct {
10351035
int64_t next_seq;
10361036
int64_t next_frag_offset;
10371037
bool is_final;
1038+
// Window cap (max_window_bytes): payload bytes emitted so far by this scan, and
1039+
// whether the scan ended because the budget ran out rather than because the
1040+
// window was drained. window_bytes counts across chunks, not within one.
1041+
int64_t max_window_bytes;
1042+
int64_t window_bytes;
1043+
bool window_capped;
10381044
} cloudsync_payload_chunks_cursor;
10391045

10401046
static int payload_chunks_connect(sqlite3 *db, void *aux, int argc, const char *const *argv, sqlite3_vtab **vtab, char **err) {
@@ -1048,7 +1054,12 @@ static int payload_chunks_connect(sqlite3 *db, void *aux, int argc, const char *
10481054
// back as the resume_* inputs (cols 15..17) to continue the drain without
10491055
// a spool table — O(1) seek per chunk instead of replaying from since.
10501056
"next_db_version INTEGER, next_seq INTEGER, next_frag_offset INTEGER, is_final INTEGER, "
1051-
"resume_db_version HIDDEN, resume_seq HIDDEN, resume_frag_offset HIDDEN)");
1057+
// window_capped (col 15) is an output, so it does not shift the hidden columns
1058+
// a table-valued call binds positionally. max_window_bytes is declared last for
1059+
// the same reason: it becomes argument 8, leaving arguments 1..7 as they were.
1060+
"window_capped INTEGER, "
1061+
"resume_db_version HIDDEN, resume_seq HIDDEN, resume_frag_offset HIDDEN, "
1062+
"max_window_bytes HIDDEN)");
10521063
if (rc != SQLITE_OK) return rc;
10531064
cloudsync_payload_chunks_vtab *p = sqlite3_malloc64(sizeof(*p));
10541065
if (!p) return SQLITE_NOMEM;
@@ -1086,9 +1097,9 @@ static int payload_chunks_best_index(sqlite3_vtab *vtab, sqlite3_index_info *idx
10861097
// in a fixed order regardless of how SQLite presents constraints. idxNum bit k
10871098
// is set when handled_cols[k] is bound; xFilter reads argv in this same order.
10881099
// bit0=since_db_version(7) bit1=site_id(8) bit2=until_db_version(9)
1089-
// bit3=exclude_filter_site_id(10) bit4=resume_db_version(15)
1090-
// bit5=resume_seq(16) bit6=resume_frag_offset(17)
1091-
static const int handled_cols[] = {7, 8, 9, 10, 15, 16, 17};
1100+
// bit3=exclude_filter_site_id(10) bit4=resume_db_version(16)
1101+
// bit5=resume_seq(17) bit6=resume_frag_offset(18) bit7=max_window_bytes(19)
1102+
static const int handled_cols[] = {7, 8, 9, 10, 16, 17, 18, 19};
10921103
int argv_index = 1;
10931104
int idxnum = 0;
10941105
for (size_t k = 0; k < sizeof(handled_cols) / sizeof(handled_cols[0]); ++k) {
@@ -1305,9 +1316,38 @@ static void payload_chunks_set_next_cursor(cloudsync_payload_chunks_cursor *c) {
13051316
}
13061317
}
13071318

1319+
// Stop the scan once max_window_bytes is spent, at the first db_version boundary, and
1320+
// report the window as an ordinary complete stream ending at that reduced watermark.
1321+
// The caller then checkpoints there and asks again, so preparation is bounded without
1322+
// any new resumable state.
1323+
//
1324+
// Two conditions are load-bearing. Never cap mid-value (frag_active) or mid-db_version
1325+
// (next_dbv == dbv_max): the receive cursor must land on a complete db_version or the
1326+
// next request skips the unapplied remainder, since it resumes with db_version > since
1327+
// and no seq. And because the boundary test is what stops us, a db_version larger than
1328+
// the whole budget is still emitted in full -- otherwise a window could end up empty
1329+
// and the drain would never advance at all.
1330+
static void payload_chunks_apply_window_cap(cloudsync_payload_chunks_cursor *c) {
1331+
if (c->max_window_bytes <= 0 || c->eof) return;
1332+
c->window_bytes += c->payload_size;
1333+
if (c->is_final || c->frag_active) return;
1334+
if (c->window_bytes < c->max_window_bytes) return;
1335+
if (c->next_dbv == c->dbv_max) return; // still inside a db_version
1336+
1337+
c->window_capped = true;
1338+
c->is_final = true;
1339+
c->watermark = c->dbv_max; // the window the caller checkpoints at
1340+
c->next_dbv = c->watermark;
1341+
c->next_seq = 0;
1342+
c->next_frag_offset = 0;
1343+
}
1344+
13081345
static int payload_chunks_advance(cloudsync_payload_chunks_cursor *c) {
13091346
int rc = payload_chunks_build_next(c);
1310-
if (rc == SQLITE_OK && !c->eof) payload_chunks_set_next_cursor(c);
1347+
if (rc == SQLITE_OK && !c->eof) {
1348+
payload_chunks_set_next_cursor(c);
1349+
payload_chunks_apply_window_cap(c);
1350+
}
13111351
return rc;
13121352
}
13131353

@@ -1353,9 +1393,22 @@ static int payload_chunks_filter(sqlite3_vtab_cursor *cursor, int idxnum, const
13531393
}
13541394
if (idxnum & 4) until = sqlite3_value_int64(argv[argi++]);
13551395
if (idxnum & 8) exclude = (sqlite3_value_int(argv[argi++]) != 0);
1356-
if (idxnum & 16) { resume_dbv = sqlite3_value_int64(argv[argi++]); positional = true; }
1396+
// An explicit NULL means "not given", as it already does for site_id. Callers must
1397+
// pass NULLs to reach a later argument, and treating one as a resume point of 0
1398+
// would silently restart the scan at the beginning of the window.
1399+
if (idxnum & 16) {
1400+
if (sqlite3_value_type(argv[argi]) != SQLITE_NULL) {
1401+
resume_dbv = sqlite3_value_int64(argv[argi]);
1402+
positional = true;
1403+
}
1404+
argi++;
1405+
}
13571406
if (idxnum & 32) resume_seq = sqlite3_value_int64(argv[argi++]);
13581407
if (idxnum & 64) resume_frag = sqlite3_value_int64(argv[argi++]);
1408+
if (idxnum & 128) {
1409+
int64_t cap = sqlite3_value_int64(argv[argi++]);
1410+
c->max_window_bytes = (cap > 0) ? cap : 0; // <= 0 means no cap
1411+
}
13591412

13601413
// Resolve the site filter:
13611414
// exclude=true -> all sites except filter_site_id (CHECK path); site required
@@ -1445,7 +1498,11 @@ static int payload_chunks_filter(sqlite3_vtab_cursor *cursor, int idxnum, const
14451498
}
14461499

14471500
static int payload_chunks_next(sqlite3_vtab_cursor *cursor) {
1448-
return payload_chunks_advance((cloudsync_payload_chunks_cursor *)cursor);
1501+
cloudsync_payload_chunks_cursor *c = (cloudsync_payload_chunks_cursor *)cursor;
1502+
// A capped window ends the scan: the rows past it belong to the next window, and
1503+
// emitting them here would contradict the reduced watermark already reported.
1504+
if (c->window_capped) { c->eof = true; return SQLITE_OK; }
1505+
return payload_chunks_advance(c);
14491506
}
14501507

14511508
static int payload_chunks_eof(sqlite3_vtab_cursor *cursor) {
@@ -1466,6 +1523,7 @@ static int payload_chunks_column(sqlite3_vtab_cursor *cursor, sqlite3_context *c
14661523
case 12: sqlite3_result_int64(ctx, c->next_seq); break;
14671524
case 13: sqlite3_result_int64(ctx, c->next_frag_offset); break;
14681525
case 14: sqlite3_result_int(ctx, c->is_final ? 1 : 0); break;
1526+
case 15: sqlite3_result_int(ctx, c->window_capped ? 1 : 0); break;
14691527
default: sqlite3_result_null(ctx); break;
14701528
}
14711529
return SQLITE_OK;

‎test/review_regressions.c‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,93 @@ static void test_block_group_atomicity(void) {
630630
}
631631
}
632632

633+
// A window cap must produce ordinary complete streams over smaller windows: repeated
634+
// capped calls tile the stream exactly, every window ends on a db_version boundary, and
635+
// the drain always advances -- a window that emitted nothing would never progress.
636+
static void test_payload_window_cap(void) {
637+
sqlite3 *db = open_db();
638+
// The smallest chunk size the setting allows, so 60 rows of 20 KB span several
639+
// chunks and a budget has something to split.
640+
CHECK(sql(db, "CREATE TABLE t(id TEXT PRIMARY KEY NOT NULL, v BLOB);"
641+
"SELECT cloudsync_init('t');"
642+
"SELECT cloudsync_set('payload_max_chunk_size','262144');") == SQLITE_OK);
643+
// Two shapes, both needed. Single-row transactions give chunk boundaries that
644+
// coincide with db_version boundaries, which is where a window may end and is the
645+
// common production shape. The two 15-row transactions are ~300 KB each, larger
646+
// than a chunk, so a chunk boundary also falls *inside* a db_version -- that is
647+
// what exercises the rule that a window must not end there. Capping mid-version
648+
// would leave the remainder unsent, because the next window starts past it.
649+
int row = 0;
650+
for (int i = 0; i < 40; i++) {
651+
char buf[128];
652+
snprintf(buf, sizeof(buf), "INSERT INTO t VALUES('r%03d', randomblob(20000));", row++);
653+
CHECK(sql(db, buf) == SQLITE_OK);
654+
}
655+
for (int txn = 0; txn < 2; txn++) {
656+
CHECK(sql(db, "BEGIN;") == SQLITE_OK);
657+
for (int i = 0; i < 15; i++) {
658+
char buf[128];
659+
snprintf(buf, sizeof(buf), "INSERT INTO t VALUES('r%03d', randomblob(20000));", row++);
660+
CHECK(sql(db, buf) == SQLITE_OK);
661+
}
662+
CHECK(sql(db, "COMMIT;") == SQLITE_OK);
663+
}
664+
665+
// Baseline: the whole window in one uncapped scan.
666+
sqlite3_stmt *vm = NULL;
667+
int64_t total_bytes = 0, total_chunks = 0, watermark = 0;
668+
CHECK(sqlite3_prepare_v2(db, "SELECT count(*), sum(payload_size), max(watermark_db_version), "
669+
"max(window_capped) FROM cloudsync_payload_chunks(0,NULL,NULL,false)",
670+
-1, &vm, NULL) == SQLITE_OK);
671+
if (sqlite3_step(vm) == SQLITE_ROW) {
672+
total_chunks = sqlite3_column_int64(vm, 0);
673+
total_bytes = sqlite3_column_int64(vm, 1);
674+
watermark = sqlite3_column_int64(vm, 2);
675+
CHECK(sqlite3_column_int(vm, 3) == 0); // nothing is capped without a budget
676+
}
677+
sqlite3_finalize(vm);
678+
CHECK(total_chunks > 1 && total_bytes > 0 && watermark == 42);
679+
680+
// Two budgets: one that spans several chunks, and one below a single chunk so the
681+
// "always emit one whole db_version" guarantee is what keeps the drain moving.
682+
const int64_t budgets[] = {200000, 1};
683+
for (size_t b = 0; b < sizeof(budgets) / sizeof(budgets[0]); ++b) {
684+
int64_t since = 0, sum_bytes = 0, sum_chunks = 0;
685+
int windows = 0;
686+
bool capped = true;
687+
while (capped && windows < 100) {
688+
char q[512];
689+
snprintf(q, sizeof(q),
690+
"SELECT count(*), coalesce(sum(payload_size),0), max(watermark_db_version), "
691+
"max(window_capped), min(db_version_min) "
692+
"FROM cloudsync_payload_chunks(%lld,NULL,NULL,false,NULL,NULL,NULL,%lld)",
693+
(long long)since, (long long)budgets[b]);
694+
vm = NULL;
695+
CHECK(sqlite3_prepare_v2(db, q, -1, &vm, NULL) == SQLITE_OK);
696+
if (sqlite3_step(vm) != SQLITE_ROW || sqlite3_column_int64(vm, 0) == 0) { sqlite3_finalize(vm); break; }
697+
int64_t chunks = sqlite3_column_int64(vm, 0);
698+
int64_t bytes = sqlite3_column_int64(vm, 1);
699+
int64_t wm = sqlite3_column_int64(vm, 2);
700+
capped = sqlite3_column_int(vm, 3) != 0;
701+
int64_t first = sqlite3_column_int64(vm, 4);
702+
sqlite3_finalize(vm);
703+
704+
CHECK(first == since + 1); // windows abut, no gap and no overlap
705+
CHECK(wm > since); // always advances, so the drain terminates
706+
sum_chunks += chunks;
707+
sum_bytes += bytes;
708+
since = wm;
709+
windows++;
710+
}
711+
CHECK(windows > 1); // the budget really did split the stream
712+
CHECK(since == watermark); // and the last window reached the end
713+
CHECK(sum_bytes == total_bytes); // tiles the uncapped stream exactly
714+
CHECK(sum_chunks == total_chunks);
715+
}
716+
717+
CHECK(close_db(db) == SQLITE_OK);
718+
}
719+
633720
int main(void) {
634721
CHECK(sqlite3_config(SQLITE_CONFIG_GETMALLOC, &memory) == SQLITE_OK);
635722
sqlite3_mem_methods faults = memory;
@@ -650,6 +737,7 @@ int main(void) {
650737
test_block_migration_orphan();
651738
test_block_not_null_payload();
652739
test_block_group_atomicity();
740+
test_payload_window_cap();
653741
test_refill_error();
654742
test_block_oom();
655743
cloudsync_memory_finalize();

0 commit comments

Comments
 (0)