Skip to content

fix(db): reconcile folders stranded by old pods during the cutover drain - #6049

Closed
waleedlatif1 wants to merge 2 commits into
stagingfrom
chore/folders-legacy-contract
Closed

fix(db): reconcile folders stranded by old pods during the cutover drain#6049
waleedlatif1 wants to merge 2 commits into
stagingfrom
chore/folders-legacy-contract

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Adds 0275, the post-drain reconcile for the generic-folder cutover shipped in #6045.

Why this exists

0272 and 0274 both ran while the previous app version was still serving. During the blue/green window the old pods write the legacy folder tables exclusively while the new pods write folder exclusively, so any folder an old pod creates between the migration and its own termination lives only in the legacy table and is invisible to the new code.

0274's header asked to be run again once the deploy drained. A journaled migration never replays, so that re-run has to be its own file. This is it.

The stranding window did materialize in production — measured live at 06:52Z, mid-drain:

rows to insert
workflow_folder 1 (archived, no parent, no workflows inside)
workspace_file_folders 0

The single stranded row has no user impact — it was created and archived within 100ms by canary traffic. The count at execution time may be higher, since old pods were still serving when that was taken.

Covers both trees, where 0274 covered only file. The workflow side is the higher-volume one and is where the stranded row actually landed.

Design

Insert-only for ids missing from folder, never a mirror upsert. 0274 could upsert every column because file folders can never diverge in name — workspace_file_folders enforces the same active-unique key folder does. That does not hold here: 0272's dedup renamed 47 workflow folders across 35 collision groups, so folder is deliberately different from workflow_folder for those rows. An upsert would revert all 47 to their colliding names and violate folder_workspace_resource_parent_name_active_unique in the process.

First free suffix, not a row number. The target name may already be held by a row this block is not inserting, so the dedup probes upward until it finds a genuinely free name.

Only active rows dedupe. The partial unique index ignores soft-deleted rows, so an archived folder keeps its original name and reappears under it if restored.

Depth-ordered inserts. A parent always lands before its children — the self-referencing FK is checked immediately and the folder_parent_resource_type_match trigger reads the parent row, so a child inserted first fails both.

Wrapped in a DO block, for the same reason 0274 is: 0272 ends with an embedded COMMIT for its trailing CONCURRENTLY index builds, so files after it are not guaranteed to run inside drizzle's batch transaction.

Verification

Executed the actual migration file against Postgres 17 with adversarial fixtures. Every case a naive implementation gets wrong:

Fixture Expected Result
Row 0272 renamed to Reports (2) untouched ✅ not reverted
Stranded active Reports, with Reports (2) already taken Reports (3)
Stranded archived row with colliding name keeps Reports ✅ no suffix
Fully-stranded 3-level chain parents insert first
Stranded child colliding with a sibling dedupes within parent Nested (2)
Same name in another workspace untouched
File Reports vs workflow Reports independent ✅ resource_type scoped

Idempotency: three consecutive runs, byte-identical state. bun run check:migrations passes.

Operational note — this has a deadline

Run before the soft-delete cleanup job purges expired folder rows. Cleanup hard-deletes from folder but never from the legacy tables, so a run landing after a purge would reinstate every purged folder as a soft-deleted phantom in Recently Deleted whose files and workflows are already gone. The retention window is far longer than any drain, so shipping promptly is sufficient — but this is the one constraint that makes deferring it indefinitely dangerous rather than merely redundant.

Not in this PR — dropping the legacy tables

Deliberately held. Verified for a future contract PR, but not acted on:

  • Application code is already fully clean — zero reads, zero writes, zero dual-read fallbacks to either legacy table. The reader-removal precondition is satisfied by feat(folders): cut file folders over, and give knowledge bases and tables folders #6045, which is deployed.
  • Structurally the drop is trivial: the only inbound FKs are self-references. Nothing else in the database depends on either table.
  • Full-row comparison (not just counts) is clean on both trees: 1,495/1,495 file rows match on every column; the workflow side shows zero parent drift, with the only material difference being the one stranded row.

What is not satisfied is rollback posture. The tables are the only rollback path, KB/Tables folders have hours of production exposure with no meaningful user traffic yet, and schema.ts carries a binding policy that dropping on the strength of "no code references it" alone destroys that path. They cost essentially nothing to keep. That call belongs to the repo owner once the new folder trees have seen real usage.

⚠️ For whoever writes that drop PR: do not let any script glob workflow_folder* when dropping indexes. workflow_folder_sort_idx is an index on the live workflow table, not on the legacy folder table. It looks obviously safe in review and is not.

0272 and 0274 both ran while the previous app version was still serving. During
the blue/green window the old pods write the legacy folder tables exclusively
while the new pods write `folder` exclusively, so any folder an old pod creates
between the migration and its own termination exists only in the legacy table
and is invisible to the new code. 0274 asked to be re-run once the deploy
drained; a journaled migration never replays, so the re-run needs its own file.

Covers both trees, where 0274 covered only `file`. The workflow side is the
higher-volume one and is where the stranded row actually landed in production.

Insert-only for ids missing from `folder`, never a mirror upsert: 0272's dedup
renamed 47 workflow folders, and an upsert would revert all 47 to their
colliding names and violate the active-unique index doing it. Names are
deduplicated against the first free suffix rather than a row number, since the
target name may already be held by a row this block is not inserting. Only
active rows dedupe — the partial index ignores soft-deleted rows, so an
archived folder keeps its name and reappears under it if restored. Rows insert
in depth order so a parent always precedes its children, which the
self-referencing FK and the parent-resource-type trigger both require.

Wrapped in a DO block for the same reason 0274 is: 0272 ends in an embedded
COMMIT, so files after it are not guaranteed to run inside drizzle's batch
transaction.

Verified by executing the file against Postgres 17 with adversarial fixtures:
a 0272-renamed row is left untouched, a stranded active row whose name and
whose "(2)" are both taken lands on "(3)", a stranded archived row keeps its
colliding name, a fully-stranded three-level chain inserts parents first,
sibling dedup scopes to the parent, and cross-workspace and cross-resource-type
names do not interfere. Idempotent across three consecutive runs with
byte-identical state. Production at 06:52Z has 1 row to insert (archived,
empty, no parent) on the workflow side and 0 on the file side.
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 29, 2026 7:21am

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches production folder data during a cutover window with unique-index and FK/trigger ordering constraints; design is insert-only and idempotent but timing relative to soft-delete cleanup matters.

Overview
Adds migration 0275_folder_cutover_post_drain_reconcile, the post-deploy reconcile after the generic folder cutover. It copies any legacy rows that still exist only in workflow_folder or workspace_file_folders (written by old pods during the blue/green drain) into folder, without upserting rows already mirrored.

The script is insert-only for missing ids so it does not undo 0272’s workflow name dedup. Active inserts get first-free (n) suffixes per workspace/parent/resource type; soft-deleted rows keep their names. Workflow locked is preserved; file folders get locked = false. Both trees insert in depth order inside a single DO block with ON CONFLICT (id) DO NOTHING for idempotency.

Ops: run before soft-delete cleanup hard-purges expired folder rows, or purged folders could reappear as phantoms in Recently Deleted.

Reviewed by Cursor Bugbot for commit a5dac82. Configure here.

Comment thread packages/db/migrations/0275_folder_cutover_post_drain_reconcile.sql
Comment thread packages/db/migrations/0275_folder_cutover_post_drain_reconcile.sql
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds post-drain migration 0275 to insert-only reconcile folders stranded in legacy tables during the blue/green cutover.

  • Depth-ordered inserts from workflow_folder and workspace_file_folders into folder for missing ids only
  • Active-name first-free suffix dedup starting at (1); workflow locked copied from legacy; file folders stay unlocked
  • Journal entry appended; idempotent via missing-id select and ON CONFLICT (id) DO NOTHING

Confidence Score: 5/5

The PR appears safe to merge; prior lock and suffix issues are fixed and no blocking failure remains.

No blocking failure remains. Workflow locked is selected and inserted; suffix enumeration starts at 0 so the first free name is (1); both prior threads are resolved.

Important Files Changed

Filename Overview
packages/db/migrations/0275_folder_cutover_post_drain_reconcile.sql Post-drain insert-only reconcile for both legacy folder trees with depth order, active-name dedup from (1), and workflow locked carry-over.
packages/db/migrations/meta/_journal.json Registers migration 0275 after 0274 in the drizzle journal.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  WF[workflow_folder legacy]
  FF[workspace_file_folders legacy]
  F[folder]
  WF -->|missing id only depth order| F
  FF -->|missing id only depth order| F
  F -->|active name collision| Dedup["suffix from (1)"]
Loading

Reviews (2): Last reviewed commit: "fix(db): carry locked through the reconc..." | Re-trigger Greptile

Comment thread packages/db/migrations/0275_folder_cutover_post_drain_reconcile.sql
Comment thread packages/db/migrations/0275_folder_cutover_post_drain_reconcile.sql Outdated
Two review findings, both real.

`locked` was hardcoded false on the workflow insert. `workflow_folder` has a
`locked` column and the app still enforces the lock feature against
`folder.locked`, so a stranded locked folder would have come back unlocked.
0272 carries `f.locked` through for exactly this reason; this now matches it.
`workspace_file_folders` has no such column — file folders have never been
lockable — so the file pass still writes false.

Name dedup started at ' (2)', skipping a free ' (1)'. 0272 enumerates
`generate_series(1, 10000)` and the app's own dedup produces "New folder (1)",
"New folder (2)", ..., so starting at (2) was inconsistent with both.

Fixtures extended to cover both: a stranded locked folder must arrive locked, a
stranded unlocked one must stay unlocked (proving the value is copied rather
than blanket-set), and a collision with a free ' (1)' must take it. Still
idempotent across three consecutive runs.

The single row stranded in production is an archived, unlocked canary folder,
so neither bug would have bitten on today's run — but this file is meant to be
re-runnable and ships to self-hosted rolling deploys.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Applied directly to production instead — the reconcile was a one-time operational cleanup and running it by hand meant it took effect immediately rather than waiting on the next deploy.

Applied 2026-07-29T07:22Z after confirming the pre-cutover pods had fully drained (last legacy-table write 06:27:21Z, zero in the preceding 5 minutes). Dry-run first inside a transaction and rolled back, which exercised the real constraints and triggers: exactly 1 row to insert, 0 rows modified (confirming insert-only, so none of 0272's 47 dedup renames were reverted), and all post-state integrity checks at 0.

Post-apply verification on production:

check result
workflow rows stranded 0
file rows stranded 0
active-name collisions 0
parent resource-type / workspace violations 0
dangling parents 0
locked mismatches vs legacy 0

The single stranded row was to_delete_canary_folder_renamed_… — archived, unlocked, empty, created by canary traffic during the rolling window. No user-visible impact.

Worth recording that the review round on this PR was not wasted: it caught two real bugs in the SQL that was ultimately run. locked was hardcoded false (a stranded locked workflow folder would have come back unlocked, since the app still enforces locking against folder.locked), and name dedup started at (2) rather than the free (1) that 0272 and the app both produce. Both were fixed before the manual apply.

Residual gap, left open deliberately: self-hosted installs doing rolling upgrades (Helm/k8s) across this cutover can strand folders the same way, and now ship no reconcile. Single-container docker-compose upgrades are unaffected, since there is no window where old and new code serve concurrently. Reopen this if that is worth covering.

⚠️ For whoever writes the eventual drop PR: do not let a script glob workflow_folder* when dropping indexes — workflow_folder_sort_idx is an index on the live workflow table, not the legacy folder table.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a5dac82. Configure here.

INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
VALUES (rec."id", 'workflow', candidate, rec."user_id", rec."workspace_id", rec."parent_id",
rec."locked", rec."sort_order", rec."created_at", rec."updated_at", rec.deleted_at)
ON CONFLICT (id) DO NOTHING;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stranded child keeps deleted parent

Medium Severity

Stranded inserts copy parent_id from the legacy row without checking whether that parent is still present and active in folder. During the drain, new pods can soft-delete a parent in folder while old pods still create children under it in the legacy table, so this reinserts an active child under a deleted parent. That disagrees with create/restore, which reject or re-root when the parent is archived, and can later break cleanup when ON DELETE SET NULL collides with an active root name.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a5dac82. Configure here.

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