Skip to content

Commit dbda797

Browse files
committed
feat: first implementation of batch merge and rls, remove the callback previously used to approve changes using the RLS select rule
1 parent a6f8b1b commit dbda797

13 files changed

+869
-1164
lines changed

plans/BATCH_MERGE_AND_RLS.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Deferred Column-Batch Merge and RLS Support
2+
3+
## Problem
4+
5+
CloudSync resolves CRDT conflicts per-column, so `cloudsync_payload_apply` processes column changes one at a time. Previously each winning column was written immediately via a single-column `INSERT ... ON CONFLICT DO UPDATE`. This caused two issues with PostgreSQL RLS:
6+
7+
1. **Partial-column UPSERT fails INSERT WITH CHECK**: An update to just `title` generates `INSERT INTO docs (id, title) VALUES (...) ON CONFLICT DO UPDATE SET title=...`. PostgreSQL evaluates the INSERT `WITH CHECK` policy *before* checking for conflicts. Missing columns (e.g. `user_id`) default to NULL, so `auth.uid() = user_id` fails. The ON CONFLICT path is never reached.
8+
9+
2. **Premature flush in SPI**: `database_in_transaction()` always returns true inside PostgreSQL SPI. The old code only updated `last_payload_db_version` inside `if (!in_transaction && db_version_changed)`, so the variable stayed at -1, `db_version_changed` was true on every row, and batches flushed after every single column.
10+
11+
## Solution
12+
13+
### Batch merge (`merge_pending_batch`)
14+
15+
New structs in `cloudsync.c`:
16+
17+
- `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)
19+
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+
22+
### UPDATE vs UPSERT (`row_exists` flag)
23+
24+
`merge_insert` sets `batch->row_exists = (local_cl != 0)` on the first winning column. At flush time `merge_flush_pending` selects:
25+
26+
- `row_exists=true` -> `sql_build_update_pk_and_multi_cols` -> `UPDATE docs SET title=$2::text WHERE id=$1::text`
27+
- `row_exists=false` -> `sql_build_upsert_pk_and_multi_cols` -> `INSERT ... ON CONFLICT DO UPDATE`
28+
29+
For SQLite, `sql_build_update_pk_and_multi_cols` delegates to the UPSERT builder (no RLS).
30+
31+
### `last_payload_db_version` fix
32+
33+
Moved the update outside the savepoint block so it executes unconditionally:
34+
35+
```c
36+
if (db_version_changed) {
37+
last_payload_db_version = decoded_context.db_version;
38+
}
39+
```
40+
41+
Previously this was inside `if (!in_transaction && db_version_changed)`, which never ran in SPI.
42+
43+
## SPI and Memory Management
44+
45+
### Nested SPI levels
46+
47+
`pg_cloudsync_payload_apply` calls `SPI_connect` (level 1). Inside the loop, `databasevm_step` executes `INSERT INTO cloudsync_changes`, which fires the INSTEAD OF trigger. The trigger calls `SPI_connect` (level 2), runs `merge_insert` / `merge_pending_add`, then `SPI_finish` back to level 1. The deferred `merge_flush_pending` runs at level 1.
48+
49+
### `database_in_transaction()` in SPI
50+
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.
52+
53+
### Error handling in SPI
54+
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.
56+
57+
### Batch cleanup paths
58+
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.
60+
61+
## Files Changed
62+
63+
| File | Change |
64+
|------|--------|
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` |
66+
| `src/cloudsync.h` | Removed `CLOUDSYNC_PAYLOAD_APPLY_STEPS` enum |
67+
| `src/database.h` | Added `sql_build_upsert_pk_and_multi_cols`, `sql_build_update_pk_and_multi_cols`; removed callback typedefs |
68+
| `src/sqlite/database_sqlite.c` | Implemented `sql_build_upsert_pk_and_multi_cols` (dynamic SQL); `sql_build_update_pk_and_multi_cols` (delegates to upsert); removed callback functions |
69+
| `src/postgresql/database_postgresql.c` | Implemented `sql_build_update_pk_and_multi_cols` (meta-query against `pg_catalog` generating typed UPDATE) |
70+
| `test/unit.c` | Removed callback code and `do_test_andrea` debug function |
71+
| `test/postgresql/27_rls_batch_merge.sql` | Tests 1-3 (superuser) + Tests 4-6 (authenticated-role RLS enforcement) |
72+
| `docs/postgresql/RLS.md` | Documented INSERT vs UPDATE paths and partial-column RLS interaction |
73+
74+
## TODO:
75+
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

plans/ISSUE_POSTGRES_SCHEMA.md

Lines changed: 0 additions & 73 deletions
This file was deleted.

plans/ISSUE_WARNING_resource_was_not_closed.md

Lines changed: 0 additions & 64 deletions
This file was deleted.

0 commit comments

Comments
 (0)