Skip to content

fix(coordinator): load a table tab's initial query exactly once#1456

Merged
datlechin merged 1 commit into
mainfrom
worktree-fix-duplicate-tab-load
May 28, 2026
Merged

fix(coordinator): load a table tab's initial query exactly once#1456
datlechin merged 1 commit into
mainfrom
worktree-fix-duplicate-tab-load

Conversation

@datlechin

@datlechin datlechin commented May 28, 2026

Copy link
Copy Markdown
Member

Symptom

Opening a table runs the same initial query up to four times. Logs from a Chinook sample SQLite open show four [executeUserQuery] SELECT * FROM Track LIMIT 1000 OFFSET 0 lines, of which three return rows=0 (interrupted) and one returns rows=1000. The grid ends up correct because queryGeneration discards the stale results, but four SQLite queries actually ran, three were cancelled mid-flight via sqlite3_interrupt, and on slower remote databases the redundant attempts add real latency and flicker.

Root cause

The initial auto-load was not idempotent, for two reasons:

  1. Bypass path. initializeAndRestoreTabs (MainContentView+Setup.swift:70 and :160) called coordinator.executeTableTabQueryDirectly() directly, skipping the freshness guards in lazyLoadCurrentTabIfNeeded.
  2. In-flight blind spot. The stale-flag heuristic in lazyLoadCurrentTabIfNeeded could not tell "abandoned mid-flight" from "still running." Any second trigger cleared the flag and re-fired, cancelling the running query.

Apple's docs give no exactly-once guarantee for onAppear/.task (only "before the first frame"). The correct fix is to make the load idempotent in the coordinator, not to chase lifecycle re-fires.

Approach

Apple's WWDC21 "Protect mutable state with Swift actors" pattern: store the in-flight Task and have concurrent callers coalesce onto it instead of starting a new one. TablePro already uses this pattern in SQLSchemaProvider.loadTask. This PR extends it to table-tab loads.

Changes

  • MainContentCoordinator: new tableLoadTasks: [UUID: Task<Void, Never>], keyed by tab id. teardown() cancels and clears the map.
  • MainContentCoordinator+WindowLifecycle.lazyLoadCurrentTabIfNeeded: rewritten as the single funnel. Pure freshness gate (canAutoLoadTableTab helper) → map-based coalescing (tableLoadTasks[tab.id] == nil else return) → abandoned-flag recovery (clearAbandonedExecutingFlagIfNeeded helper, uses currentQueryTask == nil as the discriminator) → connection check → spawn wrapping Task. The wrapping Task awaits currentQueryTask so the map entry lives until the query observably completes; defer clears it.
  • MainContentView+Setup.swift: the two initializeAndRestoreTabs auto-load call sites now go through lazyLoadCurrentTabIfNeeded(). Combined with the existing .task(id:) and connectionStatusChanged paths, all four view trigger paths now funnel through one coordinator method.
  • Comments removed: the old method had a multi-line comment explaining the stale-flag heuristic. Self-documenting helper names (canAutoLoadTableTab, clearAbandonedExecutingFlagIfNeeded) replace it, matching the project's no-comments rule.

The three other callers of executeTableTabQueryDirectly (runQuery:748, Redis post-switch Navigation:479, and the new wrapping Task inside lazyLoad) are intentional re-runs and stay direct.

Why a refactor, not a patch

The earlier targeted patch in this branch (currentQueryTask as the in-flight signal) closed the symptom but kept the auto-load orchestration spread across the view layer (initializeAndRestoreTabs's direct executor calls, the lazyLoad heuristic, the abandoned-flag clear). The full refactor moves auto-load ownership onto the coordinator with the WWDC21 coalescing pattern, eliminates the heuristic in favour of an unambiguous signal (presence in the per-tab map), and makes the call shape uniform across all four lifecycle trigger paths.

Tests

  • skipsWhenLoadTaskRegistered (replaces skipsWhenAlreadyExecuting): seeds tableLoadTasks[tabId] with a sleeping Task, calls lazyLoad, asserts no new entry and needsLazyLoad == false (returned early before the connection check). Directly exercises the map-based coalescing.
  • recoversAbandonedExecutingFlag: isExecuting=true, currentQueryTask=nil, empty map. lazyLoad clears the flag and falls through to the disconnected deferral. Proves abandoned-load recovery still works.
  • All existing tests (cheap-content guards, freshness, idempotency, eviction, windowDidBecomeKey regression) untouched.

Checks

  • swiftlint lint --strict on changed files: clean.
  • Writing-style grep on added lines: clean.
  • Verified execution.errorMessage and lastExecutedAt are NOT persisted by QueryTab.toPersistedTab(), so the extra guards lazyLoad adds for restored tabs are no-ops in practice (no regression for tabs that previously errored).

@datlechin datlechin force-pushed the worktree-fix-duplicate-tab-load branch from 9d5d1d4 to 821c463 Compare May 28, 2026 10:37
@datlechin datlechin merged commit 38b14c0 into main May 28, 2026
2 checks passed
@datlechin datlechin deleted the worktree-fix-duplicate-tab-load branch May 28, 2026 10:40
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