gorsee 0.2.10 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -4
- package/bin/gorsee.js +38 -0
- package/dist-pkg/ai/bundle.d.ts +1 -0
- package/dist-pkg/ai/framework-context.d.ts +2 -0
- package/dist-pkg/ai/framework-context.js +6 -1
- package/dist-pkg/ai/ide.d.ts +1 -0
- package/dist-pkg/ai/ide.js +3 -0
- package/dist-pkg/ai/index.d.ts +10 -1
- package/dist-pkg/ai/index.js +13 -2
- package/dist-pkg/ai/mcp.js +4 -0
- package/dist-pkg/ai/session-pack.d.ts +8 -0
- package/dist-pkg/ai/session-pack.js +51 -1
- package/dist-pkg/ai/store.d.ts +25 -1
- package/dist-pkg/ai/store.js +89 -3
- package/dist-pkg/ai/summary.d.ts +88 -0
- package/dist-pkg/ai/summary.js +310 -1
- package/dist-pkg/build/manifest.d.ts +4 -2
- package/dist-pkg/build/manifest.js +32 -2
- package/dist-pkg/cli/cmd-ai.js +66 -0
- package/dist-pkg/cli/cmd-build.js +72 -26
- package/dist-pkg/cli/cmd-check.js +104 -11
- package/dist-pkg/cli/cmd-create.js +333 -7
- package/dist-pkg/cli/cmd-deploy.js +17 -3
- package/dist-pkg/cli/cmd-docs.d.ts +3 -1
- package/dist-pkg/cli/cmd-docs.js +5 -3
- package/dist-pkg/cli/cmd-start.js +8 -1
- package/dist-pkg/cli/cmd-upgrade.d.ts +3 -0
- package/dist-pkg/cli/cmd-upgrade.js +14 -2
- package/dist-pkg/cli/cmd-worker.d.ts +9 -0
- package/dist-pkg/cli/cmd-worker.js +78 -0
- package/dist-pkg/cli/framework-md.js +16 -4
- package/dist-pkg/cli/index.js +5 -0
- package/dist-pkg/runtime/app-config.d.ts +5 -0
- package/dist-pkg/runtime/app-config.js +26 -5
- package/dist-pkg/server/index.d.ts +2 -1
- package/dist-pkg/server/index.js +1 -0
- package/dist-pkg/server/jobs.d.ts +35 -1
- package/dist-pkg/server/jobs.js +226 -3
- package/dist-pkg/server/manifest.d.ts +30 -0
- package/dist-pkg/server/manifest.js +30 -1
- package/dist-pkg/server/redis-client.d.ts +9 -0
- package/dist-pkg/server/redis-client.js +4 -1
- package/dist-pkg/server/redis-job-queue.d.ts +2 -0
- package/dist-pkg/server/redis-job-queue.js +434 -16
- package/dist-pkg/server/worker-service.d.ts +33 -0
- package/dist-pkg/server/worker-service.js +135 -0
- package/dist-pkg/server-entry.d.ts +2 -1
- package/dist-pkg/server-entry.js +4 -0
- package/package.json +3 -2
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
export const BUILD_MANIFEST_SCHEMA_VERSION = 1;
|
|
3
|
+
export const BUILD_MANIFEST_SCHEMA_VERSION = 1, RELEASE_ARTIFACT_SCHEMA_VERSION = 1;
|
|
4
4
|
export async function loadBuildManifest(distDir) {
|
|
5
5
|
const raw = await readFile(join(distDir, "manifest.json"), "utf-8");
|
|
6
6
|
return parseBuildManifest(raw);
|
|
7
7
|
}
|
|
8
|
+
export async function loadReleaseArtifact(distDir) {
|
|
9
|
+
const raw = await readFile(join(distDir, "release.json"), "utf-8");
|
|
10
|
+
return parseReleaseArtifact(raw);
|
|
11
|
+
}
|
|
8
12
|
export function parseBuildManifest(raw) {
|
|
9
13
|
const manifest = JSON.parse(raw);
|
|
10
14
|
validateBuildManifest(manifest);
|
|
11
15
|
return manifest;
|
|
12
16
|
}
|
|
17
|
+
export function parseReleaseArtifact(raw) {
|
|
18
|
+
const artifact = JSON.parse(raw);
|
|
19
|
+
validateReleaseArtifact(artifact);
|
|
20
|
+
return artifact;
|
|
21
|
+
}
|
|
13
22
|
function validateBuildManifest(manifest) {
|
|
14
23
|
if (manifest.schemaVersion !== BUILD_MANIFEST_SCHEMA_VERSION)
|
|
15
24
|
throw Error(`Unsupported build manifest schema version: expected ${BUILD_MANIFEST_SCHEMA_VERSION}, received ${String(manifest.schemaVersion)}`);
|
|
@@ -22,6 +31,26 @@ function validateBuildManifest(manifest) {
|
|
|
22
31
|
if (typeof manifest.buildTime !== "string" || manifest.buildTime.length === 0)
|
|
23
32
|
throw Error("Invalid build manifest: buildTime must be a non-empty string");
|
|
24
33
|
}
|
|
34
|
+
function validateReleaseArtifact(artifact) {
|
|
35
|
+
if (artifact.schemaVersion !== RELEASE_ARTIFACT_SCHEMA_VERSION)
|
|
36
|
+
throw Error(`Unsupported release artifact schema version: expected ${RELEASE_ARTIFACT_SCHEMA_VERSION}, received ${String(artifact.schemaVersion)}`);
|
|
37
|
+
if (artifact.appMode !== "frontend" && artifact.appMode !== "fullstack" && artifact.appMode !== "server")
|
|
38
|
+
throw Error("Invalid release artifact: appMode must be frontend, fullstack, or server");
|
|
39
|
+
if (typeof artifact.generatedAt !== "string" || artifact.generatedAt.length === 0)
|
|
40
|
+
throw Error("Invalid release artifact: generatedAt must be a non-empty string");
|
|
41
|
+
if (!artifact.summary || typeof artifact.summary !== "object")
|
|
42
|
+
throw Error("Invalid release artifact: summary must be an object");
|
|
43
|
+
if (!artifact.runtime || typeof artifact.runtime !== "object")
|
|
44
|
+
throw Error("Invalid release artifact: runtime must be an object");
|
|
45
|
+
if (!artifact.artifacts || typeof artifact.artifacts !== "object")
|
|
46
|
+
throw Error("Invalid release artifact: artifacts must be an object");
|
|
47
|
+
if (!Array.isArray(artifact.artifacts.clientAssets) || !Array.isArray(artifact.artifacts.serverEntries) || !Array.isArray(artifact.artifacts.prerenderedHtml))
|
|
48
|
+
throw Error("Invalid release artifact: artifact file groups must be arrays");
|
|
49
|
+
if (typeof artifact.artifacts.buildManifest !== "string" || artifact.artifacts.buildManifest.length === 0)
|
|
50
|
+
throw Error("Invalid release artifact: buildManifest must be a non-empty string");
|
|
51
|
+
if (!Array.isArray(artifact.runtime.processEntrypoints) || !Array.isArray(artifact.runtime.handlerEntrypoints))
|
|
52
|
+
throw Error("Invalid release artifact: runtime entrypoint groups must be arrays");
|
|
53
|
+
}
|
|
25
54
|
export function getRouteBuildEntry(manifest, pathname) {
|
|
26
55
|
return manifest.routes[pathname];
|
|
27
56
|
}
|
|
@@ -9,6 +9,9 @@ export interface RedisLikeClient {
|
|
|
9
9
|
pttl?(key: string): Awaitable<number>;
|
|
10
10
|
ttl?(key: string): Awaitable<number>;
|
|
11
11
|
setnx?(key: string, value: string): Awaitable<number>;
|
|
12
|
+
zadd?(key: string, score: number, member: string): Awaitable<number>;
|
|
13
|
+
zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
|
|
14
|
+
zrem?(key: string, ...members: string[]): Awaitable<number>;
|
|
12
15
|
}
|
|
13
16
|
export declare function buildRedisKey(prefix: string, key: string): string;
|
|
14
17
|
export declare function stripRedisPrefix(prefix: string, key: string): string;
|
|
@@ -22,6 +25,9 @@ export interface NodeRedisClientLike {
|
|
|
22
25
|
pttl?(key: string): Awaitable<number>;
|
|
23
26
|
ttl?(key: string): Awaitable<number>;
|
|
24
27
|
setnx?(key: string, value: string): Awaitable<number>;
|
|
28
|
+
zadd?(key: string, score: number, member: string): Awaitable<number>;
|
|
29
|
+
zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
|
|
30
|
+
zrem?(key: string, ...members: string[]): Awaitable<number>;
|
|
25
31
|
}
|
|
26
32
|
export interface IORedisClientLike {
|
|
27
33
|
get(key: string): Awaitable<string | null>;
|
|
@@ -33,6 +39,9 @@ export interface IORedisClientLike {
|
|
|
33
39
|
pttl?(key: string): Awaitable<number>;
|
|
34
40
|
ttl?(key: string): Awaitable<number>;
|
|
35
41
|
setnx?(key: string, value: string): Awaitable<number>;
|
|
42
|
+
zadd?(key: string, score: number, member: string): Awaitable<number>;
|
|
43
|
+
zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
|
|
44
|
+
zrem?(key: string, ...members: string[]): Awaitable<number>;
|
|
36
45
|
}
|
|
37
46
|
export declare function createNodeRedisLikeClient(client: NodeRedisClientLike): RedisLikeClient;
|
|
38
47
|
export declare function createIORedisLikeClient(client: IORedisClientLike): RedisLikeClient;
|
|
@@ -15,7 +15,10 @@ export function createNodeRedisLikeClient(client) {
|
|
|
15
15
|
expire: client.expire ? (key, seconds) => client.expire(key, seconds) : void 0,
|
|
16
16
|
pttl: client.pttl ? (key) => client.pttl(key) : void 0,
|
|
17
17
|
ttl: client.ttl ? (key) => client.ttl(key) : void 0,
|
|
18
|
-
setnx: client.setnx ? (key, value) => client.setnx(key, value) : void 0
|
|
18
|
+
setnx: client.setnx ? (key, value) => client.setnx(key, value) : void 0,
|
|
19
|
+
zadd: client.zadd ? (key, score, member) => client.zadd(key, score, member) : void 0,
|
|
20
|
+
zrangebyscore: client.zrangebyscore ? (key, min, max) => client.zrangebyscore(key, min, max) : void 0,
|
|
21
|
+
zrem: client.zrem ? (key, ...members) => client.zrem(key, ...members) : void 0
|
|
19
22
|
};
|
|
20
23
|
}
|
|
21
24
|
export function createIORedisLikeClient(client) {
|
|
@@ -1,26 +1,41 @@
|
|
|
1
|
+
import { emitJobLifecycleEvent } from "./jobs.js";
|
|
1
2
|
import { buildRedisKey, stripRedisPrefix } from "./redis-client.js";
|
|
2
3
|
export function createRedisJobQueue(client, options = {}) {
|
|
3
4
|
if (!client.incr || !client.expire || !client.setnx)
|
|
4
5
|
throw Error("Redis job queue requires incr(), expire(), and setnx() support on the Redis client.");
|
|
5
|
-
const prefix = options.prefix ?? "gorsee:jobs", lockTtlSeconds = options.lockTtlSeconds ?? 30, instanceId = options.instanceId ?? crypto.randomUUID(), handlers = new Map;
|
|
6
|
+
const prefix = options.prefix ?? "gorsee:jobs", lockTtlSeconds = options.lockTtlSeconds ?? 30, lockRenewIntervalMs = options.lockRenewIntervalMs ?? Math.max(1000, Math.floor(lockTtlSeconds * 1000 / 3)), historyLimit = options.historyLimit ?? 100, instanceId = options.instanceId ?? crypto.randomUUID(), handlers = new Map;
|
|
6
7
|
for (const job of options.jobs ?? [])
|
|
7
8
|
handlers.set(job.name, job);
|
|
8
9
|
return {
|
|
9
10
|
async enqueue(job, payload, enqueueOptions = {}) {
|
|
10
11
|
handlers.set(job.name, job);
|
|
11
|
-
const sequence = await client.incr(buildRedisKey(prefix, "__sequence")), id = `${job.name}:${sequence}:${crypto.randomUUID()}`, enqueued = {
|
|
12
|
+
const sequence = await client.incr(buildRedisKey(prefix, "__sequence")), id = `${job.name}:${sequence}:${crypto.randomUUID()}`, runAt = enqueueOptions.runAt ?? Date.now(), enqueued = {
|
|
12
13
|
id,
|
|
13
14
|
name: job.name,
|
|
14
15
|
payload,
|
|
15
|
-
runAt
|
|
16
|
+
runAt,
|
|
16
17
|
attempts: 0,
|
|
17
18
|
maxAttempts: enqueueOptions.maxAttempts ?? 3,
|
|
18
19
|
backoffMs: enqueueOptions.backoffMs ?? 1000
|
|
19
|
-
}
|
|
20
|
-
await client.set(jobKey(prefix, id), JSON.stringify({
|
|
20
|
+
}, stored = {
|
|
21
21
|
...enqueued,
|
|
22
|
-
sequence
|
|
23
|
-
|
|
22
|
+
sequence,
|
|
23
|
+
createdAt: Date.now(),
|
|
24
|
+
updatedAt: Date.now(),
|
|
25
|
+
scheduleMember: buildScheduleMember(sequence, id)
|
|
26
|
+
};
|
|
27
|
+
await writeStoredJob(client, prefix, stored);
|
|
28
|
+
await emitJobLifecycleEvent({
|
|
29
|
+
kind: "job.enqueue",
|
|
30
|
+
severity: "info",
|
|
31
|
+
queue: "redis",
|
|
32
|
+
id: stored.id,
|
|
33
|
+
name: stored.name,
|
|
34
|
+
attempts: stored.attempts,
|
|
35
|
+
maxAttempts: stored.maxAttempts,
|
|
36
|
+
runAt: stored.runAt,
|
|
37
|
+
workerInstanceId: instanceId
|
|
38
|
+
});
|
|
24
39
|
return enqueued;
|
|
25
40
|
},
|
|
26
41
|
async runNext(now = Date.now()) {
|
|
@@ -30,13 +45,45 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
30
45
|
if (await client.setnx(lockKey, instanceId) !== 1)
|
|
31
46
|
continue;
|
|
32
47
|
await client.expire(lockKey, lockTtlSeconds);
|
|
48
|
+
const renewTimer = startLockRenewal(client, lockKey, lockTtlSeconds, lockRenewIntervalMs);
|
|
33
49
|
try {
|
|
34
50
|
const current = await readStoredJob(client, prefix, job.id);
|
|
35
51
|
if (!current || current.runAt > now)
|
|
36
52
|
continue;
|
|
53
|
+
await emitJobLifecycleEvent({
|
|
54
|
+
kind: "job.start",
|
|
55
|
+
severity: "info",
|
|
56
|
+
queue: "redis",
|
|
57
|
+
id: current.id,
|
|
58
|
+
name: current.name,
|
|
59
|
+
attempts: current.attempts + 1,
|
|
60
|
+
maxAttempts: current.maxAttempts,
|
|
61
|
+
runAt: current.runAt,
|
|
62
|
+
workerInstanceId: instanceId
|
|
63
|
+
});
|
|
37
64
|
const handler = handlers.get(current.name);
|
|
38
65
|
if (!handler) {
|
|
39
|
-
await client
|
|
66
|
+
await archiveTerminalJob(client, prefix, historyLimit, {
|
|
67
|
+
...current,
|
|
68
|
+
attempts: current.attempts + 1,
|
|
69
|
+
lastError: `Missing Redis job handler registration for "${current.name}"`,
|
|
70
|
+
finishedAt: Date.now(),
|
|
71
|
+
status: "failed",
|
|
72
|
+
historyMember: buildHistoryMember(current.sequence, current.id)
|
|
73
|
+
});
|
|
74
|
+
await deleteStoredJob(client, prefix, current);
|
|
75
|
+
await emitJobLifecycleEvent({
|
|
76
|
+
kind: "job.fail",
|
|
77
|
+
severity: "error",
|
|
78
|
+
queue: "redis",
|
|
79
|
+
id: current.id,
|
|
80
|
+
name: current.name,
|
|
81
|
+
attempts: current.attempts + 1,
|
|
82
|
+
maxAttempts: current.maxAttempts,
|
|
83
|
+
runAt: current.runAt,
|
|
84
|
+
workerInstanceId: instanceId,
|
|
85
|
+
error: `Missing Redis job handler registration for "${current.name}"`
|
|
86
|
+
});
|
|
40
87
|
return {
|
|
41
88
|
id: current.id,
|
|
42
89
|
name: current.name,
|
|
@@ -51,7 +98,25 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
51
98
|
attempt: current.attempts,
|
|
52
99
|
maxAttempts: current.maxAttempts
|
|
53
100
|
});
|
|
54
|
-
|
|
101
|
+
current.updatedAt = Date.now();
|
|
102
|
+
await archiveTerminalJob(client, prefix, historyLimit, {
|
|
103
|
+
...current,
|
|
104
|
+
finishedAt: Date.now(),
|
|
105
|
+
status: "completed",
|
|
106
|
+
historyMember: buildHistoryMember(current.sequence, current.id)
|
|
107
|
+
});
|
|
108
|
+
await deleteStoredJob(client, prefix, current);
|
|
109
|
+
await emitJobLifecycleEvent({
|
|
110
|
+
kind: "job.complete",
|
|
111
|
+
severity: "info",
|
|
112
|
+
queue: "redis",
|
|
113
|
+
id: current.id,
|
|
114
|
+
name: current.name,
|
|
115
|
+
attempts: current.attempts,
|
|
116
|
+
maxAttempts: current.maxAttempts,
|
|
117
|
+
runAt: current.runAt,
|
|
118
|
+
workerInstanceId: instanceId
|
|
119
|
+
});
|
|
55
120
|
return {
|
|
56
121
|
id: current.id,
|
|
57
122
|
name: current.name,
|
|
@@ -63,7 +128,21 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
63
128
|
if (current.attempts < current.maxAttempts) {
|
|
64
129
|
current.lastError = message;
|
|
65
130
|
current.runAt = now + current.backoffMs * current.attempts;
|
|
66
|
-
|
|
131
|
+
current.updatedAt = Date.now();
|
|
132
|
+
await writeStoredJob(client, prefix, current);
|
|
133
|
+
await emitJobLifecycleEvent({
|
|
134
|
+
kind: "job.retry",
|
|
135
|
+
severity: "warn",
|
|
136
|
+
queue: "redis",
|
|
137
|
+
id: current.id,
|
|
138
|
+
name: current.name,
|
|
139
|
+
attempts: current.attempts,
|
|
140
|
+
maxAttempts: current.maxAttempts,
|
|
141
|
+
runAt: now,
|
|
142
|
+
nextRunAt: current.runAt,
|
|
143
|
+
workerInstanceId: instanceId,
|
|
144
|
+
error: message
|
|
145
|
+
});
|
|
67
146
|
return {
|
|
68
147
|
id: current.id,
|
|
69
148
|
name: current.name,
|
|
@@ -73,7 +152,27 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
73
152
|
error: message
|
|
74
153
|
};
|
|
75
154
|
}
|
|
76
|
-
|
|
155
|
+
current.lastError = message;
|
|
156
|
+
current.updatedAt = Date.now();
|
|
157
|
+
await archiveTerminalJob(client, prefix, historyLimit, {
|
|
158
|
+
...current,
|
|
159
|
+
finishedAt: Date.now(),
|
|
160
|
+
status: "failed",
|
|
161
|
+
historyMember: buildHistoryMember(current.sequence, current.id)
|
|
162
|
+
});
|
|
163
|
+
await deleteStoredJob(client, prefix, current);
|
|
164
|
+
await emitJobLifecycleEvent({
|
|
165
|
+
kind: "job.fail",
|
|
166
|
+
severity: "error",
|
|
167
|
+
queue: "redis",
|
|
168
|
+
id: current.id,
|
|
169
|
+
name: current.name,
|
|
170
|
+
attempts: current.attempts,
|
|
171
|
+
maxAttempts: current.maxAttempts,
|
|
172
|
+
runAt: current.runAt,
|
|
173
|
+
workerInstanceId: instanceId,
|
|
174
|
+
error: message
|
|
175
|
+
});
|
|
77
176
|
return {
|
|
78
177
|
id: current.id,
|
|
79
178
|
name: current.name,
|
|
@@ -83,6 +182,7 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
83
182
|
};
|
|
84
183
|
}
|
|
85
184
|
} finally {
|
|
185
|
+
clearLockRenewal(renewTimer);
|
|
86
186
|
await client.del(lockKey);
|
|
87
187
|
}
|
|
88
188
|
}
|
|
@@ -99,6 +199,66 @@ export function createRedisJobQueue(client, options = {}) {
|
|
|
99
199
|
},
|
|
100
200
|
async size() {
|
|
101
201
|
return (await listStoredJobs(client, prefix)).length;
|
|
202
|
+
},
|
|
203
|
+
async get(id) {
|
|
204
|
+
const job = await readStoredJob(client, prefix, id);
|
|
205
|
+
if (!job)
|
|
206
|
+
return null;
|
|
207
|
+
return toQueuedJobRecord(job);
|
|
208
|
+
},
|
|
209
|
+
async peek(limit = Number.POSITIVE_INFINITY) {
|
|
210
|
+
return (await listStoredJobs(client, prefix)).sort((a, b) => a.runAt - b.runAt || a.sequence - b.sequence).slice(0, limit).map((job) => toQueuedJobRecord(job));
|
|
211
|
+
},
|
|
212
|
+
async cancel(id) {
|
|
213
|
+
const job = await readStoredJob(client, prefix, id);
|
|
214
|
+
if (!job)
|
|
215
|
+
return !1;
|
|
216
|
+
if (await client.get(claimKey(prefix, id)))
|
|
217
|
+
return !1;
|
|
218
|
+
await deleteStoredJob(client, prefix, job);
|
|
219
|
+
await emitJobLifecycleEvent({
|
|
220
|
+
kind: "job.cancel",
|
|
221
|
+
severity: "info",
|
|
222
|
+
queue: "redis",
|
|
223
|
+
id: job.id,
|
|
224
|
+
name: job.name,
|
|
225
|
+
attempts: job.attempts,
|
|
226
|
+
maxAttempts: job.maxAttempts,
|
|
227
|
+
runAt: job.runAt,
|
|
228
|
+
workerInstanceId: instanceId
|
|
229
|
+
});
|
|
230
|
+
return !0;
|
|
231
|
+
},
|
|
232
|
+
async recent(limit = 50) {
|
|
233
|
+
return (await listTerminalJobs(client, prefix, limit)).map(toTerminalJobRecord);
|
|
234
|
+
},
|
|
235
|
+
async failures(limit = 50) {
|
|
236
|
+
return (await listFailedTerminalJobs(client, prefix, limit)).map(toTerminalJobRecord);
|
|
237
|
+
},
|
|
238
|
+
async retryFailed(id, options = {}) {
|
|
239
|
+
const failed = await readStoredTerminalJob(client, prefix, id);
|
|
240
|
+
if (!failed || failed.status !== "failed")
|
|
241
|
+
return null;
|
|
242
|
+
const handler = handlers.get(failed.name);
|
|
243
|
+
if (!handler)
|
|
244
|
+
return null;
|
|
245
|
+
const retried = await this.enqueue(handler, failed.payload, {
|
|
246
|
+
runAt: options.runAt ?? Date.now(),
|
|
247
|
+
maxAttempts: options.maxAttempts ?? failed.maxAttempts,
|
|
248
|
+
backoffMs: options.backoffMs ?? failed.backoffMs
|
|
249
|
+
});
|
|
250
|
+
await emitJobLifecycleEvent({
|
|
251
|
+
kind: "job.dead-letter.retry",
|
|
252
|
+
severity: "warn",
|
|
253
|
+
queue: "redis",
|
|
254
|
+
id: retried.id,
|
|
255
|
+
name: retried.name,
|
|
256
|
+
attempts: retried.attempts,
|
|
257
|
+
maxAttempts: retried.maxAttempts,
|
|
258
|
+
runAt: retried.runAt,
|
|
259
|
+
workerInstanceId: instanceId
|
|
260
|
+
});
|
|
261
|
+
return retried;
|
|
102
262
|
}
|
|
103
263
|
};
|
|
104
264
|
}
|
|
@@ -108,7 +268,32 @@ function jobKey(prefix, id) {
|
|
|
108
268
|
function claimKey(prefix, id) {
|
|
109
269
|
return buildRedisKey(`${prefix}:lock`, id);
|
|
110
270
|
}
|
|
271
|
+
function scheduleKey(prefix) {
|
|
272
|
+
return buildRedisKey(prefix, "__schedule");
|
|
273
|
+
}
|
|
274
|
+
function historyIndexKey(prefix) {
|
|
275
|
+
return buildRedisKey(prefix, "__history");
|
|
276
|
+
}
|
|
277
|
+
function failedIndexKey(prefix) {
|
|
278
|
+
return buildRedisKey(prefix, "__failed");
|
|
279
|
+
}
|
|
280
|
+
function historyItemKey(prefix, id) {
|
|
281
|
+
return buildRedisKey(`${prefix}:history`, id);
|
|
282
|
+
}
|
|
111
283
|
async function listStoredJobs(client, prefix) {
|
|
284
|
+
if (client.zrangebyscore) {
|
|
285
|
+
const members = await client.zrangebyscore(scheduleKey(prefix), "-inf", "+inf"), jobs = [];
|
|
286
|
+
for (const member of members) {
|
|
287
|
+
const job = await readStoredJob(client, prefix, extractJobIdFromScheduleMember(member));
|
|
288
|
+
if (job) {
|
|
289
|
+
jobs.push(job);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (client.zrem)
|
|
293
|
+
await client.zrem(scheduleKey(prefix), member);
|
|
294
|
+
}
|
|
295
|
+
return jobs;
|
|
296
|
+
}
|
|
112
297
|
const keys = await client.keys(`${prefix}:*`), jobs = [];
|
|
113
298
|
for (const key of keys) {
|
|
114
299
|
const visibleKey = stripRedisPrefix(prefix, key);
|
|
@@ -121,6 +306,19 @@ async function listStoredJobs(client, prefix) {
|
|
|
121
306
|
return jobs;
|
|
122
307
|
}
|
|
123
308
|
async function listDueJobs(client, prefix, now) {
|
|
309
|
+
if (client.zrangebyscore) {
|
|
310
|
+
const members = await client.zrangebyscore(scheduleKey(prefix), "-inf", now), jobs = [];
|
|
311
|
+
for (const member of members) {
|
|
312
|
+
const job = await readStoredJob(client, prefix, extractJobIdFromScheduleMember(member));
|
|
313
|
+
if (job) {
|
|
314
|
+
jobs.push(job);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (client.zrem)
|
|
318
|
+
await client.zrem(scheduleKey(prefix), member);
|
|
319
|
+
}
|
|
320
|
+
return jobs;
|
|
321
|
+
}
|
|
124
322
|
return (await listStoredJobs(client, prefix)).filter((job) => job.runAt <= now).sort((a, b) => a.runAt - b.runAt || a.sequence - b.sequence);
|
|
125
323
|
}
|
|
126
324
|
async function readStoredJob(client, prefix, id) {
|
|
@@ -128,14 +326,234 @@ async function readStoredJob(client, prefix, id) {
|
|
|
128
326
|
if (!raw)
|
|
129
327
|
return null;
|
|
130
328
|
try {
|
|
131
|
-
const parsed = JSON.parse(raw);
|
|
132
|
-
if (
|
|
133
|
-
await client
|
|
329
|
+
const parsed = JSON.parse(raw), normalized = normalizeStoredJob(parsed, id);
|
|
330
|
+
if (!normalized) {
|
|
331
|
+
await deleteCorruptStoredJob(client, prefix, id);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (!isStoredJob(parsed))
|
|
335
|
+
await writeStoredJob(client, prefix, normalized);
|
|
336
|
+
return normalized;
|
|
337
|
+
} catch {
|
|
338
|
+
await deleteCorruptStoredJob(client, prefix, id);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function writeStoredJob(client, prefix, job) {
|
|
343
|
+
await client.set(jobKey(prefix, job.id), JSON.stringify(job));
|
|
344
|
+
if (client.zadd)
|
|
345
|
+
await client.zadd(scheduleKey(prefix), job.runAt, job.scheduleMember);
|
|
346
|
+
}
|
|
347
|
+
async function deleteStoredJob(client, prefix, job) {
|
|
348
|
+
await client.del(jobKey(prefix, job.id));
|
|
349
|
+
if (client.zrem)
|
|
350
|
+
await client.zrem(scheduleKey(prefix), job.scheduleMember);
|
|
351
|
+
}
|
|
352
|
+
async function deleteCorruptStoredJob(client, prefix, id) {
|
|
353
|
+
await client.del(jobKey(prefix, id));
|
|
354
|
+
if (client.zrem)
|
|
355
|
+
await client.zrem(scheduleKey(prefix), ...candidateScheduleMembersForCorruptJob(id));
|
|
356
|
+
}
|
|
357
|
+
async function archiveTerminalJob(client, prefix, historyLimit, job) {
|
|
358
|
+
await client.set(historyItemKey(prefix, job.id), JSON.stringify(job));
|
|
359
|
+
if (client.zadd) {
|
|
360
|
+
await client.zadd(historyIndexKey(prefix), job.finishedAt, job.historyMember);
|
|
361
|
+
if (job.status === "failed")
|
|
362
|
+
await client.zadd(failedIndexKey(prefix), job.finishedAt, job.historyMember);
|
|
363
|
+
else if (client.zrem)
|
|
364
|
+
await client.zrem(failedIndexKey(prefix), job.historyMember);
|
|
365
|
+
await trimTerminalHistory(client, prefix, historyLimit);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function trimTerminalHistory(client, prefix, historyLimit) {
|
|
369
|
+
if (!client.zrangebyscore || !client.zrem || historyLimit < 0)
|
|
370
|
+
return;
|
|
371
|
+
const history = await client.zrangebyscore(historyIndexKey(prefix), "-inf", "+inf"), overflow = history.length - historyLimit;
|
|
372
|
+
if (overflow <= 0)
|
|
373
|
+
return;
|
|
374
|
+
const toDelete = history.slice(0, overflow);
|
|
375
|
+
for (const member of toDelete) {
|
|
376
|
+
const id = extractJobIdFromHistoryMember(member);
|
|
377
|
+
await client.del(historyItemKey(prefix, id));
|
|
378
|
+
await client.zrem(historyIndexKey(prefix), member);
|
|
379
|
+
await client.zrem(failedIndexKey(prefix), member);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function listTerminalJobs(client, prefix, limit) {
|
|
383
|
+
return listIndexedTerminalJobs(client, prefix, historyIndexKey(prefix), limit);
|
|
384
|
+
}
|
|
385
|
+
async function listFailedTerminalJobs(client, prefix, limit) {
|
|
386
|
+
return listIndexedTerminalJobs(client, prefix, failedIndexKey(prefix), limit);
|
|
387
|
+
}
|
|
388
|
+
async function listIndexedTerminalJobs(client, prefix, indexKey, limit) {
|
|
389
|
+
if (!client.zrangebyscore)
|
|
390
|
+
return [];
|
|
391
|
+
const members = await client.zrangebyscore(indexKey, "-inf", "+inf"), records = [];
|
|
392
|
+
for (const member of members.slice(-limit).reverse()) {
|
|
393
|
+
const record = await readStoredTerminalJob(client, prefix, extractJobIdFromHistoryMember(member));
|
|
394
|
+
if (record) {
|
|
395
|
+
records.push(record);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (client.zrem)
|
|
399
|
+
await client.zrem(indexKey, member);
|
|
400
|
+
}
|
|
401
|
+
return records;
|
|
402
|
+
}
|
|
403
|
+
async function readStoredTerminalJob(client, prefix, id) {
|
|
404
|
+
const raw = await client.get(historyItemKey(prefix, id));
|
|
405
|
+
if (!raw)
|
|
406
|
+
return null;
|
|
407
|
+
try {
|
|
408
|
+
const parsed = JSON.parse(raw), normalized = normalizeStoredTerminalJob(parsed, id);
|
|
409
|
+
if (!normalized) {
|
|
410
|
+
await client.del(historyItemKey(prefix, id));
|
|
134
411
|
return null;
|
|
135
412
|
}
|
|
136
|
-
return
|
|
413
|
+
return normalized;
|
|
137
414
|
} catch {
|
|
138
|
-
await client.del(
|
|
415
|
+
await client.del(historyItemKey(prefix, id));
|
|
139
416
|
return null;
|
|
140
417
|
}
|
|
141
418
|
}
|
|
419
|
+
function buildScheduleMember(sequence, id) {
|
|
420
|
+
return `${String(sequence).padStart(12, "0")}:${id}`;
|
|
421
|
+
}
|
|
422
|
+
function buildHistoryMember(sequence, id) {
|
|
423
|
+
return `${String(sequence).padStart(12, "0")}:${id}`;
|
|
424
|
+
}
|
|
425
|
+
function extractJobIdFromScheduleMember(member) {
|
|
426
|
+
const delimiterIndex = member.indexOf(":");
|
|
427
|
+
return delimiterIndex === -1 ? member : member.slice(delimiterIndex + 1);
|
|
428
|
+
}
|
|
429
|
+
function extractJobIdFromHistoryMember(member) {
|
|
430
|
+
const delimiterIndex = member.indexOf(":");
|
|
431
|
+
return delimiterIndex === -1 ? member : member.slice(delimiterIndex + 1);
|
|
432
|
+
}
|
|
433
|
+
function candidateScheduleMembersForCorruptJob(id) {
|
|
434
|
+
return [
|
|
435
|
+
id,
|
|
436
|
+
`000000000000:${id}`
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
function isStoredJob(value) {
|
|
440
|
+
if (!value || typeof value !== "object")
|
|
441
|
+
return !1;
|
|
442
|
+
const candidate = value;
|
|
443
|
+
return typeof candidate.id === "string" && typeof candidate.name === "string" && typeof candidate.runAt === "number" && typeof candidate.attempts === "number" && typeof candidate.maxAttempts === "number" && typeof candidate.backoffMs === "number" && typeof candidate.sequence === "number" && typeof candidate.createdAt === "number" && typeof candidate.updatedAt === "number" && typeof candidate.scheduleMember === "string";
|
|
444
|
+
}
|
|
445
|
+
function toQueuedJobRecord(job) {
|
|
446
|
+
return {
|
|
447
|
+
id: job.id,
|
|
448
|
+
name: job.name,
|
|
449
|
+
payload: job.payload,
|
|
450
|
+
runAt: job.runAt,
|
|
451
|
+
attempts: job.attempts,
|
|
452
|
+
maxAttempts: job.maxAttempts,
|
|
453
|
+
backoffMs: job.backoffMs,
|
|
454
|
+
lastError: job.lastError,
|
|
455
|
+
createdAt: job.createdAt,
|
|
456
|
+
updatedAt: job.updatedAt
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function toTerminalJobRecord(job) {
|
|
460
|
+
return {
|
|
461
|
+
id: job.id,
|
|
462
|
+
name: job.name,
|
|
463
|
+
payload: job.payload,
|
|
464
|
+
runAt: job.runAt,
|
|
465
|
+
attempts: job.attempts,
|
|
466
|
+
maxAttempts: job.maxAttempts,
|
|
467
|
+
backoffMs: job.backoffMs,
|
|
468
|
+
lastError: job.lastError,
|
|
469
|
+
createdAt: job.createdAt,
|
|
470
|
+
updatedAt: job.updatedAt,
|
|
471
|
+
finishedAt: job.finishedAt,
|
|
472
|
+
status: job.status
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function normalizeStoredJob(value, id) {
|
|
476
|
+
if (typeof value.id !== "string" || value.id !== id)
|
|
477
|
+
return null;
|
|
478
|
+
if (typeof value.name !== "string")
|
|
479
|
+
return null;
|
|
480
|
+
if (typeof value.runAt !== "number")
|
|
481
|
+
return null;
|
|
482
|
+
if (typeof value.attempts !== "number")
|
|
483
|
+
return null;
|
|
484
|
+
if (typeof value.maxAttempts !== "number")
|
|
485
|
+
return null;
|
|
486
|
+
if (typeof value.backoffMs !== "number")
|
|
487
|
+
return null;
|
|
488
|
+
if (typeof value.sequence !== "number")
|
|
489
|
+
return null;
|
|
490
|
+
const createdAt = typeof value.createdAt === "number" ? value.createdAt : value.runAt, updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : createdAt, scheduleMember = typeof value.scheduleMember === "string" ? value.scheduleMember : buildScheduleMember(value.sequence, value.id);
|
|
491
|
+
return {
|
|
492
|
+
id: value.id,
|
|
493
|
+
name: value.name,
|
|
494
|
+
payload: value.payload,
|
|
495
|
+
runAt: value.runAt,
|
|
496
|
+
attempts: value.attempts,
|
|
497
|
+
maxAttempts: value.maxAttempts,
|
|
498
|
+
backoffMs: value.backoffMs,
|
|
499
|
+
lastError: value.lastError,
|
|
500
|
+
sequence: value.sequence,
|
|
501
|
+
createdAt,
|
|
502
|
+
updatedAt,
|
|
503
|
+
scheduleMember
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function normalizeStoredTerminalJob(value, id) {
|
|
507
|
+
if (typeof value.id !== "string" || value.id !== id)
|
|
508
|
+
return null;
|
|
509
|
+
if (typeof value.name !== "string")
|
|
510
|
+
return null;
|
|
511
|
+
if (typeof value.runAt !== "number")
|
|
512
|
+
return null;
|
|
513
|
+
if (typeof value.attempts !== "number")
|
|
514
|
+
return null;
|
|
515
|
+
if (typeof value.maxAttempts !== "number")
|
|
516
|
+
return null;
|
|
517
|
+
if (typeof value.backoffMs !== "number")
|
|
518
|
+
return null;
|
|
519
|
+
if (typeof value.sequence !== "number")
|
|
520
|
+
return null;
|
|
521
|
+
if (typeof value.createdAt !== "number")
|
|
522
|
+
return null;
|
|
523
|
+
if (typeof value.updatedAt !== "number")
|
|
524
|
+
return null;
|
|
525
|
+
if (typeof value.finishedAt !== "number")
|
|
526
|
+
return null;
|
|
527
|
+
if (value.status !== "completed" && value.status !== "failed")
|
|
528
|
+
return null;
|
|
529
|
+
return {
|
|
530
|
+
id: value.id,
|
|
531
|
+
name: value.name,
|
|
532
|
+
payload: value.payload,
|
|
533
|
+
runAt: value.runAt,
|
|
534
|
+
attempts: value.attempts,
|
|
535
|
+
maxAttempts: value.maxAttempts,
|
|
536
|
+
backoffMs: value.backoffMs,
|
|
537
|
+
lastError: value.lastError,
|
|
538
|
+
createdAt: value.createdAt,
|
|
539
|
+
updatedAt: value.updatedAt,
|
|
540
|
+
finishedAt: value.finishedAt,
|
|
541
|
+
status: value.status,
|
|
542
|
+
sequence: value.sequence,
|
|
543
|
+
historyMember: typeof value.historyMember === "string" ? value.historyMember : buildHistoryMember(value.sequence, value.id)
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function startLockRenewal(client, lockKey, lockTtlSeconds, renewIntervalMs) {
|
|
547
|
+
if (renewIntervalMs <= 0)
|
|
548
|
+
return null;
|
|
549
|
+
const timer = setInterval(() => {
|
|
550
|
+
client.expire?.(lockKey, lockTtlSeconds);
|
|
551
|
+
}, renewIntervalMs);
|
|
552
|
+
if (typeof timer.unref === "function")
|
|
553
|
+
timer.unref();
|
|
554
|
+
return timer;
|
|
555
|
+
}
|
|
556
|
+
function clearLockRenewal(timer) {
|
|
557
|
+
if (timer)
|
|
558
|
+
clearInterval(timer);
|
|
559
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type Awaitable<T> = T | Promise<T>;
|
|
2
|
+
export interface WorkerServiceContext {
|
|
3
|
+
name: string;
|
|
4
|
+
workerId: string;
|
|
5
|
+
startedAt: number;
|
|
6
|
+
signal: AbortSignal;
|
|
7
|
+
emitReady(): Promise<void>;
|
|
8
|
+
emitHeartbeat(message?: string, data?: Record<string, unknown>): Promise<void>;
|
|
9
|
+
waitForShutdown(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface WorkerServiceStartHandle {
|
|
12
|
+
ready?: Promise<void>;
|
|
13
|
+
stop?: () => Awaitable<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface WorkerServiceDefinition {
|
|
16
|
+
name: string;
|
|
17
|
+
start(context: WorkerServiceContext): Awaitable<void | (() => Awaitable<void>) | WorkerServiceStartHandle>;
|
|
18
|
+
}
|
|
19
|
+
export interface RunWorkerServiceOptions {
|
|
20
|
+
registerSignalHandlers?: boolean;
|
|
21
|
+
workerId?: string;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export interface RunningWorkerService {
|
|
25
|
+
name: string;
|
|
26
|
+
workerId: string;
|
|
27
|
+
startedAt: number;
|
|
28
|
+
ready: Promise<void>;
|
|
29
|
+
stop(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function defineWorkerService(name: string, start: WorkerServiceDefinition["start"]): WorkerServiceDefinition;
|
|
32
|
+
export declare function runWorkerService(service: WorkerServiceDefinition, options?: RunWorkerServiceOptions): Promise<RunningWorkerService>;
|
|
33
|
+
export {};
|