Skip to content

Commit 17f29b7

Browse files
authored
fix(webapp,run-store): make run-ops sharding failures visible and unroutable ids a 404 (#4868)
## Summary Four failure modes found while exercising a multi-database run-ops setup by hand. None stopped the service: each was unalertable, unreported, or answered with the wrong status. - A replication source whose Postgres publication carries no tables replicates nothing. Boot succeeds, the service looks healthy, and every ClickHouse-backed surface silently under-counts. There is now a counter to alert on. - Completing a wait token with an ID naming a database the deployment has no store for answered a server error. It now answers not-found, like the equivalent run routes. - An unparseable stored shard set still degrades safely to the previous ID format, but the parse failure went unreported. It is now reported on the mint path. - Per-shard observability, without which a gradual rollout cannot be watched. ## Design **Publication misconfiguration.** The replication client already detected an empty publication and emitted a `LogicalReplicationClientError` on every reconnect, which the service logged. That is visible but not alertable, and indistinguishable from any other client error. It now emits a `PublicationMisconfiguredError` subclass of that error, counted per source as `runs_replication_publication_misconfigured_total`. No boot-time check was added: it would need a live query per source before the service exists, so a transient blip would fail closed and refuse to boot, which is worse than the problem being solved. The counter increments on every retry, so a nonzero rate is the alarm and it clears when the publication is repaired. Repairing the publication restores replication going forward but does not backfill rows written while it was empty, so catching this early is the point. **Unroutable IDs.** All seven API route builders now map this to a 404, including the two worker builders that previously fell through to a 500. Both new call sites log before answering, so a shard key dropped from a config meant to be append-only still alarms rather than turning every live token on it into a quiet not-found. Routes that handle their own errors never reach a builder, so a sweep covers those too: four bare API routes that answered 500 (run result, run tags, reschedule, batch results) and ten dashboard and resource routes that threw straight through to an error page. Each takes the not-found path it already had for a run that does not exist, and logs first. **Shard-set parse failures.** The parsing module is pure by design and its tests depend on that, so it reports through a callback and the caller logs, matching the existing operator reports. This covers the mint path, which every process re-reads within one cache TTL, so a bad set alarms across the fleet. Saving one is still silent at save time: the stamping helper takes the same callback but its caller does not yet pass it. **Observability.** `runops_shard_routed_total{shard}` counts ID-routed store resolutions, and `runops_read_through_source_total{source,shard}` records which store served a read-through. The first is deliberately a relative ramp signal rather than a request count: fan-outs and probe hits resolve no single shard, so they are not counted, and the help text says so. Label children are cached rather than hashed per call, since both sit on hot read paths. Each fix has a test that fails if the corresponding production change is reverted, with two exceptions worth naming: the lines binding the metric recorders into the production service and read-through defaults are module-scope wiring that the tests inject around.
1 parent 04d3264 commit 17f29b7

41 files changed

Lines changed: 1080 additions & 163 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/webapp/app/routes/@.runs.$runParam.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { redirectWithErrorMessage } from "~/models/message.server";
88
import { requireUser } from "~/services/session.server";
99
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
1010
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
11+
import { undefinedOnUnroutableId } from "~/v3/runOpsMigration/unroutableRead.server";
1112

1213
const ParamsSchema = z.object({
1314
runParam: z.string(),
@@ -33,17 +34,21 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
3334
);
3435
}
3536

36-
const run = await runStore.findRun(
37-
{
38-
friendlyId: runParam,
39-
},
40-
{
41-
select: {
42-
spanId: true,
43-
runtimeEnvironmentId: true,
44-
},
45-
},
46-
prisma
37+
const run = await undefinedOnUnroutableId(
38+
() =>
39+
runStore.findRun(
40+
{
41+
friendlyId: runParam,
42+
},
43+
{
44+
select: {
45+
spanId: true,
46+
runtimeEnvironmentId: true,
47+
},
48+
},
49+
prisma
50+
),
51+
{ runParam: params.runParam ?? params.runId }
4752
);
4853

4954
if (!run) {

apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~
55
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
66
import { authenticateApiRequest } from "~/services/apiAuth.server";
77
import { logger } from "~/services/logger.server";
8+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
89

910
const ParamsSchema = z.object({
1011
/* This is the batch friendly ID */
@@ -42,6 +43,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4243

4344
return json(result);
4445
} catch (error) {
46+
const unroutable = unroutableIdResponse(error);
47+
if (unroutable) {
48+
logger.warn("Unroutable batch id on batch results", {
49+
error: error instanceof Error ? error.message : error,
50+
});
51+
return unroutable;
52+
}
53+
4554
logger.error("Failed to load batch results", { error });
4655
return json({ error: "Something went wrong, please try again." }, { status: 500 });
4756
}

apps/webapp/app/routes/api.v1.runs.$runId.tags.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { logger } from "~/services/logger.server";
1010
import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server";
1111
import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server";
1212
import { runStore } from "~/v3/runStore.server";
13+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
1314

1415
// Pull the existing tags out of a buffer entry's serialised payload so
1516
// the buffer-path response can dedup against them, matching the
@@ -137,6 +138,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
137138
}
138139
return outcome.response;
139140
} catch (error) {
141+
const unroutable = unroutableIdResponse(error);
142+
if (unroutable) {
143+
logger.warn("Unroutable run id on run tags", {
144+
error: error instanceof Error ? error.message : error,
145+
});
146+
return unroutable;
147+
}
148+
140149
logger.error("Failed to add run tags", { error });
141150
return json({ error: "Something went wrong, please try again." }, { status: 500 });
142151
}

apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RescheduleTaskRunService } from "~/v3/services/rescheduleTaskRun.server
1212
import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server";
1313
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
1414
import { parseDelay } from "~/utils/delays";
15+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
1516

1617
const ParamsSchema = z.object({
1718
runParam: z.string(),
@@ -156,6 +157,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
156157
if (error instanceof ServiceValidationError) {
157158
return json({ error: error.message }, { status: 400 });
158159
}
160+
const unroutable = unroutableIdResponse(error);
161+
if (unroutable) {
162+
logger.warn("Unroutable run id on reschedule", {
163+
error: error instanceof Error ? error.message : error,
164+
});
165+
return unroutable;
166+
}
167+
159168
logger.error("Failed to reschedule run", { error });
160169
return json({ error: "Something went wrong, please try again." }, { status: 500 });
161170
}

apps/webapp/app/routes/api.v1.runs.$runParam.result.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.ser
55
import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
66
import { authenticateApiRequest } from "~/services/apiAuth.server";
77
import { logger } from "~/services/logger.server";
8+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
89

910
const ParamsSchema = z.object({
1011
/* This is the run friendly ID */
@@ -41,6 +42,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4142

4243
return json(result);
4344
} catch (error) {
45+
const unroutable = unroutableIdResponse(error);
46+
if (unroutable) {
47+
logger.warn("Unroutable run id on run result", {
48+
error: error instanceof Error ? error.message : error,
49+
});
50+
return unroutable;
51+
}
52+
4453
logger.error("Failed to load run result", { error });
4554
return json({ error: "Something went wrong, please try again." }, { status: 500 });
4655
}

apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
66
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
77
import { verifyHttpCallbackHash } from "~/services/httpCallback.server";
88
import { logger } from "~/services/logger.server";
9+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
910
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
1011
import { engine } from "~/v3/runEngine.server";
1112
import { runStore } from "~/v3/runStore.server";
@@ -102,6 +103,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
102103
{ status: 200 }
103104
);
104105
} catch (error) {
106+
// Same as the complete route: the waitpoint id comes off the URL, so an unconfigured shard
107+
// key is caller-supplied input and must answer 404 rather than 500. This route is a bare
108+
// Remix action, so the api-builder boundary never sees the error — answer it here.
109+
const unroutable = unroutableIdResponse(error);
110+
if (unroutable) {
111+
// Same reason as the complete route: a silent 404 would hide a dropped shard key.
112+
logger.warn("Unroutable waitpoint id on HTTP callback", {
113+
waitpointFriendlyId: params.waitpointFriendlyId,
114+
error: error instanceof Error ? error.message : error,
115+
});
116+
return unroutable;
117+
}
118+
105119
logger.error("Failed to complete HTTP callback", { error });
106120
throw json({ error: "Failed to complete HTTP callback" }, { status: 500 });
107121
}

apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { env } from "~/env.server";
1010
import { logger } from "~/services/logger.server";
1111
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
1212
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
13+
import { unroutableIdResponse } from "~/services/routeBuilders/unroutableId.server";
1314
import { engine } from "~/v3/runEngine.server";
1415
import { runStore } from "~/v3/runStore.server";
1516

@@ -87,6 +88,19 @@ const { action, loader } = createActionApiRoute(
8788
// client gets the correct status code instead of a 500, and we don't log them as errors.
8889
if (error instanceof Response) throw error;
8990

91+
// A caller-supplied id naming a shard this topology has no store for cannot be routed,
92+
// so it is a 404 like an absent token — not the 500 this catch would otherwise answer.
93+
const unroutable = unroutableIdResponse(error);
94+
if (unroutable) {
95+
// Logged so a shard key dropped from an append-only config still alarms, rather than
96+
// every live token on it quietly answering "not found".
97+
logger.warn("Unroutable waitpoint id on token completion", {
98+
waitpointFriendlyId: params.waitpointFriendlyId,
99+
error: error instanceof Error ? error.message : error,
100+
});
101+
throw unroutable;
102+
}
103+
90104
logger.error("Failed to complete waitpoint token", {
91105
error:
92106
error instanceof Error

apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { requireUserId } from "~/services/session.server";
66
import { ProjectParamSchema, v3RunPath } from "~/utils/pathBuilder";
77
import { runStore } from "~/v3/runStore.server";
88
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
9+
import { undefinedOnUnroutableId } from "~/v3/runOpsMigration/unroutableRead.server";
910

1011
const ParamSchema = ProjectParamSchema.extend({
1112
runParam: z.string(),
@@ -15,16 +16,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
1516
const userId = await requireUserId(request);
1617
const { organizationSlug, projectParam, runParam } = ParamSchema.parse(params);
1718

18-
const run = await runStore.findRun(
19-
{
20-
friendlyId: runParam,
21-
},
22-
{
23-
select: {
24-
projectId: true,
25-
runtimeEnvironmentId: true,
26-
},
27-
}
19+
const run = await undefinedOnUnroutableId(
20+
() =>
21+
runStore.findRun(
22+
{
23+
friendlyId: runParam,
24+
},
25+
{
26+
select: {
27+
projectId: true,
28+
runtimeEnvironmentId: true,
29+
},
30+
}
31+
),
32+
{ runParam: params.runParam ?? params.runId }
2833
);
2934

3035
if (!run) {

apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { requireUserId } from "~/services/session.server";
55
import { v3RunSpanPath } from "~/utils/pathBuilder";
66
import { runStore } from "~/v3/runStore.server";
77
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
8+
import { undefinedOnUnroutableId } from "~/v3/runOpsMigration/unroutableRead.server";
89

910
const ParamsSchema = z.object({
1011
projectRef: z.string(),
@@ -36,18 +37,22 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
3637
return new Response("Not found", { status: 404 });
3738
}
3839

39-
const run = await runStore.findRun(
40-
{
41-
friendlyId: validatedParams.runParam,
42-
},
43-
{
44-
select: {
45-
friendlyId: true,
46-
spanId: true,
47-
runtimeEnvironmentId: true,
48-
},
49-
},
50-
prisma
40+
const run = await undefinedOnUnroutableId(
41+
() =>
42+
runStore.findRun(
43+
{
44+
friendlyId: validatedParams.runParam,
45+
},
46+
{
47+
select: {
48+
friendlyId: true,
49+
spanId: true,
50+
runtimeEnvironmentId: true,
51+
},
52+
},
53+
prisma
54+
),
55+
{ runParam: params.runParam ?? params.runId }
5156
);
5257

5358
if (!run) {

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.$io.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
1414
import { requireUserId } from "~/services/session.server";
1515
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
16+
import { undefinedOnUnroutableId } from "~/v3/runOpsMigration/unroutableRead.server";
1617

1718
const ParamsSchema = z.object({
1819
runParam: z.string(),
@@ -61,8 +62,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6162
// Replica lag can null out a live run; a spurious 404 breaks the dashboard Agent tab subscription
6263
// (useRealtimeStream surfaces the error and does not auto-retry). Re-read the primary on a miss.
6364
const run =
64-
(await runStore.findRun(runWhere, runArgs, $replica)) ??
65-
(await runStore.findRunOnPrimary(runWhere, runArgs));
65+
(await undefinedOnUnroutableId(() => runStore.findRun(runWhere, runArgs, $replica), {
66+
runParam: params.runParam,
67+
})) ??
68+
(await undefinedOnUnroutableId(() => runStore.findRunOnPrimary(runWhere, runArgs), {
69+
runParam: params.runParam,
70+
}));
6671

6772
if (!run) {
6873
return new Response("Run not found", { status: 404 });

0 commit comments

Comments
 (0)