Table Diff Architecture
The design of table diff is driven by the following requirements and constraints:
- Whether a table is kept consistent via logical replication, physical replication, backup/restore, or bulk copy, operators need a fast, low-impact way to verify that the replicas really match.
- Full-table comparisons that take hours are operationally risky (locks, lag, disk and network blow-ups). We need a method that scales down to quick “am I in sync?” checks and scales up to multi-billion-row tables without bringing the cluster to its knees.
- The outcome should be actionable: pinpoint what differs (adds/deletes/changes) rather than a binary “good/bad,” so remediation can be targeted.
Existing Approaches and Their Limitations
- Dump + file diff (e.g.,
pg_dumpthendiff) - Pros: simple tooling, easy to script.
- Limitations: requires extracting the entire table; dumps must be sorted or canonicalised to be diffable; produces huge intermediates; easily runs for hours on large tables; false positives from ordering/format differences.
- Whole-table checksum
- Pros: single pass, compact result.
- Limitations: still scans the whole table; a single checksum gives no locality of differences; any drift forces another full scan to localise; sensitive to scan order; changes during the scan can invalidate results.
- Row-by-row comparison script
- Pros: can respect primary-key ordering and return precise differences.
- Limitations: typically single-threaded and network-heavy; must stream or materialise the entire table; slow on wide rows; prone to timeouts and snapshot drift if not carefully managed; bespoke scripts vary in correctness and performance.
Algorithm (Block-Hash Diff)
The table is partitioned into primary-key ranges (blocks). Identical block boundaries are applied on every node. Each range/node pair is hashed by parallel workers; only blocks whose hashes disagree are split further and, eventually, materialised for row-level diffing.
flowchart LR
%% Styles
classDef blk fill:#eef,stroke:#6b7a99,color:#1f2a44;
classDef worker fill:#f5f9ff,stroke:#5b8def,stroke-dasharray:3 2,color:#0f2b5b;
classDef cmp fill:#fff7e6,stroke:#e0a800,color:#5a4300;
classDef split fill:#fdecea,stroke:#de4c4c,color:#5b0f0f;
classDef skip fill:#e9f7ef,stroke:#2ca24c,color:#1f5b32;
classDef fetch fill:#ffffff,stroke:#6b7a99,color:#1f2a44;
classDef diff fill:#ffffff,stroke:#6b7a99,color:#1f2a44;
classDef report fill:#ffffff,stroke:#6b7a99,color:#1f2a44;
%% Blocks on Node A
subgraph Blocks_NodeA["Node A: PK-ordered blocks"]
A1["Block 1<br>PK a..b"]:::blk --> A2["Block 2<br>PK b..c"]:::blk --> A3["Block 3<br>PK c..d"]:::blk
end
%% Blocks on Node B
subgraph Blocks_NodeB["Node B: same boundaries"]
B1["Block 1<br>PK a..b"]:::blk --> B2["Block 2<br>PK b..c"]:::blk --> B3["Block 3<br>PK c..d"]:::blk
end
%% Hashing in parallel
A1 -.-> WA1["Worker hashes<br>Block 1 @A"]:::worker
B1 -.-> WB1["Worker hashes<br>Block 1 @B"]:::worker
A2 -.-> WA2["Worker hashes<br>Block 2 @A"]:::worker
B2 -.-> WB2["Worker hashes<br>Block 2 @B"]:::worker
A3 -.-> WA3["Worker hashes<br>Block 3 @A"]:::worker
B3 -.-> WB3["Worker hashes<br>Block 3 @B"]:::worker
%% Hash compare per block
subgraph CompareQ["Hash compare per block"]
WA1 --> C1{"Block 1<br>hash match?"}:::cmp
WB1 --> C1
WA2 --> C2{"Block 2<br>hash match?"}:::cmp
WB2 --> C2
WA3 --> C3{"Block 3<br>hash match?"}:::cmp
WB3 --> C3
end
%% Block-level decisions
C1 -->|match| S1["Skip block<br>(no fetch)"]:::skip
C2 -->|mismatch| Split2["Split Block 2<br>into subranges"]:::split
C3 -->|match| S3["Skip block<br>(no fetch)"]:::skip
%% Recursive path for mismatched block
Split2 --> SA["Sub-block 2a"]:::blk
Split2 --> SB["Sub-block 2b"]:::blk
SA -.-> WSA["Worker hashes<br>2a"]:::worker
SB -.-> WSB["Worker hashes<br>2b"]:::worker
WSA --> RC2{"Sub-block hashes<br>match?"}:::cmp
WSB --> RC2
RC2 -->|match| RS2["Skip sub-block"]:::skip
RC2 -->|mismatch| Fetch2["Fetch ordered rows<br>for sub-block(s)"]:::fetch
Fetch2 --> Diff2["Row diff: add/del/mod<br>respect max_diff_rows"]:::diff
Diff2 --> Report["Report & summary<br>(JSON/HTML, taskstore)"]:::report
Block Boundaries
ACE takes the block boundaries from one anchor node: the node with the
highest estimated row count. The estimate is used only to choose this node;
the number and the size of blocks do not depend on it. The code is in
internal/consistency/slicer; mtree build uses the same code.
- Parts. ACE splits the key space of the anchor node into K parts, the
units of work for the cutting workers. There is one cutting worker per
four hash workers (at most four), and four parts per cutting worker, so
K does not depend on the size of the table. The part bounds come from
pg_stats.histogram_boundsof the first primary-key column: each chosen value becomes the first key of the anchor node that is not less than it. If the column has no histogram (the table was never analyzed, or the first column of a composite key has few distinct values), the bounds come from aTABLESAMPLE SYSTEM ... REPEATABLEsample of the key, split withntile(K). The sample reads about 100 pages per part, at least 1000; a smaller table is read in full. ACE does not check how old the histogram is: an old histogram makes some parts larger (on an append-only table, the last one), but the blocks stay the same. - Blocks. A cutting worker walks its part in key order on the anchor
node and starts a new block every
block_sizerows. One query cuts a chunk of blocks by jumpingblock_sizerows forward from the current block start:
WITH RECURSIVE b(i, <key>) AS (
(SELECT 0, <key> FROM t
WHERE <key> >= <block start> AND <key> < <hi> AND <filter>
ORDER BY <key> LIMIT 1)
UNION ALL
SELECT b.i + 1, x.*
FROM b CROSS JOIN LATERAL (
SELECT <key> FROM t
WHERE <key> >= b.<key> AND <key> < <hi> AND <filter>
ORDER BY <key> OFFSET <block_size> LIMIT 1) x
WHERE b.i < <blocks in chunk>
)
SELECT i, <key> FROM b ORDER BY i
Each jump is one descent of the primary-key index that reads
block_size entries, and only one key per block goes back to ACE. When
a jump finds no row, the part ends, and ACE counts the rows of its last
block. So, when a block is cut, it holds at most block_size rows on the
anchor node, whatever the key distribution, the physical row order or
the size of the table. Rows that table_filter removes are not counted.
The limit is not a promise about other nodes or later moments: the first
and the last block are open (see below), and rows written during the run
can make any block larger.
A chunk runs in a read-only REPEATABLE READ transaction with
enable_seqscan and enable_sort off; otherwise, with a selective
filter, the planner can choose a sequential scan and a sort for every
jump. The first chunk of a part cuts one block; each next chunk cuts
twice as many, up to about one million rows. So the first block is ready
at once, and the cut does not hold back the vacuum horizon of the anchor
node. The cut does not need one snapshot: a block is a key range, and
every node hashes the same ranges. How the hashes see concurrent writes
does not change: each block hash is its own query on each node. On a laptop one cutting
worker cuts 5–6 million bigint keys per second, and the rate grows
linearly with the number of workers.
Workers send each block to the hash queue as soon as they cut it, so hashing does not wait for a full pass over the key. Blocks of different parts can arrive in any order; the comparison step sorts them by key.
The log shows how ACE built the boundaries: the part source and why, the anchor node, the number of parts and workers, and, at the end, the number of blocks, the largest block, the time to the first block and the time to cut all blocks.
Range Alignment Across Nodes
Challenge: block boundaries come from the anchor node. Other nodes may have rows that sort before the anchor’s first PK or after its last PK. If we only used closed intervals from the anchor, those edge rows would never be hashed.
- The first block has no lower bound: it is
(NULL, k_first), wherek_firstis the first key of the anchor node. On the anchor node it is empty. - The last block has no upper bound: it is
[k_last, NULL). On the anchor node it holds at mostblock_sizerows. - Each block is applied identically to every node. In
hashRange,nilmeans “no bound,” so rows of another node outside the anchor’s key range fall into the first or the last block and surface as hash mismatches. On such a node these two blocks can hold more thanblock_sizerows: the extra rows are differences, and recursion splits the block until the discrepant rows are isolated.
flowchart LR
subgraph Anchor["Anchor node (range generation)"]
A0["Start=nil<br>End=a"] --> A1["a..b"] --> A2["b..c"] --> A3["c..nil"]
end
subgraph Other["Other node (extra rows possible)"]
B0["Rows < a"] --> B1["a..b"] --> B2["b..c"] --> B3["Rows > c"]
end
A0 -.same bounds hashed.-> B0
A1 -.same bounds hashed.-> B1
A2 -.same bounds hashed.-> B2
A3 -.same bounds hashed.-> B3
classDef default fill:#eef,stroke:#6b7a99,color:#1f2a44;
Comparison Notes: Simple vs Composite Primary Keys
- Ordering and hashing
- Simple PK: ranges use scalar bounds; hashing binds one value for lower/upper when present.
- Composite PK: ranges are tuples (
ROW(col1, col2, ...)); hashing binds each component, and comparisons rely on the tuple sort order. - Range splitting
- Simple PK: median discovery and splits are straightforward using a single OFFSET/LIMIT on the ordered PK.
- Composite PK: splits still pivot on PK order but must scan and bind every key component; more columns mean more bind parameters and slightly heavier queries.
- Row materialisation
fetchRowsalways orders by the PK columns (single or composite) soCompareRowSetscan align rows deterministically.- Arrays/UDTs get cast to
TEXTto avoid OID/scan issues regardless of PK shape. - What users should watch for
- Ensure the declared PK is the true business key; otherwise, composite drift can hide behind non-unique or misordered keys.
- If the first key column has few distinct values, ACE cannot use its histogram and takes the part bounds from a sample. This is a little slower to start, but the blocks keep their size.
- Avoid nullable or unstable key components (e.g., keys derived from timestamps that can change) to keep comparisons consistent over time.
flowchart LR
%% Styles
classDef note fill:#fff7e6,stroke:#e0a800,color:#5a4300;
classDef pk fill:#eef,stroke:#6b7a99,color:#1f2a44;
%% Composite PK
subgraph CompositePK["Composite PK (lexical ordering)"]
direction LR
C1["(dept=10, id=1)"]:::pk --> C2["(dept=10, id=2)"]:::pk --> C3["(dept=20, id=1)"]:::pk --> C4["(dept=20, id=3)"]:::pk
C2 -.-> CNOTE["Sorted by ROW(dept,id)<br>(dept then id)"]:::note
end
%% Simple PK
subgraph SimplePK["Simple PK (scalar ordering)"]
direction LR
S1["pk=1"]:::pk --> S2["pk=2"]:::pk --> S3["pk=3"]:::pk
S2 -.-> SNOTE["Compare with scalar <, >, ="]:::note
end
Resource Utilisation and Tuning
- block_size: Larger blocks reduce hash tasks and recursion but increase memory/IO per hash and slow mismatch localisation; smaller blocks do the opposite (more queries, finer locality).
- concurrency_factor: CPU ratio (0.0–4.0) that scales workers relative to
NumCPU(e.g. 0.5 on a 16-CPU host spawns 8 workers). Higher = faster hashing but more load on DB backends, network, and local CPU; can contend with other workloads and connection limits. The connection pool per node is sized to match the worker count (minimum 4). - max_connections: Hard cap on the connection pool size per node. When set, overrides the concurrency-derived pool size. Useful for environments with limited
max_connectionson the database server. Workers that exceed the pool size will queue for a connection rather than fail. - compare_unit_size: Lower values push recursion deeper (more queries, smaller fetches); higher values stop earlier (fewer queries, larger fetches on mismatched ranges).
- max_diff_rows: Early-exit guardrail. Lower caps keep runs short and reports small on divergent tables; raising/removing can grow memory and report size when drift is large.
- table_filter: Narrows scope and cost; rows that do not match the filter do not count toward
block_size. Must be identical across nodes to avoid false positives. - override_block_size: Skips safety rails from
ace.yaml. Oversized blocks can spike memory and slow hashes, especially on wide rows. - output (json/html): HTML adds minor post-processing; DB load is unaffected.
Failure Modes and Safeguards
- Diff limit hit:
max_diff_rowsstops recursion early and marks the report; more differences may exist. - Permission or schema/PK mismatch: Validation fails before work starts; nothing is executed against the DB.
- Timeouts/slow ranges: Hashing is wrapped in timeouts; the first error is recorded and surfaced after workers finish.
- Stale stats: Some parts are larger, so the cutting work is less even. Block size does not depend on statistics.
Consistency Caveats
- No cross-node snapshot coordination. Concurrent writes during a run can appear as drift.
- Prefer quiescent windows or use
table_filterto target stable partitions. - PK stability matters: changing PK values mid-run can reshuffle ordering and produce noisy diffs.
Operational Tuning Playbook
- Start conservative on busy systems: smaller
concurrency_factor, moderateblock_size. - For large but mostly consistent tables: increase
block_sizeandconcurrency_factorto hash faster; keepcompare_unit_sizereasonable to localise mismatches. - For drift-heavy tables: lower
block_size/compare_unit_sizeto localise quickly; keepmax_diff_rowslow to bound runtime and report size.
Limits and Edge Cases
- Requires a declared PK; up to three-way diffs only.
table_filtercreates per-node materialized views; filters must match exactly across nodes.- Parts from an old histogram or a sample can be uneven. This changes only how the cutting work is shared between workers, not the block size.
- Wide JSON/bytea/UDT columns increase hash and fetch cost.
Observability
- Logs show range hashing progress, mismatches, recursion, and diff limits.
- Progress bars (mpb) reflect hash and mismatch-analysis stages.
- Task status and summary are persisted to SQLite (
ace_tasks.dbby default, or theACE_TASKS_DBpath) viataskstorein tableace_taskswith columns:task_id,task_type,task_status,cluster_name,task_context(JSON),schema,table_name,repset_name,diff_file_path,started_at,finished_at,time_taken. Example rows:
| task_id | task_status | cluster | schema | table_name | diff_file_path | started_at | finished_at | time_taken | task_context (truncated) |
|---|---|---|---|---|---|---|---|---|---|
| 9f7f…e21b | COMPLETED | acctg | public | customers_large | public_customers_large_diffs-20250722120353.json | 2025-07-22T12:03:51Z | 2025-07-22T12:03:53Z | 2.1 | {"qualified_table":"public.customers_large","mode":"diff","nodes":"all","diff_summary":{...}} |
| 3b2c…9aa4 | FAILED | acctg | public | orders | 2025-07-21T10:15:00Z | 2025-07-21T10:15:08Z | 8.0 | {"qualified_table":"public.orders","mode":"diff","nodes":"n1,n2","error":"user \"replicator\" lacks…"} | |
| 1a4d…c7f2 | COMPLETED | acctg | public | invoices | public_invoices_diffs-20250720115900.json | 2025-07-20T11:58:55Z | 2025-07-20T11:59:00Z | 5.0 | {"qualified_table":"public.invoices","mode":"diff","nodes":"n1,n2","table_filter":"billing_cycle = …"} |
- Diff reports write to timestamped JSON (and HTML if selected); paths are logged on completion.
-
Sample analytics (SQLite):
-
Recent table-diff runs:
SELECT task_id, task_status, started_at, finished_at, time_taken, diff_file_path FROM ace_tasks WHERE task_type = 'TABLE_DIFF' ORDER BY started_at DESC LIMIT 20; -
Success/fail counts over the last 7 days:
SELECT task_status, COUNT(*) FROM ace_tasks WHERE task_type = 'TABLE_DIFF' AND started_at >= datetime('now','-7 day') GROUP BY task_status; -
Drift summary by run (JSON fields from
task_context.diff_summary):SELECT task_id, json_extract(task_context, '$.diff_summary.total_rows_checked') AS rows_checked, json_extract(task_context, '$.diff_summary.mismatched_ranges_count') AS mismatched_ranges, json_extract(task_context, '$.diff_summary.diff_row_limit_reached') AS limit_hit FROM ace_tasks WHERE task_type = 'TABLE_DIFF' ORDER BY started_at DESC LIMIT 20;