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
feat(batch-merge): add statement cache, per-PK savepoints, and SQLite UPDATE path
- Add statement cache to merge_pending_batch: reuse prepared statements across consecutive PK flushes when the column combination and row_exists flag match, recovering the precompiled-statement advantage of the old single-column path.
- Wrap merge_flush_pending in a per-PK savepoint so that on RLS denial RollbackAndReleaseCurrentSubTransaction properly releases all executor resources (open relations, snapshots, plan cache). This eliminates the "resource was not closed" warnings from SPI_finish after RLS errors.
- Implement sql_build_update_pk_and_multi_cols for SQLite with numbered bind parameters (UPDATE "t" SET "col"=?2 WHERE "pk"=?1). Previously delegated to the UPSERT builder, which fails RLS INSERT WITH CHECK when the payload contains only a subset of columns. Required for SQLiteCloud which enforces RLS on the SQLite extension.
- Add PostgreSQL test 28 (db_version_tracking): replicates the SQLite "Merge Test db_version 1/2" tests, verifying data roundtrip and (db_version, seq) uniqueness after one-way and bidirectional merges.
- Fix error path cleanup: goto/early-return paths in cloudsync_payload_apply now free cached_vm and cached_col_names.
-`merge_pending_entry` — one buffered column (col_name, col_value via `database_value_dup`, col_version, db_version, site_id, seq)
18
-
-`merge_pending_batch` — collects entries for one PK (table, pk, row_exists flag, entries array)
18
+
-`merge_pending_batch` — collects entries for one PK (table, pk, row_exists flag, entries array, statement cache)
19
19
20
20
`data->pending_batch` is set to `&batch` (stack-allocated) at the start of `cloudsync_payload_apply`. The INSTEAD OF trigger calls `merge_insert`, which calls `merge_pending_add` instead of `merge_insert_col`. Flush happens at PK/table/db_version boundaries and after the loop.
21
21
22
22
### UPDATE vs UPSERT (`row_exists` flag)
23
23
24
24
`merge_insert` sets `batch->row_exists = (local_cl != 0)` on the first winning column. At flush time `merge_flush_pending` selects:
25
25
26
-
-`row_exists=true` -> `sql_build_update_pk_and_multi_cols` -> `UPDATE docs SET title=$2::text WHERE id=$1::text`
26
+
-`row_exists=true` -> `sql_build_update_pk_and_multi_cols` -> `UPDATE docs SET title=? WHERE id=?`
27
27
-`row_exists=false` -> `sql_build_upsert_pk_and_multi_cols` -> `INSERT ... ON CONFLICT DO UPDATE`
28
28
29
-
For SQLite, `sql_build_update_pk_and_multi_cols` delegates to the UPSERT builder (no RLS).
29
+
Both SQLite and PostgreSQL implement `sql_build_update_pk_and_multi_cols` as a proper UPDATE statement. This is required for SQLiteCloud (which uses the SQLite extension but enforces RLS).
30
+
31
+
**Example**: DB A and DB B both have row `id='doc1'` with `user_id='alice'`, `title='Hello'`. Alice updates `title='World'` on A. The payload applied to B contains only `(id, title)`:
32
+
33
+
-**UPSERT** (wrong for RLS): `INSERT INTO docs ("id","title") VALUES (?,?) ON CONFLICT DO UPDATE SET "title"=EXCLUDED."title"` — fails INSERT `WITH CHECK` because `user_id` is NULL in the proposed row.
34
+
-**UPDATE** (correct): `UPDATE "docs" SET "title"=?2 WHERE "id"=?1` — skips INSERT `WITH CHECK` entirely; the UPDATE `USING` policy checks the existing row which has the correct `user_id`.
35
+
36
+
In plain SQLite (no RLS) both produce the same result. The distinction only matters when RLS is enforced (SQLiteCloud, PostgreSQL).
37
+
38
+
### Statement cache
39
+
40
+
`merge_pending_batch` caches the last prepared statement (`cached_vm`) along with the column combination and `row_exists` flag that produced it. On each flush, `merge_flush_pending` compares the current column names, count, and `row_exists` against the cache:
41
+
42
+
-**Cache hit**: `dbvm_reset` + rebind (skip SQL build and `databasevm_prepare`)
43
+
-**Cache miss**: finalize old cached statement, build new SQL, prepare, and update cache
44
+
45
+
This recovers the precompiled-statement advantage of the old single-column path. In a typical payload where consecutive PKs change the same columns, the cache hit rate is high.
46
+
47
+
The cached statement is finalized once at the end of `cloudsync_payload_apply`, not on every flush.
30
48
31
49
### `last_payload_db_version` fix
32
50
@@ -40,6 +58,63 @@ if (db_version_changed) {
40
58
41
59
Previously this was inside `if (!in_transaction && db_version_changed)`, which never ran in SPI.
42
60
61
+
## Savepoint Architecture
62
+
63
+
### Two-level savepoint design
64
+
65
+
`cloudsync_payload_apply` uses two layers of savepoints that serve different purposes:
These savepoints group rows with the same source `db_version` into one transaction. The `RELEASE` (commit) at each db_version boundary triggers `cloudsync_commit_hook`, which:
82
+
- Saves `pending_db_version` as the new `data->db_version`
83
+
- Resets `data->seq = 0`
84
+
85
+
This ensures unique `(db_version, seq)` tuples in `cloudsync_changes` across groups.
86
+
87
+
**In PostgreSQL SPI, these are dead code**: `database_in_transaction()` returns `true` (via `IsTransactionState()`), so the condition `!database_in_transaction(data)` is always false and `in_savepoint` is never set. This is correct because:
88
+
1. PostgreSQL has no equivalent commit hook on subtransaction release
89
+
2. The SPI transaction from `SPI_connect` already provides transaction context
90
+
3. The inner per-PK savepoint handles the RLS isolation PostgreSQL needs
91
+
92
+
**Why a single outer savepoint doesn't work**: We tested replacing per-db_version savepoints with a single savepoint wrapping the entire loop. This broke the `(db_version, seq)` uniqueness invariant in SQLite because the commit hook never fired mid-apply — `data->db_version` never advanced and `seq` never reset.
93
+
94
+
### Inner savepoints: per-PK in `merge_flush_pending`
Wraps each PK's flush in a savepoint. On failure (e.g. RLS denial), `database_rollback_savepoint` calls `RollbackAndReleaseCurrentSubTransaction()` in PostgreSQL, which properly releases all executor resources (open relations, snapshots, plan cache) acquired during the failed statement. This eliminates the "resource was not closed" warnings that `SPI_finish` previously emitted.
107
+
108
+
In SQLite, when the outer per-db_version savepoint is active, these become harmless nested savepoints.
| **PostgreSQL SPI** | Dead code (`in_transaction` always true) | Active — RLS error isolation + resource cleanup | Only inner savepoint runs |
115
+
| **SQLite client** | Active — groups writes, triggers commit hook | Active — nested inside outer, harmless | Both run; outer provides transaction grouping |
116
+
| **SQLiteCloud** | Active — groups writes, triggers commit hook | Active — RLS error isolation | Both run; each serves its purpose |
117
+
43
118
## SPI and Memory Management
44
119
45
120
### Nested SPI levels
@@ -48,33 +123,45 @@ Previously this was inside `if (!in_transaction && db_version_changed)`, which n
48
123
49
124
### `database_in_transaction()` in SPI
50
125
51
-
Always returns true in SPI context. No savepoints are created. This is why `last_payload_db_version` must be updated unconditionally — the savepoint-gated update path is dead code in PostgreSQL.
126
+
Always returns true in SPI context (`IsTransactionState()`). This makes the per-db_version savepoints dead code in PostgreSQL and is why `last_payload_db_version` must be updated unconditionally.
52
127
53
128
### Error handling in SPI
54
129
55
-
When RLS denies a write, PostgreSQL raises an error inside SPI which is caught by `PG_CATCH()` in `databasevm_step`. Since there are no savepoints, an RLS denial aborts the current SPI transaction for subsequent SQL within that `cloudsync_payload_apply` call.
130
+
When RLS denies a write, PostgreSQL raises an error inside SPI. The inner per-PK savepoint in `merge_flush_pending` catches this: `RollbackAndReleaseCurrentSubTransaction()` properly releases all executor resources. Without the savepoint, `databasevm_step`'s `PG_CATCH` + `FlushErrorState()` would clear the error stack but leave executor resources orphaned, causing `SPI_finish` to emit "resource was not closed" warnings.
56
131
57
132
### Batch cleanup paths
58
133
59
-
`batch.entries` is heap-allocated via `cloudsync_memory_realloc` and reused across flushes. Each entry's `col_value` (from `database_value_dup`) is freed by `merge_pending_free_entries` on every flush. The entries array itself is freed once at the end of `cloudsync_payload_apply`. Error paths (`goto cleanup`, early returns) must call `merge_pending_free_entries` before freeing the array to avoid leaking `col_value` copies.
134
+
`batch.entries` is heap-allocated via `cloudsync_memory_realloc` and reused across flushes. Each entry's `col_value` (from `database_value_dup`) is freed by `merge_pending_free_entries` on every flush. The entries array, `cached_vm`, and `cached_col_names` are freed once at the end of `cloudsync_payload_apply`. Error paths (`goto cleanup`, early returns) must free all three and call `merge_pending_free_entries` to avoid leaking `col_value` copies.
135
+
136
+
## Batch Apply: Pros and Cons
137
+
138
+
The batch path is used for all platforms (SQLite client, SQLiteCloud, PostgreSQL), not just when RLS is active.
139
+
140
+
**Pros (even without RLS)**:
141
+
- Fewer SQL executions: N winning columns per PK become 1 statement instead of N. Each `databasevm_step` involves B-tree lookup, page modification, WAL write.
142
+
- Atomicity per PK: all columns for a PK succeed or fail together.
143
+
144
+
**Cons**:
145
+
- Dynamic SQL per unique column combination (mitigated by the statement cache).
146
+
- Memory overhead: `database_value_dup` copies each column value into the buffer.
**Why not maintain two paths**: SQLiteCloud uses the SQLite extension with RLS, so the batch path (UPDATE vs UPSERT selection, per-PK savepoints) is required there. Maintaining a separate single-column path for plain SQLite clients would double the code with marginal benefit.
60
150
61
151
## Files Changed
62
152
63
153
| File | Change |
64
154
|------|--------|
65
-
|`src/cloudsync.c`| Batch merge structs, `merge_pending_add`, `merge_flush_pending`, `merge_pending_free_entries`; `pending_batch` field on context; `row_exists` propagation in `merge_insert`; batch mode in `merge_sentinel_only_insert`; `last_payload_db_version` fix; removed `payload_apply_callback`|
155
+
| `src/cloudsync.c` | Batch merge structs with statement cache (`cached_vm`, `cached_col_names`), `merge_pending_add`, `merge_flush_pending` (with per-PK savepoint), `merge_pending_free_entries`; `pending_batch` field on context; `row_exists` propagation in `merge_insert`; batch mode in `merge_sentinel_only_insert`; `last_payload_db_version` fix; removed `payload_apply_callback` |
| `docs/postgresql/RLS.md` | Documented INSERT vs UPDATE paths and partial-column RLS interaction |
73
163
74
-
## TODO:
164
+
## TODO
75
165
76
-
- check the working logs on test psql:test/postgresql/27_rls_batch_merge.sql:246: WARNING: resource was not closed: relation "documents_pkey"
77
-
- fully implement sql_build_update_pk_and_multi_cols in the sqlite extension because sqlitecloud has RLS even if sqlite doesn't
78
-
- the batch apply is better than single apply even if rls is not set? for example, in sqlite client there is no RLS, should we completely exclude this new code and follow the old path or it is still better to use this batch apply path? pros/cons
79
-
- there is still an issue of postgres rollbacking the full apply transaction if a change apply is denied by RLS because of the savepoints are not used inside transactions?
80
-
- add a new test like the n° 27 with more columns and more cases
166
+
- add a new test like the n° 27 with more columns and more cases
167
+
- update documentation: RLS.md, README.md and the https://github.com/sqlitecloud/docs repo
0 commit comments