# Sync Internals Sync bridges mutable source tables and an immutable base CSR graph. The current implementation uses durable trigger logs, backend-local apply, node tombstones/inserts, filter refresh, tenant refresh, and edge overlays. Background workers run build, maintenance, or one-shot internal due-job passes. Hosted schedulers can run due durable sync policy jobs through `graph.run_due_jobs()`, and installations that allow dynamic workers can launch the same path through `graph.run_due_jobs_async()`. pgGraph does not broadcast in-place updates to every backend-local engine. ## Modules | Module | Responsibility | |---|---| | `sync.rs` | Generate trigger functions/triggers and create sync tables | | `sql_sync.rs` | Parse sync mode, read durable sync log in bounded batches, apply row changes | | `sql_build.rs` | Maintenance rebuild and vacuum orchestration | | `sql_jobs.rs` | Durable job rows, hosted job helpers, and dynamic background worker launch for build/maintenance | | `engine.rs` | Edge mutation buffer, read-only flag, overlay reduction | | `persistence.rs` | `.pggraph` load/write plus base-only projection manifest discovery | | `projection/ingest.rs` | Core committed-row to L0 segment publication logic | | `projection/layered.rs` | Base CSR plus durable-segment plus transaction-delta layered read source | | `projection/manifest.rs` | Projection manifest metadata and active-generation heartbeats | | `projection/normalize.rs` | Deterministic committed-mutation normalization and ingest buffer limits | | `projection/segment.rs` | Durable delta segment format for future layered projections | ## Durable Tables Bootstrap and sync schema helpers create: ```text graph._sync_log id bigserial primary key op char(1) table_oid oid table_name text pk text old_pk text new_pk text properties jsonb old_row jsonb new_row jsonb xid bigint needs_vacuum boolean error_message text created_at timestamptz graph._sync_buffer legacy compatibility buffer graph._projection_generations graph_id uuid generation_id bigint backend_pid integer database_oid oid heartbeat_at timestamptz expires_at timestamptz sync_watermark bigint validation_status text repair_status text is_current boolean published_at timestamptz retained_until timestamptz graph._sync_watermarks graph_id uuid backend_pid integer database_oid oid applied_sync_id bigint heartbeat_at timestamptz expires_at timestamptz graph._jobs job_id uuid primary key graph_id uuid policy_kind text enabled boolean schedule_interval_secs bigint max_runtime_secs bigint max_retries integer next_run_at timestamptz last_run_at timestamptz last_status text last_error text last_sqlstate text graph._job_runs run_id uuid primary key job_id uuid graph_id uuid started_at timestamptz finished_at timestamptz status text sqlstate text error text rows_applied bigint retry_count integer worker_identity text execution_mode text graph._sync_policies policy_id uuid primary key graph_id uuid job_id uuid schedule_interval_secs bigint max_sync_lag_rows bigint enabled boolean last_run_at timestamptz next_run_at timestamptz last_status text last_error text ``` Replay treats `id`, `op`, and `table_name` as required structural fields. The durable log keeps `old_pk` and `new_pk` nullable because valid operations need different PK images, while the legacy buffer requires its coalesced old/new PK fields before replay. For tables with a registered `tenant_column`, durable sync replay reads old and new tenant values from captured row images when available. Tenant bitmap maintenance can then touch only the affected old/new tenant entries; legacy rows without row images retain the broader compatibility fallback. ## Trigger Capture Generated triggers capture: | Operation | Captured data | |---|---| | INSERT | new PK and row properties | | UPDATE | old/new PKs and row images | | DELETE | old PK and old row | | TRUNCATE | statement-level table marker; replay uses table membership to tombstone affected nodes | The trigger layer writes durable rows. It does not update backend-local engines directly. ## Apply Flow ```mermaid flowchart TD A["capture max graph._sync_log id"] --> B["read_sync_log_entries_after(applied, batch, high_water)"] B --> C["guard edge buffer capacity for this batch"] C --> D["apply entries with shared catalog context"] D --> E{"operation"} E --> F["insert node/update resolution delta"] E --> G["update properties/filter/tenant"] E --> H["tombstone delete"] E --> I["edge overlay mutation"] F --> J["advance applied_sync_id after each row"] G --> J H --> J I --> J J --> K{"more rows up to high-water?"} K -->|yes| B K -->|no| L["apply bounded legacy buffer batches"] ``` `graph.sync_batch_size` controls the maximum durable sync rows fetched and replayed in one internal batch. `graph.apply_sync()` still drains the durable log that was pending when the call started; the bound is an internal memory and latency control, not a change to the public admin contract. `graph.query_freshness = 'apply_pending_sync'` is the default and routes topology reads through the same high-water replay primitive before they execute. The wired topology-read entrypoints are traversal variants, shortest-path queries, weighted shortest-path queries, component APIs, and `graph.traverse_search()` before its traversal phase. `graph.query_freshness = 'off'` keeps the compatibility/manual catch-up path available. `graph.search()` remains separate because it reads source-table properties through SQL rather than graph topology. `graph.sync_health()` is the operator-facing read model for this state. It combines backend-local replay progress, durable `_sync_log` high-water state, freshness configuration, trigger health, projection mode, transaction-delta counts, and overlay pressure into one row for external schedulers and monitoring checks. The durable projection metadata table records graph-scoped published generations and backend heartbeats that keep older generations alive while a backend is still using them. Cleanup logic can only remove derived projection files after they are no longer referenced by retained manifests or unexpired active-generation rows for the same graph. `projection::gc` scans valid manifest metadata, follows the checksummed current pointer through `previous_generation_id` for `graph.projection_retention_generations`, treats any generation with an active heartbeat as protected, and deletes only files that a manifest lists in `obsolete_files`. Cleanup-intent manifests remain until temporarily reader-protected obsolete files can be removed. The table is extension-owned operational state; PostgreSQL source tables remain the authoritative graph data. Projection manifests are published under a graph-scoped advisory lock and a cross-process artifact lock with a temp-file write, file fsync, directory fsync, atomic rename, final directory fsync, and current-generation compare-and-swap. Loaders hold a shared generation lock, follow `projection-current.json`, verify its manifest checksum, and validate the manifest plus active base, segment, and chunk references before exposing a generation. Legacy stores without a pointer bootstrap it from the highest valid final manifest during their first publication. GC ignores unrelated files and removes abandoned temp files only while holding the exclusive artifact lock. Projection segments use a fixed little-endian header with magic bytes, version, kind, level, direction coverage, source-node range, sync watermark, section row counts, payload offsets, CRC32 checksum, and reserved bytes that must remain zero. The loader validates offsets, checksum, row bounds, and section ownership before decoding edge topology/delete/weight sections or node, resolution, filter, and tenant sections. Before committed rows are written to segments, normalization sorts rows into a stable order, groups them by generation, direction, source, target, and edge type, cancels net-neutral insert/delete pairs, and lets delete rows win conflicts that still have a net effect. The bounded normalization buffer rejects oversized batches before segment encoding starts. Edge segment writers reject normalized node rows so node and edge deltas remain separate segment domains. The core projection ingester filters committed sync rows above the latest manifest watermark, normalizes edge mutations, writes L0 edge segments by direction, writes node/resolution/filter/tenant L0 deltas into node segments, and narrows each segment's source-node range to the rows it actually covers when that range is known. Mapped relationship source keys are interned into a deterministically ordered cumulative dictionary, written as a checksummed artifact, and referenced by projection manifest version 2. It then publishes durable no-overwrite segment files, validates segment and identity-artifact reloads, and publishes the next manifest under an artifact-root ingestion publication lock. The SQL boundary also holds the graph-scoped PostgreSQL transaction advisory lock across current-manifest loading, deterministic node allocation, validation, publication, and backend reload. Aborted transaction rows are ignored, and the manifest watermark advances only after every referenced artifact succeeds. Every generated source trigger takes a shared transaction-scoped advisory lock before it appends a sync row. Durable ingestion and persisted builds use the corresponding exclusive try-lock before reading source or sync state. Active source writers therefore make the graph operation fail with SQLSTATE `55P03`; new source writers wait behind an operation that already owns the exclusive lock. This creates a commit-safe snapshot boundary even when sequence allocation and transaction commit order differ, while shared locks still let source transactions run concurrently with one another. A same-transaction guard also rejects durable ingestion after the calling transaction has already written applicable sync rows. Before either durable apply path acquires the barrier, it verifies that all applicable row and truncate trigger functions contain the current shared-lock call. This makes older installed definitions a fail-closed upgrade condition. Trigger-mode builds reinstall the functions before reading source tables; the trigger DDL relation locks establish a clean boundary with transactions that began under an older definition. The admin-only `graph.ingest_projection(max_rows bigint DEFAULT NULL, max_bytes bigint DEFAULT NULL)` entrypoint reads committed `graph._sync_log` rows after the current projection manifest watermark, converts rows through the same registered table, edge, filter, and tenant catalog semantics as `graph.apply_sync()`, and publishes the resulting L0 generation in the selected graph's artifact root. The base graph must be persisted, such as by building with `graph.persist_on_build = on`; its current manifest identifies the exact immutable base used by the new generation. Scheduled maintenance treats a missing persisted artifact as a no-op so existing in-memory sync maintenance keeps its behavior. Trigger installation uses the union of registered node relations and mapped relationship source relations. For a standalone relationship table, replay resolves the source and target from the registered endpoint columns and uses the relationship table's primary key as the canonical identity. For an FK-style edge stored on a registered node table, replay continues to use the node primary key as the source and the mapped foreign-key column as the target. For every standalone mapping, node deletion and primary-key changes fail with rebuild guidance. PostgreSQL permits deferred foreign keys to survive an endpoint delete/reinsert transaction, so foreign-key presence alone cannot prove that an existing relationship row should move to the replacement node slot. Exact target-table resolution never falls back to another registered table; source fallback without a known table succeeds only when one registered table matches the endpoint key. The layered projection runtime merges base CSR neighbors, durable edge insert/delete/weight segments, durable node visibility and tenant membership deltas, manifest-referenced base chunk replacements, any remaining backend-local compatibility edge overlays, and transaction-local edge deltas in a fixed order. Base CSR is read first, base chunks replace their covered source-node ranges, durable tombstones suppress older base or segment edges, later durable inserts can reactivate an edge, duplicate edges are suppressed, legacy `engine.edge_buffer` changes remain visible when present, and transaction-local deltas are applied last for read-your-own-writes semantics. When a loaded projection manifest references durable segments, traversal, unweighted shortest path, weighted shortest path, connected components, and GQL relationship expansion select this layered runtime. `csr_readonly` mode and base-only manifests keep the clean CSR fast path and avoid segment lookup. Dirty base CSR ranges are repaired by publishing immutable base chunk files and referencing them from the next projection manifest generation. A base chunk is a full replacement for an inclusive/exclusive source-node range and carries its own checksum plus dirty source/edge counters. Publishing a replacement manifest does not remove older chunk files, so older generations remain readable until generation-aware cleanup makes them obsolete. If a chunk checksum fails, targeted repair rewrites the corrupted range into a new generation. `projection::recovery` validates the latest active manifest, referenced segments, and base chunks before deciding whether a projection is healthy, repairable by chunk rewrite, or requires a full rebuild. Full rebuild repair moves the latest final manifest aside before calling the persisted maintenance rebuild path, then publishes a higher base-only generation and reloads it so future backends do not select the corrupt manifest again. Compaction reduces durable segment fanout by publishing a new generation with higher-level segment files that preserve the prior layered output. L0 segments compact to L1, L1 segments compact to L2, and delete/tombstone precedence is preserved by comparing the final layered view against the base CSR for the affected source ranges. When segment pressure crosses the configured dirty chunk threshold, compaction can publish base chunk replacements instead of more segments. The admin-facing `graph.projection_compact()` function runs one bounded compaction pass. Budget failures abort before manifest publication, leaving the previous generation current and readable. When a logical `.pggraph` path is loaded, the loader takes the generation reader lock and follows the checksummed `projection-current.json` pointer to one immutable manifest. That manifest selects the exact generation-specific base; the logical path is used only as a compatibility fallback when projection metadata is absent. The engine installs only a manifest whose referenced base matches the current artifact format version and CRC32 checksum. The active read path remains CSR for base-only manifests unless the persisted build-mode sidecar says the graph was built as `mutable_overlay`. Segment-backed manifests are installed with mutable-overlay read mode so public read APIs consume durable L0/L1/L2 segment deltas after reload. Segment format v3 stores registered filter deltas as tagged, self-contained values: signed numeric, boolean, UTF-8 text, date, timestamp-with-time-zone, UUID, and SQL `NULL`. Text is stored as text rather than as a backend-local dictionary token, then interned when the manifest is installed. A filter tombstone remains distinct from SQL `NULL`. The loader validates each tag, payload length, UTF-8 string, registered column, and column domain before switching the engine to the new manifest. Filter node identifiers must be within the loaded projection's supported node range, and the base filter index's storage and text-dictionary arrays must agree before any segment value is applied. An invalid value or malformed index leaves the prior engine state unchanged. Projection segment v6 stores the exact UTF-8 primary key beside its table OID and the exact UTF-8 tenant identifier beside its hash. A batch-wide planner allocates new node slots contiguously before it emits topology, so relationships can target nodes introduced later in the same committed batch. Identity-stable tenant updates retain their existing slot, while insert/delete lifecycles keep any allocated slot as an inactive tombstone. Existing slots cannot change identity, and edge endpoints are validated against the final staged node count. The planner reads a fresh persisted base-plus-manifest snapshot while holding the graph publication lock; transaction-local serving slots therefore cannot change durable allocation order or create gaps after reload. The loader can read v5 resolution rows only when they verify an existing base node. A v5 tenant delta is rejected with rebuild guidance because its hash is not sufficient to reconstruct exact membership. Segment format v6 keys topology, tombstone, and weight rows by optional stable relationship identity, which preserves equal-endpoint parallel relationships through layered reads, compaction, and dirty-range chunk replacement. After an upgrade from an older or identity-incomplete segment format, rebuild the graph from its authoritative PostgreSQL tables before serving the mutable projection. A clean `graph.build()` publishes a compatible base and manifest; old derived segment files can then be removed by the normal generation cleanup path. Manifest version 2 adds the cumulative post-build relationship-identity dictionary reference. The loader validates its checksum, declared slot count, reserved empty slot, uniqueness, and base-dictionary prefix before installing segments. Segment relationship IDs outside that dictionary fail closed. GC protects identity artifacts referenced by retained manifests and accounts for their bytes in projection status. Ingestion generations retain all segment, base-chunk, and obsolete-file references from the preceding manifest before appending new segments. A sync batch that cannot produce a durable row does not advance the watermark. Incremental `TRUNCATE` currently fails with rebuild guidance rather than publishing an empty generation, so later generations cannot silently omit a committed source-table change. Before publishing a candidate manifest, ingestion loads the persisted base into a fresh engine and installs every candidate segment and relationship identity artifact. Only a candidate that passes this semantic reload can become current. The serving backend is replaced only by a fully loaded engine after publication; a failed validation leaves both the previous manifest and serving state intact. Inactive historical node identities remain available for relationship tombstones, so deleting an endpoint before deleting its mapped relationship row does not prevent the relationship deletion from being replayed. The engine keeps base manifest generation and sync watermark metadata backend-locally, and `graph.active_generation_count()` exposes the selected graph's active heartbeat count so operators can see whether concurrent backends are retaining projection generations for that graph. The SQL `graph.status()` table is already at pgrx's tuple-return arity limit, so durable projection diagnostics live in `graph.projection_status()`. That row is computed by `projection::status` from the latest manifest, persisted operation timestamps, and referenced artifact metadata. `graph.sync_health()` uses the metadata-only status collector so scheduler polling does not decode every durable projection segment; full artifact validation remains on `graph.projection_status()` and repair paths. Scheduler ownership intentionally stays outside the extension. PostgreSQL background-worker lifecycle, crash recovery, privilege boundaries, and cadence controls belong to pg_cron, Kubernetes CronJobs, systemd timers, Docker init SQL, or application schedulers; the stable pgGraph boundary is the admin-only `graph.run_scheduled_maintenance()` call. ## Node Changes Mmap-backed node stores cannot be mutated. Before node mutation, the engine materializes node data into owned arrays. Inserted nodes are appended and added to the indexed `resolution_delta`; deleted nodes are tombstoned through `NodeStore`. ## Edge Changes The base `EdgeStore` remains immutable. For persisted `mutable_overlay` graphs, `graph.apply_sync()` publishes committed sync edge mutations into durable L0 projection segments, reloads the latest manifest, and records the applied watermark. A fresh backend can load the persisted graph artifact, ingest the committed sync rows, and observe the new durable edge segment without a full rebuild. `csr_readonly` graphs and non-persisted graphs keep the clean CSR path unless an explicit legacy overlay path is needed. Transaction-local edge deltas live in `TxGraphDelta`. Overlay-aware graph algorithms reduce durable segments, any remaining backend-local compatibility overlays, and transaction-local deltas into insert and delete overlays for the selected direction, with transaction-local deltas applied last for read-your-own-writes semantics. Traversal, unweighted shortest path, weighted shortest path, connected components, and read-only GQL relationship expansion consume the shared neighbor-source abstraction. When the legacy edge buffer reaches `graph.edge_buffer_size`, the engine enters read-only mode with `read_only_reason = 'edge_buffer_full'` and returns `EdgeBufferFull`. Later sync mutations against an already read-only engine return `ReadOnly` with the stored reason. ## Filter And Tenant Refresh `sql_sync.rs` refreshes registered filter values from authoritative new row images and publishes exact typed values into durable projection segments for persisted `mutable_overlay` graphs. Reload applies those values to a staged filter index before the manifest becomes active. Filter-only rows do not change node lifecycle state. Tenant membership is a `HashMap` from tenant value to node indices. ## Maintenance Rebuild Maintenance applies pending sync state and rebuilds from source tables. This folds: | State | Folded into | |---|---| | Tombstones | Fresh NodeStore without deleted rows | | Edge overlays | Fresh CSR stores | | Resolution delta | Fresh finalized ResolutionIndex | | Filter changes | Fresh FilterIndex | | Tenant membership | Fresh tenant bitmaps | ## Sync-Log Retention `graph._sync_log` rows are never deleted outside of `sql_sync::prune_sync_log` and `sql_sync::prune_sync_log_if_safe`, called only from `sql_build::execute_maintenance_rebuild_with_progress` (both the foreground `graph.maintenance()` path and the background maintenance worker path). `graph.apply_sync()` never deletes rows. Backend-local `applied_sync_id` (`csr_readonly` mode's replay progress) is otherwise invisible outside the owning backend, so a naive `DELETE ... WHERE id < X` could remove a row a slower or idle backend has not replayed yet. `sql_sync::record_sync_watermark_heartbeat` registers each backend's `applied_sync_id` into `graph._sync_watermarks` (TTL `SYNC_WATERMARK_HEARTBEAT_TTL`, currently 300s) whenever `sql_facade::admin::refreshed_engine_status` runs — the shared path behind `graph.status()` and `graph.sync_health()` — and only when `current_sync_mode() == SyncMode::Trigger`, since a `manual`-mode graph's `applied_sync_id` never advances past 0. `sql_sync::sync_watermark_diagnostics` computes the safe floor as the minimum of the durable projection's own `sync_watermark` (for a persisted `mutable_overlay` graph) and the minimum `applied_sync_id` across all currently unexpired heartbeats. `compute_sync_log_retention_floor` (a pure function, unit- and proptest-covered independent of SPI) implements the bootstrap safety rule: `(None, None) => None`, never a default floor of `0` or any other guess. `sql_sync::prune_sync_log` deletes `graph._sync_log` rows with `id <= floor` (inclusive: a row exactly at the floor is, by construction, already applied by everyone counted in the computation) scoped to the selected graph's registered table oids via `SyncReplayContext::applicable_table_oids`, matching the same scoping `read_sync_log_entries_after` already uses. Every successful prune records its floor into `graph._graphs.sync_log_pruned_before_id` as a running maximum (`sql_sync::record_sync_log_prune_floor`). `apply_sync_to_high_watermark` checks `sql_sync::ensure_sync_replay_not_pruned(applied_sync_id)` before doing any work — the single shared entry point for both explicit `graph.apply_sync()` (via `apply_sync_internal`) and the `apply_pending_sync` query-freshness auto-apply path (`sql_facade::runtime::ensure_current_graph_for_query`). A backend whose `applied_sync_id` predates a completed prune fails closed with `GraphError::SyncLogPruned` (`PG022`) instead of silently replaying past a gap. `applied_sync_id == 0` is exempt from this check — a backend that has never applied anything is not blocked by a prior prune. See: `todo/full-graph-engine/12-sync-log-retention-plan.md` for the original design and `todo/progress.md`'s 2026-07-17 entry for the implementation record. ## Status Interaction Runtime status and graph validation refresh: | Field | Source | |---|---| | `pending_sync_rows` | Count of sync log rows above `applied_sync_id` | | `disabled_trigger_count` | Catalog inspection of disabled graph triggers | | `schema_state` | Current catalog/schema drift validation | | `needs_vacuum` | Edge/tombstone overlay state | | `needs_rebuild` | Catalog/schema drift | ## Current Boundaries | Boundary | Current code behavior | |---|---| | WAL sync mode | Reserved and rejected for active use | | Query-time hidden catch-up | Queries do not silently apply all pending durable sync rows | | CSR mutation | Not supported; use overlays then rebuild | | Cross-backend engine sync | Backend-local; persisted files and source tables coordinate state | | Sync-log retention | Pruned only from `graph.maintenance()`/`run_scheduled_maintenance()`, only below a floor backed by active backend heartbeats or the durable projection watermark; never by `graph.apply_sync()` |