fix(knowledge): bound the members-mode tombstone pass by what a run changed - #8172
Conversation
…hanged The tombstone phase of the member document lifecycle ran one anti-join over every live document of the connector on every run, and LIMIT could not bound it because almost every document is observed. On a large connector that statement outgrew the statement timeout, rolled back the member's page, and walked the connector toward disabled. Tombstoning is now driven by the documents whose observations the run itself removed (unseen after a complete listing, withdrawn by a change feed, or held by a member being removed), then by a resumable backstop that walks the reconciliation index one page per statement and checks observations only in the UPDATE over that page's live ids. The backstop saves its position on the connector and does a bounded number of pages per run. Resurrection and purge are unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
There was a problem hiding this comment.
All reported issues were addressed across 12 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
…mbstone explicit removals without a completed listing The absence reconcile ordered its resumable walk by source_seen_at, which every member listing rewrites for what it observed. On a connector larger than one run's page budget the walk chased re-stamped documents and a pass never ended, so a document that lost its observers behind the cursor was never revisited. The walk now keys on external id through doc_connector_external_id_idx, which a document never changes, so a pass completes within ceil(documents / budget) runs. Documents a run explicitly unobserved (a complete listing, a change-feed withdrawal, or a member removal) are now tombstoned even when no member with a completed listing remains, as the stale-member sweep already does. Removing the only listed member previously left every document only it observed live and unobserved indefinitely. The reconcile and the purge stay gated on a completed listing.
…ide the reconcile cursor - A member removal interrupted by the run deadline resumed from its saved document cursor and only handed the lifecycle the documents above it. The resumed walk now re-reads the removed member's observations below the cursor first. They still exist until the member row is deleted. - The absence reconcile writes its cursor once per run instead of once per page, and documents how many runs a pass takes. - Connector presenters drop member_tombstone_cursor alongside the stored API key, so the internal cursor never reaches API responses. - CI runs member-document-lifecycle.integration.ts with the other knowledge PostgreSQL suites.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 17 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
…removal itself A membership removal handed the documents its member alone observed to the lifecycle through run memory. If the run stopped after the member row was deleted, the ids were gone, the observations had cascaded away, and with no member left with a completed listing the absence reconcile never ran. Those documents stayed live indefinitely. Each removal page now tombstones the documents no other member observes in the same transaction that advances the removal's checkpoint, so nothing depends on the run surviving to its lifecycle. This applies only where observations decide existence; a corpus owned by a dedicated content credential is left alone, as before. The in-memory re-collection added earlier is removed. The absence reconcile's external_id IS NOT NULL filter is documented. Every writer that sets connector_id copies a required external id, so the filter excludes nothing.
…le-bounded-tombstone
…ember's restored ACLs A multi-run member removal tombstones, page by page, the documents only that member observes. If directory re-listing withdrew the removal mid-walk, the restarted walk restored the member's token to those ACLs, but the documents stayed deleted until the lifecycle's resurrection phase ran. That phase runs only after the member loop finishes before the deadline. A membership walk that is not a removal now applies the lifecycle's own resurrect predicate to each page, in the same transaction. The predicate is shared through one helper and covers: connector-owned, not excluded, not archived, content present, observed. Only where observations decide existence.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 16 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
Summary
document⋈NOT EXISTS (observation)anti-join over every live document of the connector on every run once any member had completed a listing. Almost every document is observed, soLIMIT 500never stopped the walk. A member run died at the statement timeout afterapplyMemberListinghad already committed that member's page and observations. The run then failed and pushed the connector down the failure ladder. This statement is inferred as the one that timed out, not observed directly: it is the only statement in the run whose cost grows with the connector's live documents. Locally the planner chose a Parallel Seq Scan with a Hash Anti Join for it, not the nested-loop plan seen in productionUPDATE ... WHERE id IN (<=500 ids) AND NOT EXISTS (...), a nested-loop anti join bounded by the batch These are tombstoned even when no member with a completed listing remains, as the stale-member sweep already does. Previously, removing the only listed member left the documents only it observed live forever. The backstop and the purge stay gated on a completed listingdoc_connector_external_id_idx, one page per statement withLIMITon the document side. It checks observations only in the UPDATE over that page's ids, and saves its position in a new nullableknowledge_connector.member_tombstone_cursor. External id is the key because it never changes, whilesource_seen_atis rewritten by every listing and a walk ordered by it would never finish a pass. Each run does at mostMEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN(20) pages, so a pass completes within ceil(documents / budget) runs. A small connector still finishes a full pass every rundoc_connector_tombstone_idx, so they are bounded by tombstonesmember_tombstone_cursoralong with the stored API key, so it never reaches API responses.member-document-lifecycle.integration.tsnow runs in CI with the other knowledge PostgreSQL suites0377(on top of fix(knowledge): release kept connector documents in the background so removing a large source cannot time out #8165's0376):ADD COLUMN member_tombstone_cursor jsonb, nullable and with no default, so it is metadata-only and older app versions are unaffected. No index changesType of Change
Testing
member-observations.test.ts): a removed-this-run id is tombstoned through a batch UPDATE that rechecksNOT EXISTSand skips already-tombstoned rows; the page SELECT has no observation filter and carriesLIMIT 500; the page budget stops the walk and saves the cursor; a later run starts from the saved cursor; the deadline leaves work for the next runmember-sync-engine.integration.test.ts): ids from complete-listing removal and change-feed withdrawal reach the lifecyclemember-document-lifecycle.integration.ts, new case): with more unobserved documents than one run's budget, the document this run unobserved is tombstoned right away even though it sits past the budget. A document another member still observes is not tombstoned. The rest is finished on the next run and the cursor resets.listing-continuation.integration.tsnow asserts that removing a member hands over its remaining observed documents. All existing member integration suites pass (jira/github/gmail/calendar/drive-shortcuts/excluded/listing-continuation/scope-renewal/directory-sync) Two more cases: removing the only member with a completed listing still tombstones what only it observed, but not a document another member observes or one nobody ever observed. A connector larger than the budget, with every observed document re-stamped between runs, still finishes a pass within ceil(N/budget) runs and reaches a document that lost its observers behind the cursor on the next passdoc_connector_external_id_idxunderLimit, reading about 18 buffers for 500 rows at both the first page and a mid-connector cursor. The page UPDATE is a nested loop anti join over the PK, bounded to 500 probesbun run lint,bun run check:audits(47 passed),bun run check:migrations origin/staging,bun run type-check(apps/sim)Checklist