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:
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user