Skip to content

Commit 3b04574

Browse files
committed
refactor(run-engine,webapp): one shared gate parser with tests
The webapp and the enqueue system carried identical copies of the gate contract with nothing enforcing their parity. parseGates now lives in the run-engine package with unit tests covering every rule (bounds kept inclusive at 128, empty keys inherit, malformed entries dropped, capped at two), and both callers delegate to it.
1 parent 57f5139 commit 3b04574

5 files changed

Lines changed: 93 additions & 46 deletions

File tree

apps/webapp/app/services/taskMetadataCache.server.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Redis, Result, Callback } from "ioredis";
2+
import { parseGates } from "@internal/run-engine";
23
import type { TaskTriggerSource } from "@trigger.dev/database";
34
import { logger } from "./logger.server";
45

@@ -61,27 +62,8 @@ export type RedisTaskMetadataCacheOptions = {
6162
* entries so a malformed value can never fail a trigger.
6263
*/
6364
export function parseTaskGates(gates: unknown): TaskMetadataGate[] | null {
64-
if (!Array.isArray(gates) || gates.length === 0) {
65-
return null;
66-
}
67-
68-
const parsed = gates.flatMap((gate) => {
69-
if (!gate || typeof gate !== "object" || typeof (gate as any).queue !== "string") {
70-
return [];
71-
}
72-
const queue = (gate as any).queue;
73-
if (queue.length === 0 || queue.length > 128) {
74-
return [];
75-
}
76-
const rawKey = (gate as any).concurrencyKey;
77-
if (typeof rawKey === "string" && rawKey.length > 128) {
78-
return [];
79-
}
80-
const concurrencyKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
81-
return [{ queue, concurrencyKey }];
82-
});
83-
84-
return parsed.length > 0 ? parsed.slice(0, 2) : null;
65+
const parsed = parseGates(gates);
66+
return parsed.length > 0 ? parsed : null;
8567
}
8668

8769
type EncodedEntry = {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseGates } from "./gateParsing.js";
3+
4+
describe("parseGates", () => {
5+
it("keeps well-shaped gates and caps at two", () => {
6+
expect(
7+
parseGates([{ queue: "a" }, { queue: "b", concurrencyKey: "shared" }, { queue: "c" }])
8+
).toEqual([
9+
{ queue: "a", concurrencyKey: undefined },
10+
{ queue: "b", concurrencyKey: "shared" },
11+
]);
12+
});
13+
14+
it("returns empty for non-arrays and empty arrays", () => {
15+
expect(parseGates(undefined)).toEqual([]);
16+
expect(parseGates(null)).toEqual([]);
17+
expect(parseGates("gates")).toEqual([]);
18+
expect(parseGates([])).toEqual([]);
19+
});
20+
21+
it("drops malformed entries", () => {
22+
expect(parseGates([null, "x", 4, { concurrencyKey: "k" }, { queue: 7 }])).toEqual([]);
23+
});
24+
25+
it("drops empty and over-length queue names, keeping exactly 128", () => {
26+
const max = "q".repeat(128);
27+
expect(parseGates([{ queue: "" }, { queue: "q".repeat(129) }, { queue: max }])).toEqual([
28+
{ queue: max, concurrencyKey: undefined },
29+
]);
30+
});
31+
32+
it("treats an empty-string key as omitted and keeps exactly 128-char keys", () => {
33+
const maxKey = "k".repeat(128);
34+
expect(parseGates([{ queue: "a", concurrencyKey: "" }])).toEqual([
35+
{ queue: "a", concurrencyKey: undefined },
36+
]);
37+
expect(parseGates([{ queue: "a", concurrencyKey: maxKey }])).toEqual([
38+
{ queue: "a", concurrencyKey: maxKey },
39+
]);
40+
});
41+
42+
it("drops gates whose literal key exceeds the cap", () => {
43+
expect(parseGates([{ queue: "a", concurrencyKey: "k".repeat(129) }, { queue: "b" }])).toEqual([
44+
{ queue: "b", concurrencyKey: undefined },
45+
]);
46+
});
47+
48+
it("ignores non-string keys", () => {
49+
expect(parseGates([{ queue: "a", concurrencyKey: 5 }])).toEqual([
50+
{ queue: "a", concurrencyKey: undefined },
51+
]);
52+
});
53+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* The gate contract for stored/untyped gate values (BackgroundWorkerTask.gates,
3+
* TaskRun.gates are Json columns): keep only well-shaped entries so a malformed
4+
* value can never fail a trigger or an enqueue. A gate needs a queue name within
5+
* the manifest bounds (1-128 chars); a literal concurrency key must fit the same
6+
* bounds, and an empty-string key means "omitted" so the gate inherits the run's
7+
* key. At most two gates apply.
8+
*/
9+
export type ParsedGate = { queue: string; concurrencyKey?: string };
10+
11+
export function parseGates(gates: unknown): ParsedGate[] {
12+
if (!Array.isArray(gates) || gates.length === 0) {
13+
return [];
14+
}
15+
16+
const parsed = gates.flatMap((gate): ParsedGate[] => {
17+
if (!gate || typeof gate !== "object" || typeof (gate as any).queue !== "string") {
18+
return [];
19+
}
20+
const queue = (gate as any).queue;
21+
if (queue.length === 0 || queue.length > 128) {
22+
return [];
23+
}
24+
const rawKey = (gate as any).concurrencyKey;
25+
if (typeof rawKey === "string" && rawKey.length > 128) {
26+
return [];
27+
}
28+
const concurrencyKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
29+
return [{ queue, concurrencyKey }];
30+
});
31+
32+
return parsed.slice(0, 2);
33+
}

internal-packages/run-engine/src/engine/systems/enqueueSystem.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { RunStore } from "@internal/run-store";
88
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
99
import type { MinimalAuthenticatedEnvironment } from "../../shared/index.js";
1010
import { QUEUED_SNAPSHOT_DESCRIPTION, QUEUED_SNAPSHOT_STATUS } from "../consts.js";
11+
import { parseGates } from "../gateParsing.js";
1112
import type { ExecutionSnapshotSystem } from "./executionSnapshotSystem.js";
1213
import type { SystemResources } from "./systems.js";
1314

@@ -16,34 +17,11 @@ export type EnqueueSystemOptions = {
1617
executionSnapshotSystem: ExecutionSnapshotSystem;
1718
};
1819

19-
/**
20-
* TaskRun.gates is an untyped Json column; admit correctness only needs well-shaped
21-
* entries, so anything malformed is dropped rather than failing the enqueue.
22-
*/
2320
function parseRunGates(
2421
gates: unknown
2522
): Array<{ queue: string; concurrencyKey?: string }> | undefined {
26-
if (!Array.isArray(gates) || gates.length === 0) {
27-
return undefined;
28-
}
29-
30-
const parsed = gates.flatMap((gate) => {
31-
if (!gate || typeof gate !== "object" || typeof (gate as any).queue !== "string") {
32-
return [];
33-
}
34-
const queue = (gate as any).queue;
35-
if (queue.length === 0 || queue.length > 128) {
36-
return [];
37-
}
38-
const rawKey = (gate as any).concurrencyKey;
39-
if (typeof rawKey === "string" && rawKey.length > 128) {
40-
return [];
41-
}
42-
const concurrencyKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
43-
return [{ queue, concurrencyKey }];
44-
});
45-
46-
return parsed.length > 0 ? parsed.slice(0, 2) : undefined;
23+
const parsed = parseGates(gates);
24+
return parsed.length > 0 ? parsed : undefined;
4725
}
4826

4927
export class EnqueueSystem {

internal-packages/run-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,4 @@ export type {
6161
WatcherEntry,
6262
} from "./engine/waitpointCoordinator/storeCoordinator.js";
6363
export { WaitpointKeyTagError } from "./engine/waitpointCoordinator/keys.js";
64+
export { parseGates, type ParsedGate } from "./engine/gateParsing.js";

0 commit comments

Comments
 (0)