Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,72 @@ restart is not required.
spock.read_retry_count = 5
```

### `spock.missing_update_to_insert`

Controls what the apply worker does with an `UPDATE` whose target row cannot
be found locally, after [`spock.read_retry_count`](#spockread_retry_count)
retries are exhausted.

When enabled (the default), Spock rebuilds the row from the `UPDATE` message
and inserts it, instead of raising an error. This works because an `UPDATE`
carries every replicated column of the new row, not only the columns the
statement changed. The conflict is still counted as `update_missing` and,
when `spock.save_resolutions` is on, recorded in `spock.resolutions` with a
resolution of `apply_remote`.

When disabled, the `UPDATE` fails and is handled by
[`spock.exception_behaviour`](#spock-exception_behaviour), as in Spock 5.

The main reason to leave this enabled is out-of-order arrival. In a mesh, a
node can receive an `UPDATE` from one peer before the original `INSERT`
arrives from another; with serial apply there is no ordering between those
two streams. Rebuilding the row converges correctly: when the older `INSERT`
does arrive it is resolved as `insert_exists` and loses to the newer row.

The conversion is refused, and the `UPDATE` fails as it would with the
setting off, when the row cannot be rebuilt faithfully:

* A column arrived as an **unchanged TOAST value**. PostgreSQL does not write
the TOAST chunks to WAL for an update that did not change them, and they
may already have been vacuumed, so the value is not in the message and
cannot be recovered — unless the old value travels with the UPDATE, which
it does on tables with `REPLICA IDENTITY FULL` and a `PRIMARY KEY`, or for
columns marked `LOG_OLD_VALUE`. Otherwise, inserting would silently store
a `NULL` in its place.
* A **replica identity column is not replicated**, for example because the
table was added to a replication set with a `columns` list that excludes
the key. The key would have to come from a local default, inventing a row
that matches nothing upstream.

To guarantee the conversion for a table with TOAST-able columns, give the
table a `PRIMARY KEY` and set `REPLICA IDENTITY FULL`, in that order:

```sql
ALTER TABLE mytable REPLICA IDENTITY FULL;
```

The whole old row then travels with every `UPDATE`, so the rebuild always
has every value. The cost is WAL and network volume — the full old row is
logged and sent on each `UPDATE` and `DELETE` of that table — so reserve it
for tables that need the guarantee.

!!! warning

Spock does not yet track tombstones, so the apply worker cannot
distinguish a row that has not arrived yet from one that was
deliberately deleted. If a `DELETE` newer than the `UPDATE` races it,
the conversion will bring the row back. Set this to `off` if your
workload deletes rows that are concurrently updated on another node and
you would rather the `UPDATE` fail loudly.

Valid values are `on` and `off`. Default: `on`. Changes take effect on
`SIGHUP` (for example, `SELECT pg_reload_conf()`); a server restart is not
required.

```
spock.missing_update_to_insert = on
```

### Logical Slot Failover (HA Standby)

Spock creates logical replication slots on each provider node. For high
Expand Down
46 changes: 34 additions & 12 deletions docs/conflict_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@ are recorded in `spock.exception_log`.
| `insert_exists` | INSERT | Yes |
| `update_origin_differs` | UPDATE | N/A (normal flow, not recorded) |
| `update_exists` | UPDATE | No (unique constraint violated), saved in `spock.exception_log` |
| `update_missing` | UPDATE | No (row not found), saved in `spock.exception_log` |
| `update_missing` | UPDATE | Yes, by default (row is rebuilt and inserted); see below |
| `delete_origin_differs` | DELETE | N/A (normal flow, not recorded) |
| `delete_missing` | DELETE | Yes |
| `delete_exists` | DELETE | Yes |

A conflict is **resolvable** when Spock can automatically choose a
winning tuple and continue replication without operator intervention.
`update_missing` and `update_exists` are not resolvable and result in
an ERROR that is recorded in `spock.exception_log`.
`update_exists` is not resolvable and results in an ERROR that is
recorded in `spock.exception_log`. `update_missing` is resolvable by
default, but falls back to that same behaviour when the row cannot be
rebuilt or when
[`spock.missing_update_to_insert`](configuring.md#spockmissing_update_to_insert)
is off.

---

Expand Down Expand Up @@ -97,10 +101,27 @@ replication gap.

Spock retries the lookup several times (with short waits) in case the
row is being inserted by a concurrent transaction. If the row still
cannot be found after retries, the conflict is raised.

**Resolution:** This conflict is **not resolvable**. Spock raises an
ERROR, which is logged to `spock.exception_log`.
cannot be found after retries, the conflict is reported.

**Resolution:** By default Spock rebuilds the whole row from the UPDATE
message and inserts it, resolving the conflict as `apply_remote` and
recording it in `spock.resolutions` (when `spock.save_resolutions` is on).
An UPDATE carries every replicated
column of the new row, not only the changed ones, so the rebuilt row
matches what the provider has.

Spock refuses to rebuild, and raises an ERROR logged to
`spock.exception_log` instead, when the row cannot be reconstructed
faithfully -- an unchanged TOAST column is not present in the message, or
a replica identity column is not replicated. Tables with `REPLICA IDENTITY
FULL` and a `PRIMARY KEY` are never refused for the TOAST reason: the whole
old row travels with the UPDATE, and an unchanged column's old value is its
new value. Setting
[`spock.missing_update_to_insert`](configuring.md#spockmissing_update_to_insert)
to `off` restores the Spock 5 behaviour of always raising.
Comment thread
mason-sharp marked this conversation as resolved.

Spock does not yet track tombstones, so a `DELETE` newer than the UPDATE
that races it will be undone by the rebuild. See the GUC documentation.

---

Expand Down Expand Up @@ -160,8 +181,8 @@ kept (`skip` / `keep_local`). The event is recorded in the
### Conflict Resolution Strategies

The `spock.conflict_resolution` GUC controls how resolvable conflicts
(all types except `update_missing` and `update_exists`) are decided. In
current Spock releases the only supported value is:
(all types except `update_exists`) are decided. In current Spock
releases the only supported value is:

| Strategy | Behavior |
|-----------------------|-----------------------------------------------------------|
Expand Down Expand Up @@ -228,7 +249,7 @@ each system *resolves* conflicts and where it *records* them.
| `insert_exists` | Logs and raises ERROR. | Resolves via `last_update_wins`; transforms INSERT into UPDATE of the winning tuple. |
| `update_origin_differs` | Logs and always applies the remote tuple. | Resolves via `last_update_wins`; local tuple can win. Treated as normal replication flow (not a true conflict) with optional logging via `log_origin_change`. |
| `update_exists` | Detects unique constraint violation on updated row; logs. | Logs and records in `spock.exception_log`. |
| `update_missing` | Logs and skips. | Logs and records in `spock.exception_log`. |
| `update_missing` | Logs and skips. | Rebuilds the row from the UPDATE and inserts it; records in `spock.resolutions`. Falls back to `spock.exception_log` when the row cannot be rebuilt. |
| `delete_origin_differs` | Logs and always applies the delete. | Resolves via `last_update_wins`; local tuple can win (reported as `delete_exists`). Treated as normal replication flow (not a true conflict) with optional logging. |
| `delete_missing` | Logs and skips. | Logs and skips. Records in `spock.resolutions`. |
| `delete_exists` | No equivalent. | Unique to Spock. The local row is newer than the remote DELETE, so the delete is skipped and the row is preserved. |
Expand All @@ -242,8 +263,9 @@ tuple to win when it is more recent.
**Persistence.** PostgreSQL 18 writes conflicts only to the PostgreSQL
server log. Spock additionally persists certain conflicts in the
`spock.resolutions` table (with full tuple details in JSON) --
specifically `insert_exists`, `delete_missing`, and `delete_exists` --
and non-resolvable conflicts (`update_missing`, `update_exists`) in
specifically `insert_exists`, `update_missing`, `delete_missing`, and
`delete_exists` -- and non-resolvable conflicts (`update_exists`, plus
`update_missing` when the row cannot be rebuilt) in
`spock.exception_log`. Origin-differs events are not persisted to
either table.

Expand Down
10 changes: 5 additions & 5 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ unique, not partial, not deferrable, and include only columns marked NOT
NULL. Replication has no way to find the tuple that should be updated or
deleted since there is no unique identifier.

`REPLICA IDENTITY FULL` is not supported as a standalone replication identity
for UPDATE or DELETE operations. However, it is supported when used in
conjunction with Delta-Apply columns on tables that have a primary key. For
tables without a primary key or Delta-Apply configuration, UPDATE and DELETE
operations require a PRIMARY KEY or explicit REPLICA IDENTITY USING INDEX.
`REPLICA IDENTITY FULL` is supported for UPDATE and DELETE operations only on
tables that also have a `PRIMARY KEY`: the whole old row is WAL-logged and
travels with each change, and the `PRIMARY KEY` is used to find the row on the
subscriber. A `REPLICA IDENTITY FULL` table without a `PRIMARY KEY` cannot
replicate UPDATEs or DELETEs.

## Only One Unique Index or Constraint or PK

Expand Down
9 changes: 5 additions & 4 deletions docs/spock_functions/functions/spock_repset_add_all_tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ once and passed over as a whole.
A replication set that replicates UPDATEs or DELETEs has to locate the affected
row on the subscriber, so it can only take tables with an index-based replica
identity — a PRIMARY KEY, or a unique index on NOT NULL columns nominated with
ALTER TABLE ... REPLICA IDENTITY USING INDEX. Note that REPLICA IDENTITY FULL
and REPLICA IDENTITY NOTHING do not qualify, even when the table has a PRIMARY
KEY. Tables without an index-based replica identity can be added to a set that
replicates only INSERTs and TRUNCATEs.
ALTER TABLE ... REPLICA IDENTITY USING INDEX — or REPLICA IDENTITY FULL paired
with a PRIMARY KEY, in which case the PRIMARY KEY serves for the row lookup.
REPLICA IDENTITY NOTHING never qualifies, and neither does FULL without a
PRIMARY KEY. Tables that do not qualify can be added to a set that replicates
only INSERTs and TRUNCATEs.

Unlike spock.repset_add_table(), which raises an error when the table cannot be
replicated, this function never refuses the whole call on account of a single
Expand Down
130 changes: 130 additions & 0 deletions docs/spock_release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ see *Upgrading* below before running `ALTER EXTENSION spock UPDATE`.
* **More granular conflict classification** — seven conflict types (up from
four), with origin-aware suppression and DELETE conflicts now resolved
through timestamp-based resolution.
* **`update_missing` is now resolvable** — an UPDATE whose row is gone
rebuilds the row and inserts it instead of failing. On by default; see
*UPDATE of a missing row is now applied as an INSERT* below for the
behaviour change and its one caveat.
* **`REPLICA IDENTITY FULL` tables can replicate UPDATEs and DELETEs** when
they also have a PRIMARY KEY. The whole old row travels with each change
and the PRIMARY KEY serves for the row lookup.
* **Per-subscription conflict statistics** on PostgreSQL 18+ via a custom
pgstat kind.
* **Liveness and feedback refactor** — TCP keepalive replaces the fragile
Expand Down Expand Up @@ -160,6 +167,113 @@ enables the new `delete_exists` classification — Spock can determine
whether a delete should be applied or whether a newer local version should
be preserved.

### UPDATE of a missing row is now applied as an INSERT

**This is a behaviour change, on by default.**

In v5.0, an UPDATE whose target row could not be found on the subscriber
raised an error and was handed to `spock.exception_behaviour`. In v6.0 the
apply worker rebuilds the row from the UPDATE message and inserts it. This
is possible because an UPDATE carries every replicated column of the new
row, not only the columns the statement changed, so the rebuilt row matches
what the provider has.

The motivating case is out-of-order arrival in a mesh. A node can receive
an UPDATE from one peer before the original INSERT arrives from another;
under serial apply there is no ordering between those two streams, so no
amount of waiting fixes it. Rebuilding converges correctly: when the older
INSERT does arrive it is resolved as `insert_exists` and loses to the newer
row. Row filters benefit too — a row that left a filter set and later
re-entered it used to be lost permanently on the subscriber.

The conflict is still counted as `update_missing` in the PostgreSQL 18+
conflict statistics. It no longer lands in `spock.exception_log`; it is
recorded in `spock.resolutions` with a resolution of `apply_remote` instead,
so monitoring that watched for update-missing exceptions should move to:

```sql
SELECT * FROM spock.resolutions WHERE conflict_type = 'update_missing';
```

Note that `spock.resolutions` is only written when `spock.save_resolutions`
is on, and it defaults to off — enable it if this table is your monitoring
point. A message is always emitted to the server log at
`spock.conflict_log_level` regardless.

Spock refuses to rebuild, and the UPDATE fails exactly as it did in v5.0,
when the row cannot be reconstructed faithfully:

* a column arrived as an **unchanged TOAST value**, which PostgreSQL does
not put in WAL for an update that did not change it, so the value is not
in the message and inserting would store a `NULL` in its place. Tables
with `REPLICA IDENTITY FULL` and a PRIMARY KEY are exempt — see the next
section;
* a **replica identity column is not replicated**, for example a table added
to a replication set with a `columns` list that excludes the key, where
the key would have to be invented from a local default.

**Deleted rows can come back.** Spock does not yet track tombstones, so
the apply worker cannot tell a row that has not arrived yet from one that
was deliberately deleted. If a DELETE newer than the UPDATE races it, the
rebuild brings the row back and the nodes diverge — where in v5.0 the loud
failure left them agreeing. This applies to operator deletes too: a row
removed by hand on a subscriber comes back on the next upstream UPDATE.
This closes when tombstone support lands.

Smaller behaviour notes:

* If a subscriber-side `ON DELETE CASCADE` removed a row together with its
children, the rebuild reinserts only the parent — the children stay gone.
* The rebuilt row is applied as an INSERT, so `ENABLE REPLICA` and
`ENABLE ALWAYS` INSERT triggers fire where in v5.0 nothing did.
* Some teams used the failing UPDATE (and the disabled subscription under
`sub_disable`) as a drift alarm. That alarm no longer fires; watch
`spock.resolutions` as above instead.
* In a mixed-version cluster the conversion happens only on 6.0 subscribers;
a 5.0.x subscriber behind the same provider still fails such UPDATEs.

To restore the 5.0 behaviour entirely:
`ALTER SYSTEM SET spock.missing_update_to_insert = off`, per node.

See
[`spock.missing_update_to_insert`](configuring.md#spockmissing_update_to_insert)
and [Conflict Types](conflict_types.md#update_missing).

### REPLICA IDENTITY FULL with a PRIMARY KEY

Tables with `REPLICA IDENTITY FULL` can now belong to replication sets that
replicate UPDATEs and DELETEs, provided they also have a `PRIMARY KEY`.
`spock.repset_add_table()`, `spock.repset_add_all_tables()` and
`spock.repset_alter()` all accept them; `REPLICA IDENTITY FULL` without a
`PRIMARY KEY`, and `REPLICA IDENTITY NOTHING`, are still refused.

FULL splits the two jobs a replica identity normally bundles: it decides
what is WAL-logged — the entire old row, flattened, including TOAST values —
while the `PRIMARY KEY` decides how the subscriber finds the row, via an
ordinary index lookup. The payoff is that every column of the old row
travels with each UPDATE, so the missing-row conversion described above is
never refused for an unchanged TOAST column: such tables always rebuild in
full.

The cost is WAL and network volume: the whole old row is logged and sent
with every UPDATE and DELETE of the table. Reserve it for tables that need
the guarantee.

Behaviour notes:

* Set the `PRIMARY KEY` up first and then `ALTER TABLE ... REPLICA IDENTITY
FULL`. A default-managed table stays in (or is routed into) the `default`
replication set across that ALTER; a table in a custom replication set
keeps its membership, as with any identity change.
* A FULL table that reached a replication set on an earlier release (by
altering the identity after the table was added) was located by a
sequential scan on the subscriber; it now uses the `PRIMARY KEY`. Besides
being faster, this changes conflict classification on rows that had
diverged locally: the old whole-row match reported such an UPDATE as
`update_missing`, while the key lookup finds the row and resolves it as
the update conflict it is. DELETEs of diverged rows likewise now find
and resolve rather than skip as `delete_missing`.

### Cascade replication origin tracking

v6.0 adds support for tracking and forwarding replication origins in
Expand Down Expand Up @@ -370,6 +484,11 @@ AutoDDL has been refactored and hardened:
connection alive but stops sending data. The timer resets on any
received message. Set to `0` to disable and rely solely on TCP
keepalive for liveness detection.
* `spock.missing_update_to_insert` (bool, default `on`, `SIGHUP`) —
rebuild and insert the row when an UPDATE cannot find it locally,
instead of raising. Set to `off` for the v5.0 behaviour. This
changes replication behaviour by default; see *UPDATE of a missing
row is now applied as an INSERT* above.
* `spock.output_delay` (int milliseconds, default `0`, range 0–60000,
`SIGHUP`) — artificial delay in the publisher-side output plugin.
Used to reproduce conflict and lag scenarios in tests.
Expand Down Expand Up @@ -497,6 +616,17 @@ once the binaries are swapped. The upgrade:
[Logical Slot Failover](logical_slot_failover.md) for how to handle any
skipped slots.

Also review any monitoring that alerts on `update_missing` exceptions. From
6.0 the converted updates are resolved and recorded in `spock.resolutions`
rather than `spock.exception_log`, so such an alert gets much quieter — but
not silent: an UPDATE whose row cannot be rebuilt (see the refusal cases
above), and every update-missing on a node with
`spock.missing_update_to_insert = off`, still lands in
`spock.exception_log`. Keep the exception alert for those, add the
resolutions query above for the converted ones, and note that
`spock.resolutions` rows are only written when `spock.save_resolutions` is
on (default off).

Check your runbooks and automation for direct DDL against the `spock` or
`snowflake` schemas before upgrading. Statements such as
`DROP TABLE snowflake.x` or `CREATE INDEX` on a `spock` table succeeded on
Expand Down
1 change: 1 addition & 0 deletions include/spock.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ extern int spock_pause_timeout;
extern int spock_sync_timeout;
extern int spock_read_retry_count;
extern bool check_all_uc_indexes;
extern bool missing_update_to_insert;
extern bool spock_enable_quiet_mode;
extern int log_origin_change;
extern int spock_apply_idle_timeout;
Expand Down
3 changes: 3 additions & 0 deletions include/spock_proto_native.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ typedef struct SpockTupleData
Datum values[MaxTupleAttributeNumber];
bool nulls[MaxTupleAttributeNumber];
bool changed[MaxTupleAttributeNumber];

/* a column arrived as 'u': its value is not in this message */
bool has_unchanged;
} SpockTupleData;

extern void spock_write_commit_order(StringInfo out,
Expand Down
Loading
Loading