Skip to content

Commit 3201d21

Browse files
committed
Prevent duplicate populate runs
1 parent ad08b21 commit 3201d21

2 files changed

Lines changed: 64 additions & 18 deletions

File tree

backend/src/index.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ function emailDomain(email: string): string {
2121
}
2222

2323
type DatasetPopulateStatus = "building" | "live" | "failed";
24+
type DatasetPopulateBeginOutcome =
25+
| "started"
26+
| "not_found"
27+
| "forbidden"
28+
| "already_building";
2429
type PopulateWorkflowRun = Awaited<ReturnType<typeof populateWorkflow.createRun>>;
2530

2631
function statusErrorMessage(err: unknown): string {
@@ -40,6 +45,18 @@ async function setDatasetPopulateStatus(
4045
});
4146
}
4247

48+
async function beginDatasetPopulate(
49+
datasetId: string,
50+
ownerId: string,
51+
): Promise<DatasetPopulateBeginOutcome> {
52+
const claim = await convex.mutation(internal.datasets.beginPopulateInternal, {
53+
id: datasetId,
54+
ownerId,
55+
});
56+
57+
return claim.outcome;
58+
}
59+
4360
async function sendDatasetReadyNotification({
4461
logger,
4562
clerk,
@@ -272,28 +289,25 @@ await fastify.register(async (instance) => {
272289
return reply.code(401).send({ error: "Authentication required" });
273290
}
274291

275-
// Ownership check uses the INTERNAL (admin-callable, no-authz) getter.
276-
// We can't use `api.datasets.get` here because that runs through
277-
// `loadReadableDataset`, which requires either a Clerk-identified
278-
// caller OR visibility="public". The backend's ConvexHttpClient is
279-
// admin-authed but does NOT impersonate a user, so private datasets
280-
// (the typical case) get rejected as `anonymous_private`.
281-
//
282-
// The /populate route enforces ownership against `req.auth.userId`
283-
// (from the verified Clerk JWT) immediately below — that's the
284-
// authoritative check, not Convex's user-identity authz.
285-
const dataset = await convex.query(internal.datasets.getInternal, {
286-
id: parsed.data.datasetId,
287-
});
288-
if (!dataset) {
292+
const run = await populateWorkflow.createRun();
293+
const populateOutcome = await beginDatasetPopulate(
294+
parsed.data.datasetId,
295+
auth.userId,
296+
);
297+
298+
if (populateOutcome === "not_found") {
289299
return reply.code(404).send({ error: "Dataset not found" });
290300
}
291-
if (dataset.ownerId !== auth.userId) {
301+
if (populateOutcome === "forbidden") {
292302
return reply.code(403).send({ error: "Not authorized to populate this dataset" });
293303
}
304+
if (populateOutcome === "already_building") {
305+
return reply.code(409).send({ error: "Dataset is already being populated" });
306+
}
307+
if (populateOutcome !== "started") {
308+
throw new Error(`Unexpected populate claim outcome: ${populateOutcome}`);
309+
}
294310

295-
const run = await populateWorkflow.createRun();
296-
await setDatasetPopulateStatus(parsed.data.datasetId, "building");
297311
void runPopulateWorkflowInBackground({
298312
input: parsed.data,
299313
run,

frontend/convex/datasets.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,38 @@ export const getInternal = internalQuery({
114114
},
115115
});
116116

117+
/**
118+
* Atomically claims a user-requested populate run for a dataset.
119+
*
120+
* This is the concurrency gate for backend /populate calls. The workflow
121+
* starts by clearing existing rows, so duplicate background runs for the same
122+
* dataset must be rejected before either one reaches the row-clearing step.
123+
*/
124+
export const beginPopulateInternal = internalMutation({
125+
args: {
126+
id: v.id("datasets"),
127+
ownerId: v.string(),
128+
},
129+
handler: async (ctx, args) => {
130+
const dataset = await ctx.db.get(args.id);
131+
if (!dataset) {
132+
return { outcome: "not_found" as const };
133+
}
134+
if (dataset.ownerId !== args.ownerId) {
135+
return { outcome: "forbidden" as const };
136+
}
137+
if (dataset.status === "building") {
138+
return { outcome: "already_building" as const };
139+
}
140+
141+
await ctx.db.patch(dataset._id, {
142+
status: "building",
143+
lastStatusError: undefined,
144+
});
145+
return { outcome: "started" as const };
146+
},
147+
});
148+
117149
/**
118150
* Admin-only status transition. Used by the backend orchestration layer
119151
* to move a dataset between lifecycle states after a workflow completes.
@@ -124,7 +156,7 @@ export const getInternal = internalQuery({
124156
*
125157
* Lifecycle today:
126158
* - "paused" : default for newly created datasets before first run
127-
* - "building" : set by /populate after ownership passes
159+
* - "building" : set by beginPopulateInternal after ownership passes
128160
* - "live" : set by background populate after rows exist
129161
* - "failed" : set by background populate on workflow failure
130162
*

0 commit comments

Comments
 (0)