fix(backend+frontend): migration deadlock, async pattern, timeout safety, fire-and-forget translations

Backend:
- MigrationPlugin.execute — remove AsyncJobRunner.run() deadlock on post-migration
  ID sync (2 deadlocks total: plugins/migration.py + api/routes/migration.py
  trigger_sync_now). Replace blocking runner.run with direct await.
- MigrationPlugin.execute — fix temp file leak (dry_run=True prevented cleanup).
- MigrationPlugin.execute — IdMappingService(SessionLocal()) now closed in finally.
- MigrationPlugin.execute — wire IdMappingService into MigrationEngine constructor
  so cross-filter patching actually works instead of silently skipping.
- MigrationPlugin.execute — SupersetClient.aclose() in finally to prevent
  httpx connection pool leak.
- TaskManager — _async_tasks dict leak (add_done_callback cleanup).
- JobLifecycle — CancelledError handler (tasks stuck in RUNNING after cancel).
- JobLifecycle — persist_task BEFORE _broadcast_task_status (crash consistency).
- JobLifecycle — wait_for_resolution/wait_for_input now have 3600s timeout.
- AsyncJobRunner.run — 300s timeout on future.result() (APScheduler thread safety).

Translate scheduler:
- execute_scheduled_translation — replace blocking runner.run(orch.execute_run(run))
  with fire-and-forget TranslationOrchestrator.execute_background(). APScheduler
  thread is freed in ms instead of blocking for the full translation duration.
  Translation runs of 10k+ rows (200+ LLM batches, hours) no longer hit the
  300s runner timeout.
- TranslationOrchestrator.execute_background — new static method: opens own DB
  session, dispatches asyncio.create_task, handles errors + notification.
- Scheduler last_run_at updated at dispatch time (not after completion).

Frontend:
- MigrationModel.stepReady[3] now requires dryRunResult != null (was always
  true, allowing UI to reach step 3 without dry-run).
- WizardModel.goToStep gate for step 3 uses stepReady[3].
- +page.svelte  — dryRunResult no longer self-clears via reactive loop.
- Progress bar step 3 indicator gate fixed for new readiness logic.

Tests:
- 2 new regression tests for migration sync (deadlock-free, completes cleanly).
- test_migration_plugin.py — _make_mock_superset_client/_make_mock_mapping_service
  helpers for proper async mock behavior (aclose, sync_environment AsyncMock).
- 10 scheduled-translation tests updated for fire-and-forget pattern.
- MigrationModel.test.ts — step 3 invariant + goToStep block test.
- All affected tests: 104 backend + 79 frontend = 183 passed.
This commit is contained in:
2026-07-07 21:04:23 +03:00
parent 3f6d7222c3
commit 34aeeb92a2
17 changed files with 423 additions and 212 deletions

View File

@@ -5,7 +5,7 @@
// @RELATION DEPENDS_ON -> [Migration.Model]
// @INVARIANT Backward navigation (step <= currentStep) is always allowed.
// @INVARIANT Step 2 requires source !== target and both envs selected (delegates to parent.stepReady).
// @INVARIANT Step 3 requires step 1 and step 2 ready.
// @INVARIANT Step 3 requires completed dry-run results.
// @INVARIANT Step 4 requires a dry-run result.
// @STATE currentStep — Active wizard step (1=environments, 2=dashboards, 3=review, 4=execute).
// @ACTION goToStep(step) — Navigates to a wizard step with readiness gating.
@@ -27,7 +27,7 @@ export class WizardState {
if (
step <= this.currentStep ||
(step === 2 && this.parent.stepReady[1]) ||
(step === 3 && this.parent.stepReady[1] && this.parent.stepReady[2]) ||
(step === 3 && this.parent.stepReady[3]) ||
(step === 4 && dryRunResult)
) {
this.currentStep = step;

View File

@@ -161,7 +161,7 @@ export class MigrationModel {
stepReady = $derived<Record<number, boolean>>({
1: this.sourceEnvId !== "" && this.targetEnvId !== "" && this.sourceEnvId !== this.targetEnvId,
2: this.selectedDashboardIds.length > 0,
3: true, // review is always ready if we got here
3: this.dryRunResult != null,
});
/** Human-readable screen state derived from atoms. */

View File

@@ -110,7 +110,11 @@ describe("MigrationModel — L1 invariants (no render)", () => {
it("step 1 ready when both envs selected and distinct", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; expect(model.stepReady[1]).toBe(true); });
it("step 1 not ready when same env", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-1"; expect(model.stepReady[1]).toBe(false); });
it("step 2 ready when dashboards selected", () => { model.selectedDashboardIds = ["1"]; expect(model.stepReady[2]).toBe(true); });
it("step 3 always ready", () => { expect(model.stepReady[3]).toBe(true); });
it("step 3 requires dry-run result", () => {
expect(model.stepReady[3]).toBe(false);
model.dryRunResult = { summary: {}, risk: { score: 10, level: "low", items: [] } };
expect(model.stepReady[3]).toBe(true);
});
});
describe("setReplaceDb", () => {
@@ -130,6 +134,10 @@ describe("MigrationModel — L1 invariants (no render)", () => {
it("allows backward navigation", () => { model.currentStep = 3; model.goToStep(1); expect(model.currentStep).toBe(1); });
it("allows forward to step 2 when ready", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.goToStep(2); expect(model.currentStep).toBe(2); });
it("blocks forward to step 2 when not ready", () => { model.goToStep(2); expect(model.currentStep).toBe(1); });
it("blocks forward to step 3 without dryRunResult", () => {
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
model.goToStep(3); expect(model.currentStep).toBe(1);
});
it("allows step 4 only with dryRunResult", () => {
model.currentStep = 3; model.dryRunResult = {} as any; model.selectedDashboardIds = ["1"]; model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
model.goToStep(4); expect(model.currentStep).toBe(4);

View File

@@ -71,9 +71,11 @@
// The model self-enforces the invariant via toggleDashboard(), but the bind: path is outside model control.
// This guard ensures dryRunResult is cleared regardless of the mutation path.
// @INVARIANT: Dashboard selection change clears stale dry-run result
let selectedDashboardIdsKey = $state("");
$effect(() => {
model.selectedDashboardIds; // always track
if (model.dryRunResult) {
const nextSelectedDashboardIdsKey = model.selectedDashboardIds.join("\u0000");
if (nextSelectedDashboardIdsKey !== selectedDashboardIdsKey) {
selectedDashboardIdsKey = nextSelectedDashboardIdsKey;
model.dryRunResult = null;
}
});
@@ -129,7 +131,7 @@
<!-- Step indicator buttons — custom styling for step wizard (not standard Button) -->
<Button variant="ghost"
onclick={() => model.goToStep(s.step)}
disabled={s.step > model.currentStep && !((s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[1] && model.stepReady[2]) || (s.step === 4 && model.dryRunResult))}
disabled={s.step > model.currentStep && !((s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[3]) || (s.step === 4 && model.dryRunResult))}
class="flex flex-col items-center group"
>
<div class={`
@@ -140,7 +142,7 @@
? 'bg-primary-light border-primary-ring text-primary'
: 'bg-surface-card border-border-strong text-text-subtle'
}
${(s.step <= model.currentStep || (s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[1] && model.stepReady[2])) ? 'cursor-pointer hover:border-primary-ring' : 'cursor-not-allowed'}
${(s.step <= model.currentStep || (s.step === 2 && model.stepReady[1]) || (s.step === 3 && model.stepReady[3])) ? 'cursor-pointer hover:border-primary-ring' : 'cursor-not-allowed'}
`}>
{#if model.currentStep > s.step}
<Icon name="check" size={20} strokeWidth={2.5} />
@@ -636,4 +638,4 @@
/>
<!-- #endregion MigrationModals -->
<!-- #endregion MigrationPage -->
<!-- #endregion MigrationPage -->