Skip to content

Commit a8253cb

Browse files
authored
fix(payload): actionable error from cloudsync_payload_chunks when uninitialized (#54)
Querying the cloudsync_payload_chunks vtab on a database where cloudsync was never initialized surfaced a bare "SQL logic error": the internal statements over cloudsync_changes failed at step time and the error code propagated out of xFilter without ever setting the outer vtab's zErrMsg. The /check endpoint hit this on nodes not configured for cloudsync, while cloudsync_payload_apply already reported an actionable message. - guard xFilter with the same is-initialized check (and message) used by cloudsync_payload_apply - propagate inner-statement error messages onto the vtab's zErrMsg - stop ignoring step failures of the watermark MAX(db_version) query - unit test: Payload Chunks Uninitialized - e2e: Failure Path Test now asserts the forwarded "cloudsync is not initialized" message on send, receive (non-retryable SQL error), and sync; red against nodes still running 1.1.0, green once they run 1.1.1 - bump CLOUDSYNC_VERSION to 1.1.1 and add the CHANGELOG entry (no PG changes, extension version stays 1.1 — no migration needed)
1 parent dfe6c8c commit a8253cb

5 files changed

Lines changed: 96 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

7+
## [1.1.1] - 2026-07-10
8+
9+
### Fixed
10+
11+
- Querying the SQLite `cloudsync_payload_chunks()` virtual table on a database where cloudsync is not initialized now raises the same actionable error as `cloudsync_payload_apply()` ("cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') ...") instead of a bare "SQL logic error". Errors raised by the internal `cloudsync_changes` scan (including the watermark computation, which previously ignored them) are now propagated with their message instead of being masked.
12+
713
## [1.1.0] - 2026-07-09
814

915
### Added

src/cloudsync.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
extern "C" {
1919
#endif
2020

21-
#define CLOUDSYNC_VERSION "1.1.0"
21+
#define CLOUDSYNC_VERSION "1.1.1"
2222
#define CLOUDSYNC_MAX_TABLENAME_LEN 512
2323

2424
#define CLOUDSYNC_VALUE_NOTSET -1

src/sqlite/cloudsync_sqlite.c

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,12 @@ static int payload_chunks_step_source(cloudsync_payload_chunks_cursor *c) {
12291229
int rc = sqlite3_step(c->src);
12301230
if (rc == SQLITE_ROW) { c->has_row = true; return SQLITE_OK; }
12311231
c->has_row = false;
1232-
return rc == SQLITE_DONE ? SQLITE_OK : rc;
1232+
if (rc == SQLITE_DONE) return SQLITE_OK;
1233+
// copy the inner statement's message onto this vtab or SQLite surfaces the
1234+
// error as a bare "SQL logic error"
1235+
if (c->vtab->base.zErrMsg) sqlite3_free(c->vtab->base.zErrMsg);
1236+
c->vtab->base.zErrMsg = sqlite3_mprintf("%s", sqlite3_errmsg(c->vtab->db));
1237+
return rc;
12331238
}
12341239

12351240
static int payload_chunks_plan_fragment(cloudsync_payload_chunks_cursor *c) {
@@ -1427,6 +1432,12 @@ static int payload_chunks_filter(sqlite3_vtab_cursor *cursor, int idxnum, const
14271432
UNUSED_PARAMETER(idxstr); UNUSED_PARAMETER(argc);
14281433
cloudsync_payload_chunks_cursor *c = (cloudsync_payload_chunks_cursor *)cursor;
14291434
cloudsync_context *data = c->vtab->data;
1435+
if (!cloudsync_context_is_initialized(data)) {
1436+
c->vtab->base.zErrMsg = sqlite3_mprintf(
1437+
"cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') "
1438+
"to enable sync on a table before querying cloudsync_payload_chunks.");
1439+
return SQLITE_ERROR;
1440+
}
14301441
if (c->src) { sqlite3_finalize(c->src); c->src = NULL; }
14311442
if (c->payload) { cloudsync_memory_free(c->payload); c->payload = NULL; }
14321443
// Contract: all per-scan state that can be bulk-reset here must live at or
@@ -1489,8 +1500,16 @@ static int payload_chunks_filter(sqlite3_vtab_cursor *cursor, int idxnum, const
14891500
sqlite3_free(mxsql);
14901501
if (rc != SQLITE_OK) return rc;
14911502
sqlite3_bind_blob(mx, 1, site_id, site_id_len, SQLITE_TRANSIENT);
1492-
if (sqlite3_step(mx) == SQLITE_ROW) until = sqlite3_column_int64(mx, 0);
1503+
// MAX() yields exactly one row, so anything else is an error: propagate
1504+
// it instead of silently scanning an empty window with until=0
1505+
rc = sqlite3_step(mx);
1506+
if (rc == SQLITE_ROW) until = sqlite3_column_int64(mx, 0);
14931507
sqlite3_finalize(mx);
1508+
if (rc != SQLITE_ROW) {
1509+
if (c->vtab->base.zErrMsg) sqlite3_free(c->vtab->base.zErrMsg);
1510+
c->vtab->base.zErrMsg = sqlite3_mprintf("%s", sqlite3_errmsg(c->vtab->db));
1511+
return rc == SQLITE_DONE ? SQLITE_ERROR : rc;
1512+
}
14941513
}
14951514
c->watermark = until;
14961515

test/integration.c

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,16 +1521,33 @@ int test_failure_path (const char *db_path) {
15211521
// Give the server time to process and fail the queued apply/check jobs.
15221522
sqlite3_sleep(5000);
15231523

1524-
// Second invocation — failures must surface now.
1525-
// jobId is always > 0 when failure object is present, so ->> + GT0 doubles as
1526-
// an existence check (NULL → atoi returns 0 → fails GT0).
1524+
// Second invocation — failures must surface now. The tenant database is not
1525+
// configured for cloudsync on the node, so every surfaced failure must carry
1526+
// the actionable "cloudsync is not initialized" message (instr on the message
1527+
// doubles as the lastFailure existence check: NULL fails GT0).
15271528
rc = db_expect_gt0(db,
1528-
"SELECT cloudsync_network_send_changes() ->> '$.send.lastFailure.jobId';"); RCHECK
1529-
rc = db_expect_gt0(db,
1530-
"SELECT cloudsync_network_receive_changes() ->> '$.receive.lastFailure.jobId';"); RCHECK
1531-
// sync must surface at least one of the two; instr() catches either path.
1529+
"SELECT instr(cloudsync_network_send_changes() ->> '$.send.lastFailure.message', 'cloudsync is not initialized');"); RCHECK
1530+
1531+
// The server reports the failed check job as non-retryable, so
1532+
// receive_changes raises a SQL error instead of returning JSON.
1533+
char *errmsg = NULL;
1534+
if (sqlite3_exec(db, "SELECT cloudsync_network_receive_changes();", NULL, NULL, &errmsg) == SQLITE_OK) {
1535+
printf("Error: expected cloudsync_network_receive_changes to fail, but it succeeded\n");
1536+
rc = SQLITE_ERROR;
1537+
goto abort_test;
1538+
}
1539+
if (!errmsg || !strstr(errmsg, "cloudsync is not initialized")) {
1540+
printf("Error: unexpected receive_changes error: %s\n", errmsg ? errmsg : "(null)");
1541+
sqlite3_free(errmsg);
1542+
rc = SQLITE_ERROR;
1543+
goto abort_test;
1544+
}
1545+
sqlite3_free(errmsg);
1546+
1547+
// sync keeps emitting structured JSON so its send block survives; the
1548+
// forwarded message must appear in at least one lastFailure object.
15321549
rc = db_expect_gt0(db,
1533-
"SELECT instr(cloudsync_network_sync(250, 1), '\"lastFailure\":');"); RCHECK
1550+
"SELECT instr(cloudsync_network_sync(250, 1), 'cloudsync is not initialized');"); RCHECK
15341551

15351552
rc = db_exec(db, "SELECT cloudsync_terminate();");
15361553

test/unit.c

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12934,6 +12934,48 @@ bool do_test_payload_chunks_positional_resume (bool print_result, bool cleanup_d
1293412934
return result;
1293512935
}
1293612936

12937+
// The /check endpoint queries cloudsync_payload_chunks on databases that may not
12938+
// be configured for cloudsync: the vtab must raise the same actionable message
12939+
// cloudsync_payload_apply gives, not a bare "SQL logic error".
12940+
bool do_test_payload_chunks_uninitialized (bool print_result, bool cleanup_databases) {
12941+
sqlite3 *db = NULL;
12942+
sqlite3_stmt *stmt = NULL;
12943+
bool result = false;
12944+
12945+
time_t timestamp = time(NULL);
12946+
int saved_counter = test_counter++;
12947+
12948+
db = do_create_database_file(0, timestamp, saved_counter);
12949+
if (!db) goto finalize;
12950+
12951+
// no cloudsync_init on purpose
12952+
if (sqlite3_prepare_v2(db,
12953+
"SELECT payload FROM cloudsync_payload_chunks(0, cloudsync_uuid_blob('0190a1b2-c3d4-7e5f-8a9b-001122334455'), NULL, 1) LIMIT 1;",
12954+
-1, &stmt, NULL) != SQLITE_OK) goto finalize;
12955+
if (sqlite3_step(stmt) != SQLITE_ERROR) goto finalize;
12956+
if (!strstr(sqlite3_errmsg(db), "cloudsync is not initialized")) goto finalize;
12957+
sqlite3_finalize(stmt); stmt = NULL;
12958+
12959+
result = true;
12960+
12961+
finalize:
12962+
if (!result && print_result) {
12963+
printf("do_test_payload_chunks_uninitialized error: %s\n", db ? sqlite3_errmsg(db) : "no db");
12964+
}
12965+
if (stmt) sqlite3_finalize(stmt);
12966+
if (db) close_db(db);
12967+
if (cleanup_databases) {
12968+
char path[256], walpath[300], shmpath[300];
12969+
do_build_database_path(path, 0, timestamp, saved_counter);
12970+
snprintf(walpath, sizeof(walpath), "%s-wal", path);
12971+
snprintf(shmpath, sizeof(shmpath), "%s-shm", path);
12972+
file_delete_internal(path);
12973+
file_delete_internal(walpath);
12974+
file_delete_internal(shmpath);
12975+
}
12976+
return result;
12977+
}
12978+
1293712979
bool do_test_payload_idempotency (int nclients, bool print_result, bool cleanup_databases) {
1293812980
sqlite3 *db[2] = {NULL, NULL};
1293912981
bool result = false;
@@ -13386,6 +13428,7 @@ int main (int argc, const char * argv[]) {
1338613428
result += test_report("Payload Chunks Site Exclusion:", do_test_payload_chunks_site_exclusion(print_result, cleanup_databases));
1338713429
result += test_report("Payload Chunks Split db_version:", do_test_payload_chunks_split_dbversion(print_result, cleanup_databases));
1338813430
result += test_report("Payload Chunks Positional Resume:", do_test_payload_chunks_positional_resume(print_result, cleanup_databases));
13431+
result += test_report("Payload Chunks Uninitialized:", do_test_payload_chunks_uninitialized(print_result, cleanup_databases));
1338913432

1339013433
// close local database
1339113434
close_db(db);

0 commit comments

Comments
 (0)