Skip to content

Commit fea1a29

Browse files
committed
refactor(dashboard-agent): split the watch tick into delivery, condition, batch and adapters
watch-tick.ts keeps the two task definitions and the per-watch check; the wake delivery, the condition lifecycle, the group tick and the db/session/callback adapters each move to their own module. A pure move: watch-tick.ts re-exports everything it exported before.
1 parent 31698fa commit fea1a29

5 files changed

Lines changed: 797 additions & 717 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import type { WatchCheckResult, WatchObservedOutcome } from "@internal/dashboard-agent-contracts";
2+
import { logger } from "@trigger.dev/sdk";
3+
import type { WatchDeliveryDeps, WatchTickOutcome, WatchTickStore } from "./watch-delivery";
4+
import { REVOKED_CODES, runWatchLifecycle, type CheckOutcome } from "./watch-lifecycle";
5+
6+
// The batch tick: one run per (environment, cadence), for every watch in it.
7+
8+
export type WatchBatchTickPayload = {
9+
environmentId: string;
10+
cadenceMinutes: number;
11+
apiOrigin: string;
12+
/**
13+
* Names the (environment, cadence) and carries no authority: the batch check
14+
* re-authorizes every watch's initiating user against that watch's own scope.
15+
*/
16+
token: string;
17+
/** The chain incarnation this run belongs to. A mismatch means it owns nothing. */
18+
epoch: number;
19+
/** The tick generation this run owns inside `epoch`, starting at 1. */
20+
tick: number;
21+
};
22+
23+
export type WatchBatchCheckEntry = {
24+
watchId: string;
25+
token: string;
26+
/** The generation to claim for this watch (its `tickCount + 1` when listed). */
27+
tick: number;
28+
/** The row is already resolved and its wake is owed, so the group recovers it. */
29+
deliverOnly?: boolean;
30+
result?: WatchCheckResult;
31+
facts?: Record<string, unknown>;
32+
observed?: WatchObservedOutcome;
33+
/** `access_revoked` / `cancelled` / `not_found`, instead of a result. */
34+
code?: string;
35+
error?: string;
36+
};
37+
38+
export type WatchBatchCheckResponse = {
39+
/** This run's epoch/generation is not the chain's, so it owns nothing and exits. */
40+
stale?: boolean;
41+
watches?: WatchBatchCheckEntry[];
42+
/** Whether the group still has active watches, i.e. whether to tick again. */
43+
continues?: boolean;
44+
};
45+
46+
export type WatchBatchTickDeps = {
47+
store: WatchTickStore;
48+
checkBatch: (payload: WatchBatchTickPayload) => Promise<WatchBatchCheckResponse>;
49+
deliver: WatchDeliveryDeps["deliver"];
50+
notifyFired: (target: { watchId: string; token: string }) => Promise<void>;
51+
notifyInvestigate: (target: { watchId: string; token: string }) => Promise<void>;
52+
reschedule: (
53+
payload: WatchBatchTickPayload,
54+
options: { delay: string; idempotencyKey: string }
55+
) => Promise<unknown>;
56+
now?: () => Date;
57+
/** How many watches are resolved at once. Defaults to {@link BATCH_CONCURRENCY}. */
58+
concurrency?: number;
59+
};
60+
61+
export type WatchBatchTickResult = {
62+
outcome: "ticked" | "stale";
63+
results: Array<{ watchId: string; outcome?: WatchTickOutcome; error?: string }>;
64+
rescheduled: boolean;
65+
};
66+
67+
const BATCH_CONCURRENCY = 8;
68+
69+
/** `mapper` over `items`, at most `limit` in flight. Order is preserved. */
70+
async function mapWithConcurrency<T, R>(
71+
items: T[],
72+
limit: number,
73+
mapper: (item: T) => Promise<R>
74+
): Promise<R[]> {
75+
const results = new Array<R>(items.length);
76+
let next = 0;
77+
const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
78+
while (next < items.length) {
79+
const index = next++;
80+
results[index] = await mapper(items[index]!);
81+
}
82+
});
83+
await Promise.all(workers);
84+
return results;
85+
}
86+
87+
/**
88+
* One tick of a whole (environment, cadence) group. Nothing here guards against a
89+
* double fire: the terminal transition and the delivery claim do.
90+
*/
91+
export async function runWatchBatchTick(
92+
payload: WatchBatchTickPayload,
93+
deps: WatchBatchTickDeps
94+
): Promise<WatchBatchTickResult> {
95+
const response = await deps.checkBatch(payload);
96+
97+
if (response.stale) {
98+
logger.info("dashboard-agent watch batch is stale; exiting", {
99+
environmentId: payload.environmentId,
100+
cadenceMinutes: payload.cadenceMinutes,
101+
epoch: payload.epoch,
102+
tick: payload.tick,
103+
});
104+
return { outcome: "stale", results: [], rescheduled: false };
105+
}
106+
107+
const entries = response.watches ?? [];
108+
const results = await mapWithConcurrency(
109+
entries,
110+
deps.concurrency ?? BATCH_CONCURRENCY,
111+
(entry) => resolveBatchEntry(payload, deps, entry)
112+
);
113+
114+
// Before the rethrow below, so the chain survives a watch that keeps failing.
115+
let rescheduled = false;
116+
if (response.continues) {
117+
const next = payload.tick + 1;
118+
await deps.reschedule(
119+
{ ...payload, tick: next },
120+
{
121+
delay: `${payload.cadenceMinutes}m`,
122+
// Keyed on the epoch too, so a re-armed chain can't collide with its
123+
// predecessor's keys.
124+
idempotencyKey: `watch-batch:${payload.environmentId}:${payload.cadenceMinutes}:${payload.epoch}:tick:${next}`,
125+
}
126+
);
127+
rescheduled = true;
128+
}
129+
130+
const failed = results.filter((result) => result.error !== undefined);
131+
if (failed.length > 0) {
132+
// Safe to retry the whole batch: the chain's claim is resumable and the check hands
133+
// back the owed wakes again.
134+
throw new Error(
135+
`${failed.length} of ${entries.length} watches failed their tick (${failed
136+
.map((result) => result.watchId)
137+
.join(", ")})`
138+
);
139+
}
140+
141+
return { outcome: "ticked", results, rescheduled };
142+
}
143+
144+
/** One watch of a batch: each resolves in its own try, so a failure isolates. */
145+
async function resolveBatchEntry(
146+
payload: WatchBatchTickPayload,
147+
deps: WatchBatchTickDeps,
148+
entry: WatchBatchCheckEntry
149+
): Promise<{ watchId: string; outcome?: WatchTickOutcome; error?: string }> {
150+
const target = { apiOrigin: payload.apiOrigin, watchId: entry.watchId, token: entry.token };
151+
try {
152+
const result = await runWatchLifecycle(
153+
{ watchId: entry.watchId, tick: entry.tick, deliverOnly: entry.deliverOnly },
154+
{
155+
store: deps.store,
156+
deliver: deps.deliver,
157+
notifyFired: () => deps.notifyFired(target),
158+
notifyInvestigate: () => deps.notifyInvestigate(target),
159+
now: deps.now,
160+
check: async () => batchCheckOutcome(entry),
161+
// The group's single reschedule covers every watch in it.
162+
onPending: async () => {},
163+
}
164+
);
165+
return { watchId: entry.watchId, outcome: result.outcome };
166+
} catch (error) {
167+
logger.error("dashboard-agent watch batch: a watch failed its tick", {
168+
watchId: entry.watchId,
169+
environmentId: payload.environmentId,
170+
error: (error as Error).message,
171+
});
172+
return { watchId: entry.watchId, error: (error as Error).message };
173+
}
174+
}
175+
176+
function batchCheckOutcome(entry: WatchBatchCheckEntry): CheckOutcome {
177+
if (entry.code && REVOKED_CODES.has(entry.code)) return { kind: "revoked", code: entry.code };
178+
if (!entry.result || entry.result === "unavailable") {
179+
return { kind: "unavailable", detail: entry.error, observed: entry.observed };
180+
}
181+
return {
182+
kind: "result",
183+
result: entry.result,
184+
facts: entry.facts,
185+
observed: entry.observed,
186+
};
187+
}

0 commit comments

Comments
 (0)