|
| 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