Skip to content

Commit 7040f45

Browse files
samejrclaude
andcommitted
fix(webapp): stop a ClickHouse outage crashing the dev server
The env dashboard defers its analytics queries: the presenters create the promises eagerly and the route only subscribes via typeddefer after further awaits. With ClickHouse unreachable the queries reject instantly — inside that gap — and Node treats the not-yet-subscribed rejection as unhandled and kills the whole server. The agent sparkline promises aren't consumed by the unified path at all, so with agents present those reject unhandled every time. Each deliberately un-awaited promise now carries a no-op backstop catch at creation. Consumers still receive rejections (the stats chips keep their error state); the process just no longer dies. With ClickHouse down the dashboard now renders with failed-stats placeholders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b443b2d commit 7040f45

4 files changed

Lines changed: 66 additions & 34 deletions

File tree

apps/webapp/app/presenters/v3/AgentListPresenter.server.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type ClickHouse } from "@internal/clickhouse";
77
import { z } from "zod";
88
import { $replica } from "~/db.server";
99
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
10+
import { backstopPromise } from "~/utils/backstopPromise";
1011
import { singleton } from "~/utils/singleton";
1112
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
1213

@@ -97,15 +98,19 @@ export class AgentListPresenter {
9798
};
9899
}
99100

100-
// All queries are deferred for streaming
101-
const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs);
102-
const conversationSparklines = this.#getConversationSparklines(
103-
clickhouse,
104-
environmentId,
105-
slugs
101+
// All queries are deferred for streaming. Backstopped: consumers subscribe
102+
// late (or, for some callers, not at all), and an unhandled rejection in
103+
// the gap kills the server.
104+
const activeStates = backstopPromise(this.#getActiveStates(clickhouse, environmentId, slugs));
105+
const conversationSparklines = backstopPromise(
106+
this.#getConversationSparklines(clickhouse, environmentId, slugs)
107+
);
108+
const costSparklines = backstopPromise(
109+
this.#getCostSparklines(clickhouse, environmentId, slugs)
110+
);
111+
const tokenSparklines = backstopPromise(
112+
this.#getTokenSparklines(clickhouse, environmentId, slugs)
106113
);
107-
const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs);
108-
const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs);
109114

110115
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
111116
}

apps/webapp/app/presenters/v3/TaskListPresenter.server.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ClickHouseEnvironmentMetricsRepository,
1010
type CurrentRunningStats,
1111
} from "~/services/environmentMetricsRepository.server";
12+
import { backstopPromise } from "~/utils/backstopPromise";
1213
import { singleton } from "~/utils/singleton";
1314
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
1415

@@ -84,16 +85,18 @@ export class TaskListPresenter {
8485
});
8586

8687
// IMPORTANT: Don't await this, we want to return the promise
87-
// so we can defer the loading of the data. The caller is responsible for
88-
// consuming it — an unconsumed promise here would become an unhandled
89-
// rejection if the underlying query fails.
90-
const runningStats = environmentMetricsRepository.getCurrentRunningStats({
91-
organizationId,
92-
projectId,
93-
environmentId,
94-
days: 6,
95-
tasks: slugs,
96-
});
88+
// so we can defer the loading of the data. Backstopped because the caller
89+
// only subscribes after further awaits — with ClickHouse down this rejects
90+
// instantly, and an unhandled rejection kills the whole server.
91+
const runningStats = backstopPromise(
92+
environmentMetricsRepository.getCurrentRunningStats({
93+
organizationId,
94+
projectId,
95+
environmentId,
96+
days: 6,
97+
tasks: slugs,
98+
})
99+
);
97100

98101
return { tasks, runningStats };
99102
}

apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { z } from "zod";
99
import { $replica } from "~/db.server";
1010
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
11+
import { backstopPromise } from "~/utils/backstopPromise";
1112
import { singleton } from "~/utils/singleton";
1213
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
1314
import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server";
@@ -69,26 +70,34 @@ export class UnifiedTaskListPresenter {
6970
const items = toUnifiedItems(taskResult.tasks, agentResult.agents);
7071
const allSlugs = items.map((item) => item.slug);
7172

73+
// Both deferred promises below are backstopped: the route only subscribes
74+
// (via typeddefer) after further awaits, so a rejection landing in that
75+
// gap — e.g. ClickHouse refusing connections — would otherwise be an
76+
// unhandled rejection and take the server down. Awaiting them still
77+
// rejects into the route's error elements.
7278
const hourlyActivity: Promise<HourlyTaskActivity> =
7379
allSlugs.length === 0
7480
? Promise.resolve({})
75-
: (async () => {
76-
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
77-
args.organizationId,
78-
"standard"
79-
);
80-
return getHourlyTaskActivity(clickhouse, {
81-
organizationId: args.organizationId,
82-
projectId: args.projectId,
83-
environmentId: args.environmentId,
84-
slugs: allSlugs,
85-
});
86-
})();
81+
: backstopPromise(
82+
(async () => {
83+
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
84+
args.organizationId,
85+
"standard"
86+
);
87+
return getHourlyTaskActivity(clickhouse, {
88+
organizationId: args.organizationId,
89+
projectId: args.projectId,
90+
environmentId: args.environmentId,
91+
slugs: allSlugs,
92+
});
93+
})()
94+
);
8795

88-
const runningStates: Promise<UnifiedRunningStates> = Promise.all([
89-
taskResult.runningStats,
90-
agentResult.activeStates,
91-
]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates));
96+
const runningStates: Promise<UnifiedRunningStates> = backstopPromise(
97+
Promise.all([taskResult.runningStats, agentResult.activeStates]).then(
98+
([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)
99+
)
100+
);
92101

93102
return { items, hourlyActivity, runningStates };
94103
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Marks a deliberately un-awaited promise as handled without consuming it.
3+
*
4+
* For promises created eagerly and consumed later — handed to Remix `defer()`,
5+
* or composed into another promise after further awaits — a rejection that
6+
* lands before the consumer subscribes counts as an unhandled rejection, and
7+
* Node brings the whole process down on those. The no-op catch here is a
8+
* separate branch: awaiting the returned promise still rejects as normal
9+
* (e.g. into a `<TypedAwait errorElement>`), and a branch nobody ever awaits
10+
* simply logs nothing instead of crashing the server.
11+
*/
12+
export function backstopPromise<T>(promise: Promise<T>): Promise<T> {
13+
promise.catch(() => {});
14+
return promise;
15+
}

0 commit comments

Comments
 (0)