Skip to content

Commit b7cdd93

Browse files
committed
Merge branch 'w1-ro-taskdetail-taskdetailpresenter-getactivity-split-neutral-verify' into integration-batch-01
2 parents 3cc80db + 66a5afa commit b7cdd93

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Add a ClickHouse testcontainer test pinning `TaskDetailPresenter.getActivity` as verified ClickHouse-only (task_runs_v2) and classified split-neutral for the run-ops DB split.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ export class TaskDetailPresenter {
162162
};
163163
}
164164

165+
// SPLIT-NEUTRAL (W1-RO-TASKDETAIL): served entirely from ClickHouse (task_runs_v2);
166+
// no run-ops Postgres read — single-DB behavior is n-a, RoutingRunStore is not involved.
165167
async getActivity({
166168
organizationId,
167169
projectId,
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPLIT-NEUTRAL VERIFICATION (W1-RO-TASKDETAIL): getActivity reads ClickHouse
2+
// (task_runs_v2) ONLY and never touches the run-ops Prisma client. The hetero
3+
// PG14/PG17 fixture is deliberately not applicable here — there is no run-ops
4+
// Postgres read to validate cross-version. The throwing Proxy passed as
5+
// `replica` proves it: any access throws, so a passing test is the proof.
6+
7+
import { describe, expect, vi } from "vitest";
8+
import { clickhouseTest } from "@internal/testcontainers";
9+
import { ClickHouse, type TaskRunV2 } from "@internal/clickhouse";
10+
import { randomUUID } from "node:crypto";
11+
import { z } from "zod";
12+
import { TaskDetailPresenter } from "~/presenters/v3/TaskDetailPresenter.server";
13+
14+
vi.setConfig({ testTimeout: 60_000 });
15+
16+
const organizationId = "org_activity_test";
17+
const projectId = "project_activity_test";
18+
const environmentId = "env_activity_test";
19+
const taskSlug = "my-activity-task";
20+
21+
function makeRun(overrides: Partial<TaskRunV2>): TaskRunV2 {
22+
const createdAt = overrides.created_at ?? Date.now();
23+
return {
24+
environment_id: environmentId,
25+
organization_id: organizationId,
26+
project_id: projectId,
27+
run_id: `run_${randomUUID()}`,
28+
friendly_id: `friendly_${randomUUID()}`,
29+
updated_at: createdAt,
30+
created_at: createdAt,
31+
status: "COMPLETED_SUCCESSFULLY",
32+
environment_type: "PRODUCTION",
33+
attempt: 1,
34+
engine: "V2",
35+
task_identifier: taskSlug,
36+
queue: "my-queue",
37+
schedule_id: "",
38+
batch_id: "",
39+
task_version: "",
40+
sdk_version: "",
41+
cli_version: "",
42+
machine_preset: "",
43+
root_run_id: "",
44+
parent_run_id: "",
45+
span_id: "",
46+
trace_id: "",
47+
idempotency_key: "",
48+
expiration_ttl: "",
49+
_version: "1",
50+
_is_deleted: 0,
51+
...overrides,
52+
};
53+
}
54+
55+
describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => {
56+
clickhouseTest(
57+
"buckets task_runs_v2 activity by status group, excludes deleted, never reads Postgres",
58+
async ({ clickhouseContainer }) => {
59+
const clickhouse = new ClickHouse({
60+
url: clickhouseContainer.getConnectionUrl(),
61+
name: "task-detail-activity-test",
62+
compression: { request: true },
63+
});
64+
65+
const insert = clickhouse.writer.insert({
66+
name: "insertTaskRunsActivityTest",
67+
table: "trigger_dev.task_runs_v2",
68+
schema: z.any(),
69+
settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 },
70+
});
71+
72+
// 6h window => 1h buckets => 6 buckets.
73+
const from = new Date("2026-01-01T00:00:00Z");
74+
const to = new Date("2026-01-01T06:00:00Z");
75+
76+
// Bucket 0 (00:00–01:00): 1 COMPLETED, 1 FAILED.
77+
// Bucket 2 (02:00–03:00): 1 CANCELED, 1 RUNNING (EXECUTING), 1 unknown-status
78+
// (folds into RUNNING) => RUNNING total = 2.
79+
// Plus a deleted row in bucket 0 that MUST be excluded.
80+
const bucket0 = new Date("2026-01-01T00:30:00Z").getTime();
81+
const bucket2 = new Date("2026-01-01T02:30:00Z").getTime();
82+
83+
const rows = [
84+
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY" }),
85+
makeRun({ created_at: bucket0, status: "CRASHED" }), // FAILED group
86+
makeRun({ created_at: bucket2, status: "CANCELED" }), // CANCELED group
87+
makeRun({ created_at: bucket2, status: "EXECUTING" }), // RUNNING group
88+
makeRun({ created_at: bucket2, status: "SOME_UNKNOWN_STATUS" }), // folds into RUNNING
89+
// Deleted row — distinct run, _is_deleted = 1, must NOT be counted.
90+
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY", _is_deleted: 1 }),
91+
];
92+
93+
const [insertError] = await insert(rows);
94+
expect(insertError).toBeNull();
95+
96+
const throwingReplica = new Proxy(
97+
{},
98+
{
99+
get() {
100+
throw new Error("getActivity must not touch the run-ops Prisma client");
101+
},
102+
}
103+
) as never;
104+
105+
const presenter = new TaskDetailPresenter(throwingReplica, clickhouse);
106+
107+
const activity = await presenter.getActivity({
108+
organizationId,
109+
projectId,
110+
environmentId,
111+
taskSlug,
112+
from,
113+
to,
114+
});
115+
116+
// Stable legend, fixed group order.
117+
expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]);
118+
119+
// 6 one-hour buckets, every bucket carries all four group keys.
120+
expect(activity.data).toHaveLength(6);
121+
for (const point of activity.data) {
122+
expect(typeof point.bucket).toBe("number");
123+
expect(point).toHaveProperty("COMPLETED");
124+
expect(point).toHaveProperty("FAILED");
125+
expect(point).toHaveProperty("CANCELED");
126+
expect(point).toHaveProperty("RUNNING");
127+
}
128+
129+
// Buckets are epoch MILLISECONDS aligned to the hour.
130+
const expectedStart = Math.floor(from.getTime() / (60 * 60 * 1000)) * (60 * 60 * 1000);
131+
const byBucket = new Map(activity.data.map((p) => [p.bucket, p]));
132+
const p0 = byBucket.get(expectedStart)!;
133+
const p2 = byBucket.get(expectedStart + 2 * 60 * 60 * 1000)!;
134+
expect(p0).toBeDefined();
135+
expect(p2).toBeDefined();
136+
137+
// Bucket 0: 1 COMPLETED, 1 FAILED, deleted row excluded.
138+
expect(p0.COMPLETED).toBe(1);
139+
expect(p0.FAILED).toBe(1);
140+
expect(p0.CANCELED).toBe(0);
141+
expect(p0.RUNNING).toBe(0);
142+
143+
// Bucket 2: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2.
144+
expect(p2.COMPLETED).toBe(0);
145+
expect(p2.FAILED).toBe(0);
146+
expect(p2.CANCELED).toBe(1);
147+
expect(p2.RUNNING).toBe(2);
148+
149+
// Every other bucket is all-zero for every group.
150+
for (const point of activity.data) {
151+
if (point.bucket === p0.bucket || point.bucket === p2.bucket) continue;
152+
expect(point.COMPLETED).toBe(0);
153+
expect(point.FAILED).toBe(0);
154+
expect(point.CANCELED).toBe(0);
155+
expect(point.RUNNING).toBe(0);
156+
}
157+
}
158+
);
159+
});

0 commit comments

Comments
 (0)