Skip to content

fix(connections): finish the drop and delete lifecycle the catalog adoption started - #3033

Merged
datlechin merged 1 commit into
mainfrom
fix/drop-lifecycle-remainder
Sep 20, 2026
Merged

datlechin merged 1 commit into
mainfrom
fix/drop-lifecycle-remainder

Conversation

@datlechin

Copy link
Copy Markdown
Member

The rest of the drop and delete lifecycle, reported on #3029 and #3032 rather than fixed there.

Four separate defects in one change, because they are one subsystem and three of them are two lines each. The fifth item on that list is not here, and the last section says why.

A column resized moments before its table was dropped came back

CatalogChangeService.recordDroppedTables clears the table's saved settings and then sends .dropped, which closes its tab. Every subscriber chains .receive(on: RunLoop.main), so the teardown always lands a run-loop turn later, and DataGridView.dismantleNSView calls flushPendingColumnLayoutPersistence() on the way out. A column width still inside its 500ms debounce was written back after the clear, and re-marked dirty for sync, waiting for a table recreated with the same name.

closeTabsForRemovedObjects now discards the pending write instead. That mirrors closeTabsByUser, which flushes at the same point for the opposite reason: there the user closed a tab whose table still exists, so the write is wanted.

Reordering the adopt and the send was considered and is a no-op: send() does not block on delivery, so both finish before the deferred sink either way. A "refuse writes for this scope" gate in the stores was considered and rejected as the shim CLAUDE.md forbids, with no sound lifetime, since a recreated table's write is legitimate and indistinguishable from a stale one.

A connection kept pointing at a database that was dropped

adoptContainerRename calls retargetSavedConnectionDatabase, whose doc comment says the saved default is what a reconnect and Reopen Last Session both use. adoptContainerDrop had no counterpart, so dropping the database a connection is configured for left every later connect failing on a database that cannot come back.

A rename has a new name to point at. A drop has none, so the field is emptied, guarded on an exact match with the dropped name. MySQL connects with no default database, MongoDB picks one, PostgreSQL falls back to the connecting user's own name, and SQLite has no dropDatabase at all so the branch cannot fire for it. The argument against, stated plainly: for PostgreSQL this can trade one "does not exist" error for another if the user's own name is not a database either.

Two things the review caught, both of which would have made this worse than the bug. The last database the session remembered has to go too, because selectDatabaseFromLastSession fires precisely when the saved default is empty: clearing one and leaving the other turns that action on and aims it at the database that was just dropped, so MySQL, MariaDB, ClickHouse, MSSQL, TiDB and OceanBase would try to switch to it on every connect, forever. And a type whose form requires a database value must not be emptied at all: Snowflake is .apiOnly with database switching and no hidesBuiltInDatabase, DROP DATABASE is ordinary Snowflake DDL, and the saved connection would have been left failing the form's own validation with nothing on screen saying why.

That predicate now lives in one place. ConnectionDatabaseRequirement owns it and the connection form reads it too, rather than the adoption carrying a second copy that could drift from the one the form validates against.

CatalogEditAdoption takes its ConnectionStorage and AppSettingsStorage now, defaulted to .shared. That is what made this testable, and it brought the rename path its first test in the process.

A remote connection delete pushed its own deletion back at the sender

purgeFavorites splits on origin because, per the Origin doc comment, a remote delete must not leave tombstones or it pushes a deletion back at the device that sent it. purgeAsyncStores, added in #3032, did not, and SQLFavoriteManager.removeFavoritesAndFolders tombstones every record it removes.

This is live, not theoretical. SyncCoordinator.applyRemoteChanges sets changeTracker.isSuppressed for the length of its own synchronous body and resets it in a defer. purge fires a Task, which starts after that body returns, so the suppression is already off by the time the favorites are removed.

removeFavoritesAndFoldersWithoutSync(for:) mirrors FavoriteTablesStorage's existing split, and purgeAsyncStores takes origin with no default, so every caller states which it is.

The review found the same defect two lines above, in the loop that purges the table-scoped stores: FileColumnLayoutPersister.purgeConnections ends in markDeleted, so a remote delete tombstoned every synced settings record as well. purgeConnections takes leavesTombstones now; only the synced store branches on it.

Skipping the tombstone is not enough on its own either. markDeleted is also what clears the dirty mark, so a record that was dirty when the other device deleted it kept its id forever, and every later push looked for a record that was gone and skipped it. SyncChangeTracker.discardDirty drains those ids without tombstoning, and both without-sync paths call it.

The guard that should have caught all of this

everyTableScopedStoreIsRegistered scanned one directory for class declarations mentioning one of three sentinel type names. FavoriteTablesStorage keys on its own FavoriteEntry struct and names none of them, so it was invisible, which is how a dropped table went on leaving its star behind with nothing failing.

It finds a store by the shape of its key now: a struct of at most six stored properties carrying both connectionId and database, and any type that takes one in a function signature and writes. On this tree that is exactly the five registered stores, ForeignKeyLabelColumnStore, and the two Favorites stores, with nothing else.

The two Favorites stores are exempt, with the reason in the map: their lifecycle turns on the local-or-remote origin that TableScopedSettingsStore does not carry, so ConnectionLocalState and CatalogEditAdoption drive them by name. An exemption is now a line in a diff someone has to justify rather than a silent blind spot, and a second test fails if one goes stale.

Two passes were needed to get the detector honest, and both failures are worth recording. It first matched key types as substrings, so the nested Key of one cache matched every forKey: parameter in the app and the scan demanded conformance from twenty-two types that persist nothing table-scoped. It then gated on a file merely mentioning a persistence type, which caught a view model whose display target happens to carry a connection and a database. It matches whole identifiers and requires an actual write now. The guard's own detector had, briefly, the exact defect class it exists to catch.

Not here: a raw SQL DROP still leaves its settings

DROP TABLE orders; typed in the editor refreshes the sidebar and clears nothing, while the sidebar's own Drop Table clears everything. Wiring .statementsRan to the adoption path is unsafe, and the code says so itself. MainContentCoordinator.postStatementRan:

Reported whether the statement succeeded or failed: DDL that commits as it runs, a procedure or a dropped connection can leave the catalog changed behind an error.

It is worse than that. MultiStatementFailure.ranStatementCount counts the failing statement as ran, so DROP TABLE orders; SELECT * FROM missing; submitted together reports orders as dropped even when the batch rolls back and the table is still there. Adopting on that signal deletes a user's saved filters, layouts and highlight rules for tables that exist.

A safe version exists and is a PR of its own: parse candidate names with a dialect-aware DropTargetParser, then confirm them against the reloaded catalog through LoadedBrowseCatalog.staleRefs before adopting, which is the shape reconcileDropsAfterFailedSave already uses for auto-committing-DDL engines. It costs a new per-dialect parser and a confirm-after-refresh step, and it would clear the settings one drain cycle later than the sidebar path does.

Tests

152 pass across the ten suites the change touches, which includes every store whose purgeConnections signature moved.

New: a discarded debounce writes nothing even when a flush follows it, and the discard only fires when the closed tabs include the selected one, so dropping one table cannot throw away a resize in progress on another; the saved database is emptied on a matching drop, untouched on a different one, and untouched when already blank; the rename counterpart, which had shipped untested, is pinned beside it; a remote purge removes the SQL favorites and tombstones neither them nor their folders while draining their dirty marks, a local one tombstones both; a remote connection delete drops the saved column layouts without tombstoning them and a local one tombstones them; and the widened guard, with a second test that fails if an exemption stops naming a store the scan finds.

Verification

  • verify.sh build: PASS
  • verify.sh test over ten suites: PASS, 152 executed, 152 passed
  • verify.sh lint over every changed file: PASS, 0 violations

@datlechin
datlechin merged commit 70596bc into main Sep 20, 2026
7 of 8 checks passed
@datlechin
datlechin deleted the fix/drop-lifecycle-remainder branch September 20, 2026 19:11
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