Skip to content

Commit 4aa7174

Browse files
committed
docs(repair): add one-pass latest-commit-wins example and plan
Add examples/repair-plan-latest-wins.yaml (fully commented, one-pass pick_freshest by commit_ts, shared across tables via a YAML anchor, with opt-in alternatives and a per-table override) and a "5g" section to advanced-repair-examples.md documenting the pattern, per-case resolution, and the caveats: positional n1/n2, track_commit_timestamp, string comparison, last-write-wins, and when commit_ts actually goes NULL.
1 parent 1f2b4a9 commit 4aa7174

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

docs/commands/repair/advanced-repair-examples.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,62 @@ tables:
169169
allow_stale_repairs: false
170170
```
171171

172+
## 5g) One-pass "latest commit wins" (complete, multi-table)
173+
174+
A ready-to-run plan that resolves every `row_mismatch` in a single pass by
175+
keeping the row with the newer Spock commit timestamp, reused across several
176+
tables via a YAML anchor. See
177+
[`examples/repair-plan-latest-wins.yaml`](examples/repair-plan-latest-wins.yaml)
178+
for the fully-commented version (opt-in alternatives, per-table override, and
179+
the caveats in detail).
180+
181+
The core rule, shared by every table via the `&latest_wins` anchor:
182+
```yaml
183+
tables:
184+
public.inventory:
185+
rules: &latest_wins
186+
- name: latest_commit_wins
187+
diff_type: [row_mismatch]
188+
action:
189+
type: custom
190+
helpers:
191+
pick_freshest: { key: commit_ts, tie: n1 }
192+
public.orders:
193+
rules: *latest_wins
194+
```
195+
196+
How each case resolves, all in one pass:
197+
198+
| Situation | Result |
199+
| --- | --- |
200+
| Both sides have `commit_ts`, and they differ | The newer one wins |
201+
| One side's `commit_ts` is NULL | The side that still has a timestamp wins |
202+
| Timestamps are equal, or both are NULL | The positional `tie` node |
203+
204+
Caveats:
205+
206+
- **`n1`/`n2` are positional, not node names.** For each compared pair, `n1`
207+
is the alphabetically-first node name and `n2` the second. `tie: n1` means
208+
"prefer the alphabetically-first node on a tie", not a node you can name; the
209+
plan DSL cannot target a physical node (that is what `--source-of-truth` is
210+
for).
211+
- **`track_commit_timestamp` must be on** across all nodes, or a node's rows
212+
always read a NULL `commit_ts` regardless of recency.
213+
- **`commit_ts` is compared as a string**, so "newer wins" is only reliable
214+
when all nodes emit it with the same UTC offset (ideally UTC).
215+
- **Last-write-wins is not the same as always-correct** — a recent errant
216+
update beats an older good value.
217+
- **A NULL `commit_ts` is rare.** PostgreSQL preserves a tuple's raw `xmin`
218+
across freezing (the frozen bit is set but `xmin` still returns the original
219+
xid), so a frozen row keeps a resolvable `commit_ts`. It only goes NULL once
220+
the xid ages past the commit-timestamp retention horizon — roughly 50M–200M
221+
transactions, tied to `vacuum_freeze_min_age` / `autovacuum_freeze_max_age` —
222+
and `pg_commit_ts` is truncated, or if `track_commit_timestamp` was off when
223+
the row was written.
224+
225+
Always run with `--dry-run --generate-report` first and review the per-row
226+
decisions before applying.
227+
172228
## 6) Custom row per PK
173229

174230
Pin a specific PK to a hand-crafted row:
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# ACE advanced repair plan: one-pass "latest commit wins".
2+
#
3+
# A single pick_freshest rule resolves every divergent row (row_mismatch) by
4+
# keeping the side with the newer Spock commit timestamp (commit_ts). commit_ts
5+
# comes from _spock_metadata_ in the diff file and is populated from
6+
# pg_xact_commit_timestamp(xmin).
7+
#
8+
# MULTI-TABLE FORM: the rule set is defined ONCE (anchored as &latest_wins on
9+
# the first table) and reused on the others via `rules: *latest_wins`. Edit the
10+
# anchored block once and every aliased table updates automatically.
11+
#
12+
# ---------------------------------------------------------------------------
13+
# IMPORTANT CAVEATS - read before running
14+
# ---------------------------------------------------------------------------
15+
# 0. n1 / n2 ARE POSITIONAL, NOT NODE NAMES. For each compared pair, n1 is the
16+
# alphabetically-first of the two node names and n2 is the second (assigned
17+
# at runtime from the diff pair; nothing to configure). So `tie: n1`,
18+
# keep_n1/keep_n2, and apply_from {from: n1|n2} all refer to a positional
19+
# slot, not a node you can name. The DSL cannot target a physical node by
20+
# name -- that is what standard repair's --source-of-truth is for. Also note
21+
# there is no wildcard table key: every table you repair must be listed
22+
# under `tables:` (aliasing the shared rule set keeps that cheap), and
23+
# table-repair processes ONE table's diff file per invocation.
24+
#
25+
# 1. ALWAYS DRY-RUN FIRST. Start with --dry-run (and --generate-report) and
26+
# review the per-row decisions before applying for real:
27+
# ace table-repair --diff-file <schema>_<table>_diffs-<ts>.json \
28+
# --repair-plan repair-plan-latest-wins.yaml \
29+
# --dry-run --generate-report
30+
# Only drop --dry-run once the report looks correct.
31+
#
32+
# 2. TIMEZONE / STRING COMPARISON. commit_ts is compared as a STRING, not as a
33+
# parsed timestamp. "Newer wins" is only reliable when every node emits
34+
# commit_ts with the SAME UTC offset (ideally all UTC). Nodes in different
35+
# timezones can make the freshest comparison pick the wrong side.
36+
#
37+
# 3. track_commit_timestamp MUST BE ON everywhere. If one node has it off, that
38+
# node's rows always show a NULL commit_ts regardless of how recently they
39+
# changed, which silently breaks the "side with a timestamp is newer" logic.
40+
#
41+
# 4. LATEST-WRITE != ALWAYS-CORRECT. This is a last-write-wins strategy: it
42+
# picks the most recently written row, which is not necessarily the
43+
# semantically correct one (a recent errant update will win over an older
44+
# good value). That is inherent to LWW, not a bug in this plan.
45+
#
46+
# 5. NULL commit_ts is RARE. PostgreSQL preserves a tuple's raw xmin across
47+
# freezing (VACUUM FREEZE sets the frozen bit but SELECT xmin still returns
48+
# the original xid), so a frozen tuple keeps a resolvable commit_ts. It only
49+
# becomes NULL when pg_commit_ts is actually truncated -- i.e. the xid ages
50+
# past the commit-timestamp retention horizon (tied to datfrozenxid) -- or
51+
# when track_commit_timestamp was off at commit time. When it does happen
52+
# this plan handles it in-pass: if only one
53+
# side is NULL the side that still has a timestamp wins (usually the recently
54+
# written one); if BOTH are NULL there is no recency signal, so the positional
55+
# `tie` node is chosen. If you would rather NOT guess on the ambiguous cases,
56+
# use ALTERNATIVE B (defer them to a --source-of-truth pass).
57+
#
58+
# 6. REQUIRES the pick_freshest commit_ts fix. pick_freshest resolves `key`
59+
# from _spock_metadata_ (where commit_ts lives). On older ACE builds that
60+
# lacked this, `key: commit_ts` silently fell back to `tie` every time. If in
61+
# doubt, confirm with --dry-run that the NEWER side is actually chosen.
62+
#
63+
# when-grammar reminders: use `and`/`or`/`not`, single `=`, `!=`,
64+
# `is null` / `is not null`. NOT supported: `&&`, `||`, `==`.
65+
66+
version: 1
67+
68+
tables:
69+
# >>> replace these keys with your qualified (schema.table) names <<<
70+
# The FIRST table defines the shared rule set (&latest_wins); the rest reuse
71+
# it with `rules: *latest_wins`. Editing the anchored block updates them all.
72+
73+
public.inventory:
74+
rules: &latest_wins
75+
# =================================================================
76+
# ACTIVE RULE - one-pass latest-commit-wins (shared by every table)
77+
# =================================================================
78+
# A single pick_freshest rule resolves EVERY row_mismatch in one pass:
79+
# - both sides have a commit_ts, different -> the NEWER one wins
80+
# - one side's commit_ts is NULL (frozen/untracked) -> the side that
81+
# still HAS a timestamp wins (it is the recently-written one)
82+
# - timestamps are exactly EQUAL, or BOTH are NULL -> the `tie` node
83+
# (positional n1 = alphabetically-first node; see caveat 0)
84+
- name: latest_commit_wins
85+
diff_type: [row_mismatch]
86+
action:
87+
type: custom
88+
helpers:
89+
pick_freshest:
90+
key: commit_ts
91+
tie: n1
92+
93+
# --- Rows that exist on only ONE side --------------------------------
94+
# pick_freshest cannot apply (nothing to compare). Uncomment to also
95+
# resolve inserts/deletes in the same pass; otherwise they are left alone.
96+
#
97+
# - name: propagate_inserts_to_n2
98+
# diff_type: [missing_on_n2]
99+
# action: { type: apply_from, from: n1, mode: upsert }
100+
#
101+
# - name: propagate_inserts_to_n1
102+
# diff_type: [missing_on_n1]
103+
# action: { type: apply_from, from: n2, mode: upsert }
104+
105+
# =================================================================
106+
# OPT-IN ALTERNATIVES - commented out
107+
# =================================================================
108+
# These edit the SHARED rule set, so a change applies to every table that
109+
# aliases *latest_wins. To vary a single table instead, give that table
110+
# its own explicit `rules:` list (see the override example at the bottom).
111+
112+
# --- ALTERNATIVE A (best when available): app-level timestamp column ---
113+
# If the table has a real, application-maintained timestamp column (e.g.
114+
# updated_at), use it as the key. Being ordinary column data it is immune
115+
# to freezing / wraparound, so it never goes NULL and gives correct
116+
# latest-wins for every row. Replaces the ACTIVE rule above.
117+
#
118+
# - name: latest_by_app_column
119+
# diff_type: [row_mismatch]
120+
# action:
121+
# type: custom
122+
# helpers:
123+
# pick_freshest: { key: updated_at, tie: n1 }
124+
125+
# --- ALTERNATIVE B (two-pass): defer ambiguous rows to source-of-truth -
126+
# Instead of letting `tie` guess on equal/NULL cases, resolve only the
127+
# clear cases here and SKIP the ambiguous ones, then run a second pass
128+
# `table-repair --source-of-truth <node>` to clean up the skipped rows by
129+
# a named authoritative node. Each case is its own rule (first match
130+
# wins), so you control equal vs NULL independently. Replaces the ACTIVE
131+
# rule above.
132+
#
133+
# - name: freshest_clear_case # both present AND different
134+
# diff_type: [row_mismatch]
135+
# when: n1.commit_ts is not null and n2.commit_ts is not null and n1.commit_ts != n2.commit_ts
136+
# action:
137+
# type: custom
138+
# helpers:
139+
# pick_freshest: { key: commit_ts, tie: n1 }
140+
#
141+
# - name: one_side_null_keep_ts_side # exactly one side has a commit_ts
142+
# diff_type: [row_mismatch]
143+
# when: (n1.commit_ts is null and n2.commit_ts is not null) or (n1.commit_ts is not null and n2.commit_ts is null)
144+
# action:
145+
# type: custom
146+
# helpers:
147+
# pick_freshest: { key: commit_ts, tie: n1 }
148+
#
149+
# - name: ambiguous_defer # equal timestamps OR both NULL
150+
# diff_type: [row_mismatch]
151+
# when: n1.commit_ts = n2.commit_ts or (n1.commit_ts is null and n2.commit_ts is null)
152+
# action: { type: skip }
153+
154+
# Additional tables reuse the exact same rule set via the alias:
155+
public.orders:
156+
rules: *latest_wins
157+
158+
public.customers:
159+
rules: *latest_wins
160+
161+
# --- PER-TABLE OVERRIDE EXAMPLE (commented) --------------------------------
162+
# To give one table DIFFERENT logic (e.g. it has an updated_at column), do NOT
163+
# alias -- give it its own rules list:
164+
#
165+
# public.events:
166+
# rules:
167+
# - name: latest_by_app_column
168+
# diff_type: [row_mismatch]
169+
# action:
170+
# type: custom
171+
# helpers:
172+
# pick_freshest: { key: updated_at, tie: n1 }

0 commit comments

Comments
 (0)