# Sync And Maintenance The base graph is immutable CSR once built. Sync captures source table changes into durable log tables. In `mutable_overlay` mode, committed edge changes are published as durable projection segments so other backends can observe them after `graph.apply_sync()` without a full rebuild. When sync rows identify the affected source nodes, pgGraph stores that narrower dirty range in the segment metadata instead of treating the segment as whole-graph coverage. Vacuum and maintenance still rebuild the base graph to fold accumulated changes back into the core stores. Run `graph.build()` or `graph.vacuum()` after committing changes made by the calling transaction to graph registrations or registered source tables. Builds lock the mapping catalog and every registered source, including current partitions, in a stable order and hold the sync-writer fence through publication. If the caller already owns a write-capable lock, or another writer prevents the source boundary from being acquired immediately, the operation fails with SQLSTATE `55P03` and diagnostic `PG006`; the last serving generation is left unchanged. ## Modes | Mode | Current behavior | |---|---| | `manual` | No automatic trigger install. Use `graph.build()` to refresh from source tables. | | `trigger` | Default. `graph.build()` and `graph.enable_sync()` install triggers that write to `graph._sync_log`; topology reads auto-apply pending rows by default, and `graph.apply_sync()` or `graph.maintenance()` remain available for explicit operator workflows. | | `wal` | Reserved and rejected by current sync code. | ```sql ALTER SYSTEM SET graph.sync_mode = 'manual'; SELECT pg_reload_conf(); ``` ## Trigger Sync ```sql SELECT graph.enable_sync(); ``` With the default `graph.sync_mode = 'trigger'`, `graph.build()` creates trigger functions and attaches INSERT, UPDATE, DELETE, and TRUNCATE triggers to registered tables. `graph.enable_sync()` is still available when operators want to install or reinstall those triggers explicitly. Trigger rows are durable in `graph._sync_log`. After upgrading from a pgGraph release whose sync triggers predate the durable writer barrier, run `graph.enable_sync()` before resuming durable ingestion, or run `graph.build()` with `graph.sync_mode = 'trigger'`. Durable `graph.apply_sync()` and `graph.ingest_projection()` verify the installed trigger definitions and fail with upgrade guidance until every registered source table is protected. A trigger-mode build refreshes the definitions before reading source rows, so transactions using an older definition finish before the new base snapshot is taken. For named graphs, the trigger log remains source-table oriented. A source table change is written once, and `graph.apply_sync()`, query freshness, and `graph.sync_health()` filter that log to the selected graph's registered source tables. Changes for unrelated graphs do not advance or replay the selected graph's backend-local sync state. Mapped relationship INSERT, UPDATE, and DELETE replay uses the relationship source table's registered primary key as canonical identity. Deleting one of several relationship rows with equal endpoints and type therefore tombstones only that row through immediate overlays, durable ingestion, reload, and compaction. ```text source table DML | v AFTER trigger | v graph._sync_log | +--> graph.apply_sync() | +--> graph.maintenance() ``` ## Apply Sync ```sql SELECT * FROM graph.apply_sync(); ``` Return columns: | Column | Meaning | |---|---| | `inserts_applied` | Insert sync rows applied | | `updates_applied` | Update sync rows applied | | `deletes_applied` | Delete sync rows applied | Current apply behavior includes: | Change | Behavior | |---|---| | Inserted node row | Materializes mmap node store if needed, appends node, updates resolution delta, filter values, and tenant membership | | Updated node row | Handles primary-key changes, property/filter refresh, tenant refresh, and edge mutations where possible | | Deleted node row | Tombstones node in owned store | | Edge mutations in `mutable_overlay` | Published into durable projection segments and loaded through the layered read path | | Registered filter changes in persisted `mutable_overlay` graphs | Published with their exact signed numeric, boolean, text, date, timestamp-with-time-zone, UUID, or SQL `NULL` value and restored on reload | | Edge mutations outside durable projection coverage | Stored in the legacy edge overlay buffer until rebuild/vacuum | | Truncate | Marks rebuild/vacuum need | `graph.apply_sync()` drains the selected graph's durable sync rows that were pending when the call started. Internally, replay is bounded by `graph.sync_batch_size`, so large backlogs are processed in smaller batches without changing the public admin contract. The base CSR store is immutable. Durable projection segments make committed mutable-overlay edge changes visible across backends after sync is applied, but `graph.vacuum()` or `graph.maintenance()` is still the normal path for merging accumulated projection state into a clean base graph. Dirty-range segment metadata lets compaction and diagnostics reason about the affected source-node span without rewriting existing mmap'd CSR bytes. Projection segment format v6 carries stable relationship identity on topology, deletion, and weight rows so parallel source relationships remain distinct through compaction. Older segment versions are rejected across this format boundary and require a fresh `graph.build()` before the persisted mutable graph is served. The rebuild reads authoritative PostgreSQL source tables and does not rewrite application rows or graph registrations. Mutable projection manifests also reference a checksummed relationship-identity dictionary for rows ingested after the base build. This keeps mapped relationship primary keys stable across backend reloads and allows parallel rows with the same endpoints and type to remain separately hydratable. A manifest written before this dictionary reference was introduced is not served as a current mutable generation; rebuild the graph from its PostgreSQL source tables after upgrading. Trigger mode installs sync triggers on registered node tables and on mapped relationship source tables. Relationship tables do not need a separate `graph.add_table()` registration: their declared primary key and mapped endpoint columns are captured by the relationship trigger. ## Query Freshness `graph.query_freshness` controls whether topology reads apply pending trigger-sync rows before executing. The default is `apply_pending_sync`, so topology reads catch up to a captured `_sync_log` high-water mark before reading when `graph.sync_mode = 'trigger'`. | Value | Behavior | |---|---| | `apply_pending_sync` | Topology reads apply rows up to a captured `_sync_log` high-water mark before reading. | | `off` | Compatibility mode; reads do not apply pending sync rows automatically. | | `error_on_pending` | Topology reads fail when pending sync rows exist. | The topology reads covered by query freshness are `graph.traverse()` variants, shortest-path queries, weighted shortest-path queries, component APIs, and `graph.traverse_search()` before it traverses from search matches. Primitive `graph.search()` remains source-table SQL search and does not use the topology freshness helper. `graph.apply_sync()` remains the explicit operator/admin path for all pending sync rows. ## Sync Health ```sql SELECT * FROM graph.sync_health(); ``` `graph.sync_health()` returns one row of scheduler-friendly signals: | Column | Meaning | |---|---| | `query_freshness` | Current topology-read freshness mode | | `sync_batch_size` | Maximum durable sync rows replayed per internal batch | | `applied_sync_id` | Last durable sync log row applied by this backend-local engine | | `max_sync_log_id` | Current high-water row in `graph._sync_log` for the selected graph's registered tables | | `pending_sync_rows` | Durable sync rows for the selected graph newer than `applied_sync_id` | | `disabled_trigger_count` | Registered graph triggers currently disabled | | `edge_buffer_used` | Pending edge overlay mutations | | `edge_buffer_size` | Configured edge overlay capacity | | `projection_mode` | Built projection mode: `csr_readonly` or `mutable_overlay` | | `overlay_tombstone_count` | Pending overlay delete/tombstone count | | `overlay_memory_bytes` | Estimated backend-local overlay heap memory | | `compaction_recommended` | `true` when durable overlay count, tombstones, memory, or vacuum state crosses the configured compaction policy | | `tx_delta_dirty` | Whether this backend has transaction-local graph deltas | | `tx_delta_added_nodes` / `tx_delta_deleted_nodes` | Transaction-local node delta counts | | `tx_delta_added_edges` / `tx_delta_deleted_edges` | Transaction-local edge delta counts | | `tx_delta_memory_bytes` | Estimated transaction-delta heap memory | | `read_only_reason` | Typed serving-state reason such as `edge_buffer_full` when read-only | | `apply_sync_recommended` | Pending rows can be replayed by `graph.apply_sync()` while the engine is writable | | `maintenance_recommended` | Overlay, read-only, vacuum, or rebuild state should be compacted | | `durable_ingest_recommended` | Durable projection rows are waiting to be ingested | | `durable_compaction_recommended` | Durable segment fanout exceeds the configured compaction threshold | | `durable_gc_recommended` | Manifest-declared obsolete projection files can be evaluated by GC | | `durable_repair_recommended` | Active projection artifacts need targeted repair or full rebuild | | `sync_log_retention_floor` | Computed safe floor to prune `graph._sync_log` below, or `NULL` when no active backend heartbeat or durable projection watermark makes a floor safe yet | | `sync_log_prune_recommended` | `true` when a floor exists and `graph._sync_log` has grown far enough past it that pruning would meaningfully shrink it | | `active_sync_watermark_backends` | Count of currently active (unexpired) sync-watermark heartbeats for this graph; a lagging or crashed-without-cleanup backend holding an unexpired heartbeat is the usual reason pruning is blocked | ## Sync-Log Retention `graph._sync_log` durable rows accumulate as trigger-captured changes are written. `graph.maintenance()` and `graph.run_scheduled_maintenance()` prune applied rows below a safe floor as part of the existing rebuild; nothing prunes the log outside of maintenance, and `graph.apply_sync()` never deletes rows. Backend-local `applied_sync_id` (the replay progress `csr_readonly` mode tracks) is otherwise invisible to other backends, so pruning cannot safely use just the calling backend's own progress: a slower or idle backend that has not replayed a row yet would silently diverge from PostgreSQL if that row were deleted. Each backend registers a durable heartbeat of its own `applied_sync_id` in `graph._sync_watermarks` (a five-minute TTL, refreshed whenever `graph.status()` or `graph.sync_health()` runs) whenever the graph uses trigger sync. The safe floor for a maintenance pass is the minimum of: - the durable projection's own sync watermark, for a persisted `mutable_overlay` graph; - the minimum `applied_sync_id` across all currently active (unexpired) backend heartbeats for the graph. If neither is available — for example, a freshly built graph nobody has queried since, or every backend that ever touched it has gone idle past its heartbeat TTL — no floor is considered safe and pruning is skipped for that pass. The log grows a little longer on an idle graph rather than the alternative of guessing a floor is safe. A backend that goes long-lived and idle without calling `graph.status()`, `graph.sync_health()`, or applying sync will eventually have its heartbeat expire, after which pruning can proceed past its last-known watermark. `graph._graphs` tracks the highest floor ever pruned to (`sync_log_pruned_before_id`); if that backend later resumes and its own `applied_sync_id` predates that watermark, `graph.apply_sync()` and query-freshness catch-up both fail with SQLSTATE `55000` and diagnostic `PG022` instead of silently skipping the rows that are now gone. Run `graph.build()` or `graph.vacuum()` to get a fresh consistent base, then resume normal sync. ## Scheduled Maintenance Use `graph.run_scheduled_maintenance()` from `pg_cron` or an external scheduler when you want one call that applies sync if recommended and then starts background maintenance when compaction or rebuild work remains: ```sql SELECT * FROM graph.run_scheduled_maintenance(); ``` For PostgreSQL installations that use `pg_cron`: ```sql CREATE EXTENSION IF NOT EXISTS pg_cron; SELECT cron.schedule( 'pggraph-maintenance', '*/1 * * * *', $$SELECT * FROM graph.run_scheduled_maintenance();$$ ); ``` Use a slower cadence when write volume is low: ```sql SELECT cron.schedule( 'pggraph-maintenance', '*/5 * * * *', $$SELECT * FROM graph.run_scheduled_maintenance();$$ ); ``` The extension does not run an always-on internal scheduler or background loop. Operators own the maintenance cadence and lifecycle through `pg_cron`, Kubernetes CronJobs, systemd timers, Docker initialization SQL, or an application-side scheduler. Keep the scheduled statement to the single admin-only call above so pgGraph can continue to apply sync and start maintenance using its built-in health checks. ## Sync Policies And Durable Jobs Sync policies store graph-scoped replay intent in PostgreSQL catalogs so hosted schedulers can run sync consistently across backends. They do not start an always-on scheduler inside PostgreSQL. Use `pg_cron`, an application scheduler, or another host-managed worker to call `graph.run_due_jobs()` at the cadence recorded on the policy. Create a policy for a named graph: ```sql SELECT * FROM graph.add_sync_policy( 'customer_360', schedule_interval_secs := 60, max_sync_lag_rows := 10000, graph_namespace := 'analytics' ); ``` Run the policy immediately: ```sql SELECT * FROM graph.run_sync_policy(''); ``` Or run it through the generic job surface: ```sql SELECT * FROM graph.run_job(''); ``` Hosted schedulers can run all due jobs in one bounded call: ```sql SELECT * FROM graph.run_due_jobs(max_jobs := 64); ``` Installations that allow pgGraph dynamic background workers can launch one internal pass instead: ```sql SELECT graph.run_due_jobs_async(max_jobs := 64); ``` Policy execution restores the selected graph context, uses the same replay path as `graph.apply_sync()`, and records the result in `graph._job_runs`. Disabled jobs produce a `disabled` run row and do not apply sync. Concurrent runners use row locking plus a graph/job advisory lock; a contended job records `lock_skipped` instead of replaying the same work twice. `graph.run_due_jobs_async()` is a one-shot worker registration. It uses the same durable job catalogs and records `execution_mode = 'internal'`, but it does not install an always-on scheduler loop. Use `pg_cron`, a provider scheduler, or an application scheduler to choose the cadence. Inspect policy and job state: ```sql SELECT * FROM graph.sync_policy_status('customer_360', graph_namespace := 'analytics'); SELECT * FROM graph.jobs('customer_360', graph_namespace := 'analytics'); SELECT * FROM graph.job_runs(graph_name := 'customer_360', graph_namespace := 'analytics'); SELECT * FROM graph.job_stats('customer_360', graph_namespace := 'analytics'); ``` Update or remove scheduler records: ```sql SELECT * FROM graph.alter_job('', enabled := false); SELECT * FROM graph.alter_sync_policy('', schedule_interval_secs := 300); SELECT * FROM graph.remove_job(''); -- or SELECT * FROM graph.drop_sync_policy(''); ``` Policy and job listing functions only return graphs visible to the current role. Running, altering, or removing a policy/job requires graph administration for the target graph. Hard `max_graph_jobs` quotas count generic jobs, build jobs, and maintenance jobs before a new sync policy job is created. Durable job status values are `queued`, `running`, `completed`, `failed`, `disabled`, `retryable_failed`, `permanent_failed`, `quota_blocked`, and `lock_skipped`. Existing build and maintenance job APIs continue to use their legacy queued/running/completed/failed subset. ## Edge Buffer Limits `graph.edge_buffer_size` limits legacy/backend-local pending edge mutations. For persisted `mutable_overlay` graphs, committed edge changes normally use durable projection segments instead of this buffer. When the legacy buffer is full, the engine enters read-only mode, reports SQLSTATE `54000` with diagnostic `PG008` at the capacity breach, and exposes `read_only_reason = 'edge_buffer_full'`. ```sql ALTER SYSTEM SET graph.edge_buffer_size = 500000; SELECT pg_reload_conf(); ``` Recovery: ```sql SELECT * FROM graph.vacuum(); -- or SELECT * FROM graph.maintenance(); ``` ## Vacuum ```sql SELECT * FROM graph.vacuum(); ``` Return columns: | Column | Meaning | |---|---| | `nodes_before` | Node slots before rebuild | | `nodes_after` | Node slots after rebuild | | `tombstones_removed` | Removed inactive node slots | | `edges_rebuilt` | Directed edges in rebuilt CSR | | `vacuum_time_ms` | Wall-clock rebuild time | The current implementation performs a full rebuild from source tables. This is correct for immutable CSR and reclaims tombstones and overlay state. Vacuum has a double-memory period because the old and new engines coexist until the replacement build completes. Persisted rebuilds release the owned replacement before mmap validation/reload, but `graph.memory_limit_mb` still needs enough headroom for the old engine plus the peak replacement build. Set `graph.low_memory_build = on` for maintenance windows where the building backend may unload its current graph before constructing the replacement. ## Maintenance Foreground maintenance: ```sql SELECT * FROM graph.maintenance(concurrently := false); ``` Background maintenance: ```sql SELECT * FROM graph.maintenance(concurrently := true); SELECT * FROM graph.maintenance_status(''); ``` If background maintenance fails after the worker starts, pgGraph records the failed status and error detail from a fresh worker transaction so `graph.maintenance_status('')` remains the operator source of truth. Return columns: | Column | Meaning | |---|---| | `job_id` | Durable maintenance job ID, or zero UUID for foreground path | | `status` | `queued`, `running`, `completed`, or `failed` | | `sync_rows_applied` | Sync rows folded into the rebuild | | `nodes_after` | Node count after maintenance | | `edges_after` | Edge count after maintenance | | `vacuum_time_ms` | Rebuild duration | | `progress_phase` | Coarse operator phase such as `queued`, `rebuilding`, `persisting`, `validating_persistence`, `completed`, or `failed` | | `progress_message` | Short operator-readable progress or failure detail | | `error` | Failure text if any | `graph.maintenance_status(NULL)` returns the latest 50 maintenance jobs. ## Status Columns For Sync ```sql SELECT sync_mode, sync_status, edge_buffer_used, needs_vacuum, needs_rebuild, projection_mode, tx_delta_dirty, applied_sync_id, pending_sync_rows, disabled_trigger_count, invalid_reason FROM graph.status(); ``` | Column | Meaning | |---|---| | `sync_status` | `idle`, `syncing`, or `read_only` | | `edge_buffer_used` | Pending edge overlay mutations | | `needs_vacuum` | Overlay/tombstone state should be merged | | `needs_rebuild` | Catalog/schema drift requires full rebuild | | `projection_mode` | Built projection mode | | `tx_delta_dirty` | Whether this backend has transaction-local graph deltas | | `applied_sync_id` | Last durable sync log row applied by this backend-local engine | | `pending_sync_rows` | Durable sync rows newer than `applied_sync_id` | | `disabled_trigger_count` | Registered graph triggers currently disabled | Use `graph.active_generation_count()` to inspect unexpired backend heartbeats that retain durable projection generations for generation-aware cleanup. Use `graph.projection_status()` for the full durable projection diagnostic row: ```sql SELECT * FROM graph.projection_status(); ``` The row reports the active manifest generation and watermark, pending durable rows, active base/manifest/artifact bytes, segment and chunk counts/bytes, segment fanout, durable read amplification, tombstone ratio, compaction backlog, obsolete file counts/bytes, active generation count, artifact validation state, persisted last-ingestion/compaction/GC/repair timestamps, and ingestion/compaction/GC/repair recommendations. `graph.sync_health()` keeps only the lightweight durable recommendation booleans; use `graph.projection_status()` for full artifact validation. Run `graph.projection_compact()` to reduce durable segment fanout through a new copy-on-write manifest generation: ```sql SELECT * FROM graph.projection_compact( max_rows := 10000, max_segments := 1000, dirty_chunk_segment_threshold := 8 ); ``` When the dirty-chunk threshold is reached, compaction writes replacement chunk files for the affected source-node ranges and publishes a manifest that points at those chunks. Existing mmap'd base CSR bytes are not overwritten, and older generations remain available until retention and active-generation heartbeat rules allow cleanup. Run `graph.projection_gc()` to delete obsolete projection files after the configured generation retention floor and active-generation heartbeats no longer protect them. The same pass bounds crash leftovers by removing abandoned generation manifests, their unshared artifacts, and abandoned manifest or pointer temp files while holding the exclusive generation lock: ```sql SELECT * FROM graph.projection_gc(); ``` `protected_candidates` counts artifact candidates that were still referenced by retained current-generation ancestry or an active backend generation. `deleted_files` and `deleted_bytes` include obsolete artifacts, abandoned manifests and unique artifacts, and abandoned temp files removed during that call. Run `graph.projection_repair()` when load or maintenance diagnostics indicate corrupt durable projection metadata: ```sql SELECT * FROM graph.projection_repair(); ``` The result reports `healthy`, `targeted_chunk_repair`, `full_rebuild`, or `no_projection`. Full rebuilds use PostgreSQL source tables, persist a fresh `.pggraph` artifact, and publish a new base-only projection generation before the backend reloads it. ## Operational Patterns Small manual graph: ```sql ALTER SYSTEM SET graph.sync_mode = 'manual'; SELECT pg_reload_conf(); SELECT * FROM graph.build(); -- periodically: SELECT * FROM graph.build(); ``` Default trigger-sync graph: ```sql SELECT * FROM graph.build(); -- after workload: SELECT * FROM graph.apply_sync(); SELECT * FROM graph.maintenance(); ``` High-write graph: ```text Use trigger sync for visibility. Run graph.apply_sync() when graph.sync_health().apply_sync_recommended is true. Run graph.maintenance(concurrently := true) when maintenance_recommended is true. Watch edge_buffer_used, pending_sync_rows, disabled_trigger_count, read_only, and read_only_reason. ```