fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart
- The convert job reloads the course on success and on failure, so learners get the intact import (rolled back) or the published studio course, never a course whose folder moved away. - POST /convert on a course with an incomplete conversion (.converting-<id>, staging or retired folders) queues a job that recovers and finishes it instead of answering 409; the studio page shows "Conversion incomplete" with Retry for the owner. - The 409 for pending import jobs names their kinds and ids; the dead withLock in git.ts is gone; the dot-folder discovery rule is documented in docs/operations.md and spec section 5. - Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a checksum mismatch and a pre-commit failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
771d755da4
commit
f02e30450c
11 files changed
+168
-34
No files matched your search
@@ -3,7 +3,7 @@ import type { AppConfig, AuthUser, JobQueue, ServerContext } from "@stc/contract
|
||||
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { studioFeature } from "../src/server/feature";
|
||||
import { studioFeature, type StudioFeatureOptions } from "../src/server/feature";
|
||||
import { studioCourseDirs } from "../src/server/paths";
|
||||
import { tempDir } from "./helpers";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const users: Record<string, AuthUser> = {
|
||||
};
|
||||
|
||||
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
|
||||
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}) {
|
||||
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}, featureOpts: StudioFeatureOptions = {}) {
|
||||
const dataDir = tempDir();
|
||||
prepare?.(dataDir);
|
||||
let dropSchema = async () => {};
|
||||
@@ -37,7 +37,7 @@ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite:/
|
||||
config, db, courses, jobs: null as unknown as JobQueue,
|
||||
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
|
||||
} satisfies ServerContext;
|
||||
const feature = studioFeature(ctx, { watch: false });
|
||||
const feature = studioFeature(ctx, { watch: false, ...featureOpts });
|
||||
// the real queue, as composeServer builds it from the features' jobs
|
||||
const queue = createJobQueue(db, feature.jobs ?? {}, { pollMs: 20 });
|
||||
ctx.jobs = queue;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { studioFeature } from "../src/server/feature";
|
||||
import { SimulatedCrash } from "../src/server/convert";
|
||||
@@ -84,9 +84,11 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
|
||||
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
|
||||
// no importer feature in this app: its jobs stay queued
|
||||
await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } });
|
||||
const blocker = await env.ctx.jobs.list("import.code-repo");
|
||||
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.json.error).toContain("an import job for this course is still running");
|
||||
expect(r.json.error).toContain(`import.code-repo ${blocker[0]!.id}`);
|
||||
// a convert job that was queued anyway (before the import was) fails cleanly and changes nothing
|
||||
const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
|
||||
const j = await env.waitJob(id);
|
||||
@@ -122,6 +124,61 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(dbs)("a conversion that fails without a restart [%s]", (_name, dbUrl) => {
|
||||
const media = (as: string) => env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": as, range: "bytes=10-19" } }));
|
||||
const learnerView = async (where: "import" | "studio") => {
|
||||
const all = env.ctx.courses.list().filter((c) => c.id === "imp");
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.status).toBe("ok");
|
||||
expect(all[0]!.root).toBe(join(env.dataDir, where === "import" ? "courses/imp" : "worktrees/imp/published"));
|
||||
const r = await media("lea");
|
||||
expect(r.status).toBe(206);
|
||||
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
|
||||
};
|
||||
|
||||
const cases: [string, { forceExdev?: boolean; failAt?: string; corruptStaging?: boolean }, "import" | "studio", RegExp][] = [
|
||||
["the cross-filesystem copy fails (disk full)", { forceExdev: true, failAt: "staging-copied" }, "studio", /injected failure at staging-copied/],
|
||||
["the copied media fail their checksum", { forceExdev: true, corruptStaging: true }, "studio", /do not match/],
|
||||
["the checkout fails before the commit point", { failAt: "worktree" }, "import", /injected failure at worktree/],
|
||||
];
|
||||
test.each(cases)("%s: learners keep a working course and the owner's retry completes", async (_c, hooks, after, error) => {
|
||||
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d), {}, { storeTestHooks: hooks });
|
||||
await env.db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
|
||||
await learnerView("import");
|
||||
|
||||
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
|
||||
expect(r.status).toBe(202);
|
||||
const j = await env.waitJob(r.json.jobId);
|
||||
expect(j.status).toBe("failed");
|
||||
expect(j.error).toMatch(error);
|
||||
// no restart: learners already get the intact import or the finished studio course
|
||||
await learnerView(after);
|
||||
|
||||
if (after === "studio") {
|
||||
const st = (await env.call("owner", "GET", "/api/studio/courses/imp")).json;
|
||||
expect(st).toMatchObject({ readOnly: false, conversionIncomplete: true, canRetryConversion: true });
|
||||
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ conversionIncomplete: true, canRetryConversion: false });
|
||||
} else {
|
||||
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: true });
|
||||
}
|
||||
|
||||
// the cause goes away (space freed, disk fixed); the owner retries through the same route
|
||||
delete hooks.failAt;
|
||||
delete hooks.corruptStaging;
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
|
||||
const again = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
|
||||
expect(again.status).toBe(202);
|
||||
const j2 = await env.waitJob(again.json.jobId);
|
||||
expect(j2.error).toBeNull();
|
||||
expect(j2.status).toBe("done");
|
||||
await learnerView("studio");
|
||||
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json.conversionIncomplete).toBe(false);
|
||||
expect(readdirSync(join(env.dataDir, "courses"))).toEqual([]);
|
||||
expect(readdirSync(join(env.dataDir, "media"))).toEqual(["imp"]);
|
||||
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert route refusals", () => {
|
||||
test("only the owner; cross-site requests are refused; nothing changes", async () => {
|
||||
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { ConvertPanelView, ReadOnlyView } from "../src/ui/ConvertPanel";
|
||||
import { ConvertPanelView, IncompleteConversionView, ReadOnlyView } from "../src/ui/ConvertPanel";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -36,3 +36,16 @@ test("read-only view: imported for the owner offers the action; for others expla
|
||||
expect(outside).toContain("/repos/o");
|
||||
expect(outside).not.toContain("<button");
|
||||
});
|
||||
|
||||
test("an incomplete conversion: the owner can retry, others are told who can", () => {
|
||||
const owner = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry onRetry={noop} />);
|
||||
expect(owner).toContain("Conversion incomplete");
|
||||
expect(owner).toContain(">Retry</button>");
|
||||
const author = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry={false} onRetry={noop} />);
|
||||
expect(author).toContain("Conversion incomplete");
|
||||
expect(author).not.toContain("<button");
|
||||
expect(author).toContain("Ask the owner");
|
||||
const failed = renderToString(<IncompleteConversionView phase={{ kind: "failed", error: "disk full" }} canRetry onRetry={noop} />);
|
||||
expect(failed).toContain('role="alert"');
|
||||
expect(failed).toContain("disk full");
|
||||
});
|
||||
Reference in new issue
Block a user