Skip to content

fix(postgres): prevent reconciliation storage bloat - #59

Open
tnunamak wants to merge 9 commits into
mainfrom
waspflow/db-bloat-fix-0903-main
Open

fix(postgres): prevent reconciliation storage bloat#59
tnunamak wants to merge 9 commits into
mainfrom
waspflow/db-bloat-fix-0903-main

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Repeatedly syncing unchanged records and search data can grow PostgreSQL storage by rewriting rows unnecessarily.

This change skips writes when record metadata or search entries already match, including during text-index rebuilds. It also reclaims unreferenced binary payloads when records are deleted. PostgreSQL cleanup locks each candidate payload before checking its record links in a separate statement within the same transaction, so a concurrently committed link keeps its bytes. This protects deletion of one record feed and deletion of an entire connection to a data source.

Cleanup stops waiting for a locked payload after the configured timeout and returns a busy error. It processes at most 256 payload IDs per batch. An upload whose payload is deleted before its record link can be stored returns an HTTP 409 conflict with retry guidance.

The change also configures automatic vacuuming and adds an off-peak maintenance job that reports its outcome. Vacuum makes space reusable; it does not shrink database files. Manual storage reductions are not measurements of this change.

Validation: server TypeScript checks and the focused PostgreSQL and SQLite suites pass. Regression tests prove preservation of concurrent links and exact bytes, bounded failure behind a held lock, cleanup across batch boundaries, successful publication after an explicit retry, and preservation of legacy payloads during index setup. Existing tests check bounded storage growth for unchanged data. The full test suite and production-scale contention were not tested locally.

Assisted-by: AI

@tnunamak
tnunamak force-pushed the waspflow/db-bloat-fix-0903-main branch from 3dbf49b to cc4d3be Compare September 3, 2026 23:46
@tnunamak

tnunamak commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Red checks: pre-existing seam, not caused by this branch

The failing checks here fail the same way on main, at this PR's own base commit (a72cb53a8). Baseline run: https://github.com/PDP-Connect/data-connect/actions/runs/33702664046 — on main it shows test reference implementation, typecheck reference implementation, and the reference-implementation gate all red.

test reference implementation — the set of failing test files is identical on this PR and on main:

  • test/connector-summary-fold-fleet-scope-contamination.test.ts
  • test/connector-summary-fold-page-scope-zero-history-reproduction.test.ts
  • test/consent-connection-label.test.ts
  • test/pdpp-vendored-runtime-compatibility.test.ts

Comparing the two runs file by file yields an empty set of PR-only failures. The last two fail with ERR_MODULE_NOT_FOUND for paths outside reference-implementation/ (apps/site/src/lib/consent-connection-label.ts and scripts/check-pdpp-vendored-package-pins.ts), so they are missing-module errors rather than assertion failures. The tests added or changed by this PR do not appear in the failure set — they pass in CI.

typecheck reference implementation — red on main too. None of its diagnostics name a file this PR touches; grepping the CI log for the seven changed paths returns zero matches. The errors are the known dependency and Node-version type incompatibilities (undici Headers, RequestInit.dispatcher, Timeout vs number, exactOptionalPropertyTypes mismatches).

build (windows-latest, …) — infrastructure, not code. The log shows the Rust toolchain download failing (curl errors against https://win.rustup.rs), Windows EPERM: operation not permitted, rmdir on node_modules, and a better-sqlite3 node-gyp rebuild failure. The macOS x64, macOS arm64, and Linux builds in the same workflow all pass. Nothing in this PR changes native modules or build configuration; it touches SQL and TypeScript under reference-implementation/ only.

Green on this PR: DCO, check-spdx-headers, classify reference-impacting changes, pack-install-run, and the three non-Windows builds.

I am not merging this. The classification above says these failures are not caused by this branch; it does not say the seam is fine to leave broken, and that call belongs to a maintainer.

Assisted-by: AI

@tnunamak

tnunamak commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebase onto current main was blocked before the first signed commit: GPG opened interactive pinentry; cancellation caused signing to fail. The batch stopped under the requested GPG rule. This PR was not rebased. Head: cc4d3becc4d3be (unchanged). No new CI ran; existing checks show 7 success, 4 failure, 1 skipped. Needs work: unlock GPG and retry the rebase/CI verification.

Assisted-by: AI

@tnunamak

tnunamak commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Locally rebased onto main bac5526 with no conflicts: cc4d3be → 56357c25359472946c7b4f680e9f6f2924210d0f. Original patches are unchanged (range-diff and stable patch-ID comparison).

PARKED: unattended GPG signing failed with “No pinentry”. Rewritten commits have Tim’s identity and DCO but remain unsigned locally under the owner’s fallback policy; nothing was pushed. The remote head and its old hosted CI results are unchanged; no fresh hosted checks ran.

Preservation check passed: all 68 focused tests passed with no skips, including integration checks on an isolated disk-backed PostgreSQL 16.15 database. Current hosted CI only runs the memory profile, so it cannot verify this PostgreSQL behavior. Ready for owner review of local evidence.

Assisted-by: AI

@tnunamak
tnunamak force-pushed the waspflow/db-bloat-fix-0903-main branch from cc4d3be to cc2cd20 Compare September 9, 2026 10:31
@tnunamak

tnunamak commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (bac55262f) and force-pushed; the three commits are unchanged in content.

Added evidence, measured on a dedicated PostgreSQL 16 instance: 20 replays of an unchanged workload performed 100 avoidable row updates before this change and 0 after, leaving 101 dead tuples versus none — a dead tuple being the obsolete row version PostgreSQL leaves behind on every update, which is what the bloat is made of. A control replay carrying genuinely changed data still writes on both sides, so the new skip-if-unchanged guard suppresses only redundant writes rather than writes in general. Payloads shared between connections, and the stored history of superseded record versions, both still read correctly after the guard runs.

This change prevents new bloat. It does not reclaim bloat that already exists.

Assisted-by: AI

Unchanged record payloads and derived index scopes previously rewrote physical rows during reconciliation, producing dead tuples even when logical data was unchanged. Preserve the existing JSONB and content-addressed-blob no-op paths through metadata repair, and delete only stale semantic scopes before conditional upserts.\n\nAdd low-threshold heap and TOAST autovacuum settings plus a durable, off-peak maintenance job as recovery safety nets. The job is not relied on for correctness: isolated PostgreSQL fixtures prove repeated large JSONB/BYTEA payload and derived-index reconciliations keep dead tuples near zero and relation sizes flat.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Capture the PostgreSQL maintenance constraints, the source-level write-elision
decision, and the devspecs task-index friction observed during this repair.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Follow-up to the reconciliation-bloat work in this PR, addressing three gaps
found in review.

`postgresLexicalIndexInsertMany` — the backfill writer — lacked the
`IS DISTINCT FROM` guard its semantic twin received, so re-running a backfill
over unchanged text rewrote every row and left one dead tuple per row. The
guard elides those writes. No caller inspects the statement's `rowCount`
(`rebuildLexicalInsertEntries` in search.ts discards the result), so this is
write-elision only. Note this is a different statement from
`postgresLexicalIndexPublishWithClient`, whose elision landed in #238.

The per-stream arm of the connector-wide delete dropped `blob_bindings` but
never deleted the `blobs` rows those bindings were the last reference to.
Nothing in the codebase collects orphaned blobs, so the bytes leaked
permanently. Both backends now run the refcount-gated reclaim their
whole-connection siblings already used (`deleteConnectionRecordRowsPostgres`,
`delete-blobs-by-instance.sql`).

The reclaim is refcount-gated rather than supersede-and-delete because blob
rows are globally content-addressed — the insert conflicts on `blob_id` alone,
so identical bytes from a sibling connection share one row — and the
`blob_bindings` FK is `ON DELETE CASCADE`, so an ungated delete would destroy a
live sibling's binding. Deleting at supersession is separately unsafe:
`record_changes` retains `data.blob_ref.blob_id` for every superseded revision
and history pruning is off by default, so those revisions stay readable and
would be handed URLs that 404. A fixture pins both invariants.

Also fixes a test regression: `records-delete-postgres-routing.test.ts` cast
its seed embedding to `::jsonb`, but `semantic_search_blob.embedding` is
`vector` under pgvector, which is the production configuration. The cast now
follows the same branch the production writer uses.

Verified against a throwaway pgvector/pgvector:pg16 database. Each new
assertion was mutation-checked: it fails when its guard is removed.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
…ests

The maintenance work added a Postgres table and three Postgres-backed test
files without entering them in the registries that are meant to account for
every one of them, so three inventory tests failed.

`postgres_derived_index_maintenance_receipts` is now classified in
`BACKUP_TABLE_INVENTORY` as `backup_required`: a `running` receipt is the
in-flight marker that keeps a second VACUUM/REINDEX window off a database
already under maintenance, and no executable rebuild oracle reconstructs it,
which is what a non-required classification would require. It is also
declared Postgres-only, alongside `semantic_hnsw_index_build` — SQLite's
backend never runs these maintenance statements, so the table has no SQLite
counterpart and is not part of the shared storage seam.

The three new test files each bootstrap a temporary database through
`initPostgresStorage`, so they are cold-required, not template-eligible.

Updating the cold-required list moves its size from 26 to 29; the profile
report's count is corrected to match (151 total, not 148). That figure was
already stale before this change.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
@tnunamak
tnunamak force-pushed the waspflow/db-bloat-fix-0903-main branch from cc2cd20 to 12b0a96 Compare September 9, 2026 22:12
…parser

`apps/console` lists `pdpp-reference-implementation` in its
`transpilePackages`, so `next build` type-checks this module under the
console's own ES2017 target. Named capturing groups need ES2018, so the
window parser's regex failed that build with four TS1503 errors and took
every console-dependent reference test down with it.

The reference implementation's own typecheck targets ES2023 and accepted the
same regex, which is why this only appeared in the console build.

Positional groups parse identically; the validation and the rejected-input
error are unchanged.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
…configured

The oracle fell back to a hardcoded `127.0.0.1:55448` when
`PDPP_TEST_POSTGRES_URL` was unset, so it dialled a database that does not
exist under the memory-default profile and failed on ECONNREFUSED. CI runs
that profile without PostgreSQL, so the run reported a broken maintenance
path where it should have reported an unconfigured one.

Gate the database-backed case on the variable and register an explicit
skipped test otherwise, matching `postgres-orphan-blob-reclaim.test.ts`. The
window-parser case needs no database and still runs unconditionally.

Verified both ways: unset skips the database case and passes the parser one;
set against a pgvector instance passes both.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Lock candidate blobs before checking their bindings in a fresh statement
within the same transaction. Apply this ordering to stream and connection
cleanup so a concurrently committed sibling binding retains its bytes.

Add PostgreSQL regressions for both deletion paths that fail before the fix.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Return the existing busy error when cleanup cannot acquire a blob lock
within the configured writer wait budget. Process candidate IDs in bounded
batches, using an index installed after legacy instance-column migration.

Give uploads that lose the cleanup race a 409 conflict with retry guidance.
Cover held locks, batch boundaries, legacy upgrade, and explicit retry.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
@tnunamak
tnunamak force-pushed the waspflow/db-bloat-fix-0903-main branch from 5702fb6 to 5919589 Compare September 10, 2026 01:37
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant