@qlever-llc/trellis 0.10.17 → 0.19.0-rc.1
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/esm/auth/mod.d.ts +1 -1
- package/esm/auth/mod.d.ts.map +1 -1
- package/esm/auth/mod.js +1 -1
- package/esm/auth/protocol.d.ts +416 -398
- package/esm/auth/protocol.d.ts.map +1 -1
- package/esm/auth/protocol.js +35 -33
- package/esm/codec.d.ts +1 -0
- package/esm/codec.d.ts.map +1 -1
- package/esm/codec.js +1 -0
- package/esm/contract_support/canonical.d.ts +3 -0
- package/esm/contract_support/canonical.d.ts.map +1 -1
- package/esm/contract_support/canonical.js +3 -0
- package/esm/contract_support/mod.d.ts +28 -4
- package/esm/contract_support/mod.d.ts.map +1 -1
- package/esm/contract_support/mod.js +85 -4
- package/esm/contract_support/protocol.d.ts +92 -7
- package/esm/contract_support/protocol.d.ts.map +1 -1
- package/esm/contract_support/protocol.js +40 -6
- package/esm/errors/index.d.ts +9 -9
- package/esm/generated-sdk/auth/api.d.ts +4 -1
- package/esm/generated-sdk/auth/api.d.ts.map +1 -1
- package/esm/generated-sdk/auth/api.js +6 -1
- package/esm/generated-sdk/auth/client.d.ts +47 -32
- package/esm/generated-sdk/auth/client.d.ts.map +1 -1
- package/esm/generated-sdk/auth/contract.d.ts +1 -1
- package/esm/generated-sdk/auth/contract.d.ts.map +1 -1
- package/esm/generated-sdk/auth/contract.js +1570 -1202
- package/esm/generated-sdk/auth/schemas.d.ts +1757 -1759
- package/esm/generated-sdk/auth/schemas.d.ts.map +1 -1
- package/esm/generated-sdk/auth/schemas.js +963 -768
- package/esm/generated-sdk/auth/types.d.ts +292 -302
- package/esm/generated-sdk/auth/types.d.ts.map +1 -1
- package/esm/generated-sdk/auth/types.js +1 -1
- package/esm/generated-sdk/health/client.d.ts +4 -4
- package/esm/generated-sdk/health/client.d.ts.map +1 -1
- package/esm/generated-sdk/health/contract.d.ts +1 -1
- package/esm/generated-sdk/health/contract.d.ts.map +1 -1
- package/esm/generated-sdk/health/contract.js +2 -10
- package/esm/generated-sdk/health/schemas.d.ts +1 -14
- package/esm/generated-sdk/health/schemas.d.ts.map +1 -1
- package/esm/generated-sdk/health/schemas.js +1 -9
- package/esm/generated-sdk/health/types.d.ts +3 -5
- package/esm/generated-sdk/health/types.d.ts.map +1 -1
- package/esm/generated-sdk/health/types.js +1 -2
- package/esm/generated-sdk/jobs/api.d.ts +13 -0
- package/esm/generated-sdk/jobs/api.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/client.d.ts +6 -4
- package/esm/generated-sdk/jobs/client.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/contract.d.ts +14 -1
- package/esm/generated-sdk/jobs/contract.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/contract.js +268 -1
- package/esm/generated-sdk/jobs/owned_api.d.ts +13 -0
- package/esm/generated-sdk/jobs/owned_api.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/owned_api.js +20 -1
- package/esm/generated-sdk/jobs/schemas.d.ts +437 -0
- package/esm/generated-sdk/jobs/schemas.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/schemas.js +211 -0
- package/esm/generated-sdk/jobs/types.d.ts +123 -1
- package/esm/generated-sdk/jobs/types.d.ts.map +1 -1
- package/esm/generated-sdk/jobs/types.js +1 -1
- package/esm/generated-sdk/state/client.d.ts +4 -4
- package/esm/generated-sdk/state/client.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/api.d.ts +4 -1
- package/esm/generated-sdk/trellis-core/api.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/api.js +6 -1
- package/esm/generated-sdk/trellis-core/client.d.ts +28 -3
- package/esm/generated-sdk/trellis-core/client.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/contract.d.ts +1 -1
- package/esm/generated-sdk/trellis-core/contract.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/contract.js +91 -1
- package/esm/generated-sdk/trellis-core/schemas.d.ts +113 -0
- package/esm/generated-sdk/trellis-core/schemas.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/schemas.js +81 -0
- package/esm/generated-sdk/trellis-core/types.d.ts +23 -1
- package/esm/generated-sdk/trellis-core/types.d.ts.map +1 -1
- package/esm/generated-sdk/trellis-core/types.js +1 -1
- package/esm/health.d.ts +1 -1
- package/esm/health.d.ts.map +1 -1
- package/esm/health.js +1 -1
- package/esm/index.d.ts +2 -2
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -1
- package/esm/jobs.d.ts +69 -3
- package/esm/jobs.d.ts.map +1 -1
- package/esm/jobs.js +92 -3
- package/esm/server/health.d.ts +2 -6
- package/esm/server/health.d.ts.map +1 -1
- package/esm/server/health_schemas.d.ts +0 -8
- package/esm/server/health_schemas.d.ts.map +1 -1
- package/esm/server/health_schemas.js +0 -5
- package/esm/server/internal_jobs/bindings.d.ts +11 -0
- package/esm/server/internal_jobs/bindings.d.ts.map +1 -1
- package/esm/server/internal_jobs/job-manager.d.ts +44 -1
- package/esm/server/internal_jobs/job-manager.d.ts.map +1 -1
- package/esm/server/internal_jobs/job-manager.js +361 -51
- package/esm/server/internal_jobs/key-coordinator.d.ts +260 -0
- package/esm/server/internal_jobs/key-coordinator.d.ts.map +1 -0
- package/esm/server/internal_jobs/key-coordinator.js +580 -0
- package/esm/server/internal_jobs/projection.d.ts.map +1 -1
- package/esm/server/internal_jobs/projection.js +2 -0
- package/esm/server/internal_jobs/runtime-worker.d.ts +5 -1
- package/esm/server/internal_jobs/runtime-worker.d.ts.map +1 -1
- package/esm/server/internal_jobs/runtime-worker.js +14 -2
- package/esm/server/internal_jobs/types.d.ts +5 -5
- package/esm/server/internal_jobs/types.d.ts.map +1 -1
- package/esm/server/internal_jobs/types.js +6 -0
- package/esm/server/service.d.ts +8 -3
- package/esm/server/service.d.ts.map +1 -1
- package/esm/server/service.js +127 -6
- package/esm/service/drizzle.d.ts +27 -0
- package/esm/service/drizzle.d.ts.map +1 -0
- package/esm/service/drizzle.js +84 -0
- package/esm/service/mod.d.ts +1 -1
- package/esm/service/mod.d.ts.map +1 -1
- package/esm/service/mod.js +1 -1
- package/esm/service/outbox_inbox.d.ts.map +1 -1
- package/esm/service/outbox_inbox.js +13 -2
- package/esm/trellis.d.ts +24 -8
- package/esm/trellis.d.ts.map +1 -1
- package/esm/trellis.js +44 -22
- package/package.json +14 -2
- package/script/auth/mod.d.ts +1 -1
- package/script/auth/mod.d.ts.map +1 -1
- package/script/auth/mod.js +22 -6
- package/script/auth/protocol.d.ts +416 -398
- package/script/auth/protocol.d.ts.map +1 -1
- package/script/auth/protocol.js +40 -37
- package/script/codec.d.ts +1 -0
- package/script/codec.d.ts.map +1 -1
- package/script/codec.js +1 -0
- package/script/contract_support/canonical.d.ts +3 -0
- package/script/contract_support/canonical.d.ts.map +1 -1
- package/script/contract_support/canonical.js +3 -0
- package/script/contract_support/mod.d.ts +28 -4
- package/script/contract_support/mod.d.ts.map +1 -1
- package/script/contract_support/mod.js +90 -4
- package/script/contract_support/protocol.d.ts +92 -7
- package/script/contract_support/protocol.d.ts.map +1 -1
- package/script/contract_support/protocol.js +41 -7
- package/script/errors/index.d.ts +9 -9
- package/script/generated-sdk/auth/api.d.ts +4 -1
- package/script/generated-sdk/auth/api.d.ts.map +1 -1
- package/script/generated-sdk/auth/api.js +6 -1
- package/script/generated-sdk/auth/client.d.ts +47 -32
- package/script/generated-sdk/auth/client.d.ts.map +1 -1
- package/script/generated-sdk/auth/contract.d.ts +1 -1
- package/script/generated-sdk/auth/contract.d.ts.map +1 -1
- package/script/generated-sdk/auth/contract.js +1570 -1202
- package/script/generated-sdk/auth/schemas.d.ts +1757 -1759
- package/script/generated-sdk/auth/schemas.d.ts.map +1 -1
- package/script/generated-sdk/auth/schemas.js +963 -768
- package/script/generated-sdk/auth/types.d.ts +292 -302
- package/script/generated-sdk/auth/types.d.ts.map +1 -1
- package/script/generated-sdk/auth/types.js +1 -1
- package/script/generated-sdk/health/client.d.ts +4 -4
- package/script/generated-sdk/health/client.d.ts.map +1 -1
- package/script/generated-sdk/health/contract.d.ts +1 -1
- package/script/generated-sdk/health/contract.d.ts.map +1 -1
- package/script/generated-sdk/health/contract.js +2 -10
- package/script/generated-sdk/health/schemas.d.ts +1 -14
- package/script/generated-sdk/health/schemas.d.ts.map +1 -1
- package/script/generated-sdk/health/schemas.js +1 -9
- package/script/generated-sdk/health/types.d.ts +3 -5
- package/script/generated-sdk/health/types.d.ts.map +1 -1
- package/script/generated-sdk/health/types.js +1 -2
- package/script/generated-sdk/jobs/api.d.ts +13 -0
- package/script/generated-sdk/jobs/api.d.ts.map +1 -1
- package/script/generated-sdk/jobs/client.d.ts +6 -4
- package/script/generated-sdk/jobs/client.d.ts.map +1 -1
- package/script/generated-sdk/jobs/contract.d.ts +14 -1
- package/script/generated-sdk/jobs/contract.d.ts.map +1 -1
- package/script/generated-sdk/jobs/contract.js +268 -1
- package/script/generated-sdk/jobs/owned_api.d.ts +13 -0
- package/script/generated-sdk/jobs/owned_api.d.ts.map +1 -1
- package/script/generated-sdk/jobs/owned_api.js +19 -0
- package/script/generated-sdk/jobs/schemas.d.ts +437 -0
- package/script/generated-sdk/jobs/schemas.d.ts.map +1 -1
- package/script/generated-sdk/jobs/schemas.js +212 -1
- package/script/generated-sdk/jobs/types.d.ts +123 -1
- package/script/generated-sdk/jobs/types.d.ts.map +1 -1
- package/script/generated-sdk/jobs/types.js +1 -1
- package/script/generated-sdk/state/client.d.ts +4 -4
- package/script/generated-sdk/state/client.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/api.d.ts +4 -1
- package/script/generated-sdk/trellis-core/api.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/api.js +6 -1
- package/script/generated-sdk/trellis-core/client.d.ts +28 -3
- package/script/generated-sdk/trellis-core/client.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/contract.d.ts +1 -1
- package/script/generated-sdk/trellis-core/contract.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/contract.js +91 -1
- package/script/generated-sdk/trellis-core/schemas.d.ts +113 -0
- package/script/generated-sdk/trellis-core/schemas.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/schemas.js +81 -0
- package/script/generated-sdk/trellis-core/types.d.ts +23 -1
- package/script/generated-sdk/trellis-core/types.d.ts.map +1 -1
- package/script/generated-sdk/trellis-core/types.js +1 -1
- package/script/health.d.ts +1 -1
- package/script/health.d.ts.map +1 -1
- package/script/health.js +1 -2
- package/script/index.d.ts +2 -2
- package/script/index.d.ts.map +1 -1
- package/script/index.js +2 -3
- package/script/jobs.d.ts +69 -3
- package/script/jobs.d.ts.map +1 -1
- package/script/jobs.js +93 -3
- package/script/server/health.d.ts +2 -6
- package/script/server/health.d.ts.map +1 -1
- package/script/server/health_schemas.d.ts +0 -8
- package/script/server/health_schemas.d.ts.map +1 -1
- package/script/server/health_schemas.js +1 -6
- package/script/server/internal_jobs/bindings.d.ts +11 -0
- package/script/server/internal_jobs/bindings.d.ts.map +1 -1
- package/script/server/internal_jobs/job-manager.d.ts +44 -1
- package/script/server/internal_jobs/job-manager.d.ts.map +1 -1
- package/script/server/internal_jobs/job-manager.js +361 -51
- package/script/server/internal_jobs/key-coordinator.d.ts +260 -0
- package/script/server/internal_jobs/key-coordinator.d.ts.map +1 -0
- package/script/server/internal_jobs/key-coordinator.js +593 -0
- package/script/server/internal_jobs/projection.d.ts.map +1 -1
- package/script/server/internal_jobs/projection.js +2 -0
- package/script/server/internal_jobs/runtime-worker.d.ts +5 -1
- package/script/server/internal_jobs/runtime-worker.d.ts.map +1 -1
- package/script/server/internal_jobs/runtime-worker.js +14 -2
- package/script/server/internal_jobs/types.d.ts +5 -5
- package/script/server/internal_jobs/types.d.ts.map +1 -1
- package/script/server/internal_jobs/types.js +6 -0
- package/script/server/service.d.ts +8 -3
- package/script/server/service.d.ts.map +1 -1
- package/script/server/service.js +126 -5
- package/script/service/drizzle.d.ts +27 -0
- package/script/service/drizzle.d.ts.map +1 -0
- package/script/service/drizzle.js +88 -0
- package/script/service/mod.d.ts +1 -1
- package/script/service/mod.d.ts.map +1 -1
- package/script/service/mod.js +1 -2
- package/script/service/outbox_inbox.d.ts.map +1 -1
- package/script/service/outbox_inbox.js +13 -2
- package/script/trellis.d.ts +24 -8
- package/script/trellis.d.ts.map +1 -1
- package/script/trellis.js +44 -22
- package/src/auth/mod.ts +28 -2
- package/src/auth/protocol.ts +72 -37
- package/src/codec.ts +1 -0
- package/src/contract_support/canonical.ts +3 -0
- package/src/contract_support/mod.ts +158 -5
- package/src/contract_support/protocol.ts +56 -9
- package/src/health.ts +0 -1
- package/src/index.ts +2 -1
- package/src/jobs.ts +138 -1
- package/src/sdk/_generated/auth/api.ts +9 -2
- package/src/sdk/_generated/auth/client.ts +85 -78
- package/src/sdk/_generated/auth/contract.ts +1803 -1435
- package/src/sdk/_generated/auth/schemas.ts +1166 -971
- package/src/sdk/_generated/auth/types.ts +330 -314
- package/src/sdk/_generated/core/api.ts +9 -2
- package/src/sdk/_generated/core/client.ts +41 -2
- package/src/sdk/_generated/core/contract.ts +91 -1
- package/src/sdk/_generated/core/schemas.ts +81 -0
- package/src/sdk/_generated/core/types.ts +23 -1
- package/src/sdk/_generated/health/client.ts +6 -6
- package/src/sdk/_generated/health/contract.ts +2 -10
- package/src/sdk/_generated/health/schemas.ts +1 -9
- package/src/sdk/_generated/health/types.ts +6 -2
- package/src/sdk/_generated/jobs/client.ts +17 -6
- package/src/sdk/_generated/jobs/contract.ts +269 -1
- package/src/sdk/_generated/jobs/owned_api.ts +21 -0
- package/src/sdk/_generated/jobs/schemas.ts +213 -0
- package/src/sdk/_generated/jobs/types.ts +116 -1
- package/src/sdk/_generated/state/client.ts +6 -6
- package/src/server/health.ts +2 -6
- package/src/server/health_schemas.ts +0 -6
- package/src/server/internal_jobs/bindings.ts +11 -0
- package/src/server/internal_jobs/job-manager.ts +436 -16
- package/src/server/internal_jobs/key-coordinator.ts +955 -0
- package/src/server/internal_jobs/projection.ts +2 -0
- package/src/server/internal_jobs/runtime-worker.ts +17 -1
- package/src/server/internal_jobs/types.ts +6 -0
- package/src/server/service.ts +168 -9
- package/src/service/drizzle.ts +125 -0
- package/src/service/mod.ts +0 -1
- package/src/service/outbox_inbox.ts +16 -6
- package/src/trellis.ts +76 -27
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
import { type KV, type KvEntry, Kvm } from "@nats-io/kv";
|
|
2
|
+
import type { NatsConnection } from "@nats-io/nats-core";
|
|
3
|
+
import type { JobContext, JobState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const JOBS_KEYS_BUCKET = "JOBS_KEYS";
|
|
6
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
7
|
+
const DEFAULT_HEARTBEAT_TTL_MS = 120_000;
|
|
8
|
+
const DEFAULT_MAX_ACTIVE = 1;
|
|
9
|
+
const DEFAULT_MAX_QUEUED_PER_KEY = 0;
|
|
10
|
+
const DEFAULT_QUEUE_WHEN_FULL = "reject";
|
|
11
|
+
const DEFAULT_STALE_POLICY = "fail-stale";
|
|
12
|
+
const MAX_CAS_ATTEMPTS = 8;
|
|
13
|
+
|
|
14
|
+
export type JobKeyStalePolicy = "fail-stale" | "block";
|
|
15
|
+
export type JobQueueWhenFull = "reject" | "coalesce" | "replace-oldest";
|
|
16
|
+
|
|
17
|
+
export type JobKeyConcurrencyBinding = {
|
|
18
|
+
key: string[];
|
|
19
|
+
maxActive?: number;
|
|
20
|
+
heartbeatIntervalMs?: number;
|
|
21
|
+
heartbeatTtlMs?: number;
|
|
22
|
+
stalePolicy?: JobKeyStalePolicy;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type JobQueuePolicyBinding = {
|
|
26
|
+
maxQueuedPerKey?: number;
|
|
27
|
+
whenFull?: JobQueueWhenFull;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NormalizedJobKeyPolicy = {
|
|
31
|
+
key: string[];
|
|
32
|
+
maxActive: number;
|
|
33
|
+
heartbeatIntervalMs: number;
|
|
34
|
+
heartbeatTtlMs: number;
|
|
35
|
+
stalePolicy: JobKeyStalePolicy;
|
|
36
|
+
queue: {
|
|
37
|
+
maxQueuedPerKey: number;
|
|
38
|
+
whenFull: JobQueueWhenFull;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type DerivedJobKey = {
|
|
43
|
+
service: string;
|
|
44
|
+
jobType: string;
|
|
45
|
+
key: string;
|
|
46
|
+
keyHash: string;
|
|
47
|
+
kvKey: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type JobKeyQueued = {
|
|
51
|
+
jobId: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
requestId: string;
|
|
54
|
+
context: JobContext;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type JobKeyActiveSlot = {
|
|
58
|
+
jobId: string;
|
|
59
|
+
slotToken: string;
|
|
60
|
+
instanceId: string;
|
|
61
|
+
startedAt: string;
|
|
62
|
+
heartbeatAt: string;
|
|
63
|
+
leaseExpiresAt: string;
|
|
64
|
+
tries: number;
|
|
65
|
+
context: JobContext;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type JobKeyState = {
|
|
69
|
+
version: 1;
|
|
70
|
+
service: string;
|
|
71
|
+
jobType: string;
|
|
72
|
+
key: string;
|
|
73
|
+
keyHash: string;
|
|
74
|
+
maxActive: number;
|
|
75
|
+
maxQueuedPerKey?: number;
|
|
76
|
+
active: JobKeyActiveSlot[];
|
|
77
|
+
queued: JobKeyQueued[];
|
|
78
|
+
staleTakeoverCount: number;
|
|
79
|
+
updatedAt: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type JobKeyIdentity = {
|
|
83
|
+
service: string;
|
|
84
|
+
jobType: string;
|
|
85
|
+
id: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type ReplacedQueuedJob = JobKeyIdentity & {
|
|
89
|
+
createdAt: string;
|
|
90
|
+
requestId: string;
|
|
91
|
+
context: JobContext;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type JobAdmissionRequest = {
|
|
95
|
+
service: string;
|
|
96
|
+
jobType: string;
|
|
97
|
+
jobId: string;
|
|
98
|
+
payload: unknown;
|
|
99
|
+
context: JobContext;
|
|
100
|
+
createdAt: string;
|
|
101
|
+
policy: NormalizedJobKeyPolicy;
|
|
102
|
+
strictCreate: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type JobAdmissionOutcome =
|
|
106
|
+
| { kind: "accepted"; key: string; keyHash: string; state: JobKeyState }
|
|
107
|
+
| {
|
|
108
|
+
kind: "rejected";
|
|
109
|
+
key: string;
|
|
110
|
+
reason: "active-limit" | "queue-depth" | "stale-blocked";
|
|
111
|
+
active: number;
|
|
112
|
+
queued: number;
|
|
113
|
+
limit: number;
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
kind: "coalesced";
|
|
117
|
+
key: string;
|
|
118
|
+
existing: JobKeyIdentity;
|
|
119
|
+
reason: "queue-full" | "active-limit";
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
kind: "replaced";
|
|
123
|
+
key: string;
|
|
124
|
+
keyHash: string;
|
|
125
|
+
replaced: ReplacedQueuedJob;
|
|
126
|
+
state: JobKeyState;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type ActiveSlotAcquireRequest = {
|
|
130
|
+
service: string;
|
|
131
|
+
jobType: string;
|
|
132
|
+
jobId: string;
|
|
133
|
+
payload: unknown;
|
|
134
|
+
context: JobContext;
|
|
135
|
+
lifecycleState?: JobState;
|
|
136
|
+
tries: number;
|
|
137
|
+
instanceId: string;
|
|
138
|
+
now: string;
|
|
139
|
+
policy: NormalizedJobKeyPolicy;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type ActiveSlotAcquireOutcome =
|
|
143
|
+
| {
|
|
144
|
+
kind: "acquired";
|
|
145
|
+
key: string;
|
|
146
|
+
keyHash: string;
|
|
147
|
+
slotToken: string;
|
|
148
|
+
stale: JobKeyActiveSlot[];
|
|
149
|
+
state: JobKeyState;
|
|
150
|
+
}
|
|
151
|
+
| {
|
|
152
|
+
kind: "blocked";
|
|
153
|
+
key: string;
|
|
154
|
+
reason: "active-limit" | "not-queued" | "stale-blocked";
|
|
155
|
+
active: number;
|
|
156
|
+
queued: number;
|
|
157
|
+
limit: number;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type ActiveSlotLease = {
|
|
161
|
+
key: string;
|
|
162
|
+
keyHash: string;
|
|
163
|
+
slotToken: string;
|
|
164
|
+
policy: NormalizedJobKeyPolicy;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export type ActiveSlotRenewOutcome =
|
|
168
|
+
| { kind: "renewed"; state: JobKeyState }
|
|
169
|
+
| { kind: "lost" };
|
|
170
|
+
|
|
171
|
+
export type ActiveSlotReleaseOutcome =
|
|
172
|
+
| { kind: "released"; state: JobKeyState }
|
|
173
|
+
| { kind: "staleCompletion" };
|
|
174
|
+
|
|
175
|
+
export type JobKeyCoordinator = {
|
|
176
|
+
admitCreate(request: JobAdmissionRequest): Promise<JobAdmissionOutcome>;
|
|
177
|
+
restoreReplacedQueuedJob(args: {
|
|
178
|
+
service: string;
|
|
179
|
+
jobType: string;
|
|
180
|
+
replacementJobId: string;
|
|
181
|
+
replaced: ReplacedQueuedJob;
|
|
182
|
+
payload: unknown;
|
|
183
|
+
now: string;
|
|
184
|
+
policy: NormalizedJobKeyPolicy;
|
|
185
|
+
}): Promise<QueuedJobRestoreOutcome>;
|
|
186
|
+
removeQueuedJob(args: {
|
|
187
|
+
service: string;
|
|
188
|
+
jobType: string;
|
|
189
|
+
jobId: string;
|
|
190
|
+
payload: unknown;
|
|
191
|
+
now: string;
|
|
192
|
+
policy: NormalizedJobKeyPolicy;
|
|
193
|
+
}): Promise<QueuedJobRemovalOutcome>;
|
|
194
|
+
acquireActiveSlot(
|
|
195
|
+
request: ActiveSlotAcquireRequest,
|
|
196
|
+
): Promise<ActiveSlotAcquireOutcome>;
|
|
197
|
+
renewHeartbeat(args: {
|
|
198
|
+
service: string;
|
|
199
|
+
jobType: string;
|
|
200
|
+
jobId: string;
|
|
201
|
+
lease: ActiveSlotLease;
|
|
202
|
+
now: string;
|
|
203
|
+
}): Promise<ActiveSlotRenewOutcome>;
|
|
204
|
+
releaseActiveSlot(args: {
|
|
205
|
+
service: string;
|
|
206
|
+
jobType: string;
|
|
207
|
+
jobId: string;
|
|
208
|
+
lease: ActiveSlotLease;
|
|
209
|
+
now: string;
|
|
210
|
+
}): Promise<ActiveSlotReleaseOutcome>;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export type QueuedJobRemovalOutcome =
|
|
214
|
+
| { kind: "removed"; state: JobKeyState }
|
|
215
|
+
| { kind: "not-found" };
|
|
216
|
+
|
|
217
|
+
export type QueuedJobRestoreOutcome = { kind: "restored"; state: JobKeyState };
|
|
218
|
+
|
|
219
|
+
/** Normalizes keyed queue policy defaults when bindings omit normalized values. */
|
|
220
|
+
export function normalizeJobKeyPolicy(args: {
|
|
221
|
+
keyConcurrency: JobKeyConcurrencyBinding;
|
|
222
|
+
queue?: JobQueuePolicyBinding;
|
|
223
|
+
}): NormalizedJobKeyPolicy {
|
|
224
|
+
return {
|
|
225
|
+
key: [...args.keyConcurrency.key],
|
|
226
|
+
maxActive: args.keyConcurrency.maxActive ?? DEFAULT_MAX_ACTIVE,
|
|
227
|
+
heartbeatIntervalMs: args.keyConcurrency.heartbeatIntervalMs ??
|
|
228
|
+
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
229
|
+
heartbeatTtlMs: args.keyConcurrency.heartbeatTtlMs ??
|
|
230
|
+
DEFAULT_HEARTBEAT_TTL_MS,
|
|
231
|
+
stalePolicy: args.keyConcurrency.stalePolicy ?? DEFAULT_STALE_POLICY,
|
|
232
|
+
queue: {
|
|
233
|
+
maxQueuedPerKey: args.queue?.maxQueuedPerKey ??
|
|
234
|
+
DEFAULT_MAX_QUEUED_PER_KEY,
|
|
235
|
+
whenFull: args.queue?.whenFull ?? DEFAULT_QUEUE_WHEN_FULL,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Derives the display key and stable hash for a keyed job payload. */
|
|
241
|
+
export async function deriveJobKey(args: {
|
|
242
|
+
service: string;
|
|
243
|
+
jobType: string;
|
|
244
|
+
payload: unknown;
|
|
245
|
+
template: string[];
|
|
246
|
+
}): Promise<DerivedJobKey> {
|
|
247
|
+
const segments = args.template.map((segment) => {
|
|
248
|
+
if (!segment.startsWith("/")) {
|
|
249
|
+
return segment;
|
|
250
|
+
}
|
|
251
|
+
const value = resolveJsonPointer(args.payload, segment);
|
|
252
|
+
if (!isScalarKeySegment(value)) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Job key pointer '${segment}' did not resolve to a string, finite number, or boolean`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return value;
|
|
258
|
+
});
|
|
259
|
+
const key = segments.map((segment) => String(segment)).join(":");
|
|
260
|
+
const keyHash = await stableKeyHash(
|
|
261
|
+
JSON.stringify({ version: 1, segments }),
|
|
262
|
+
);
|
|
263
|
+
return {
|
|
264
|
+
service: args.service,
|
|
265
|
+
jobType: args.jobType,
|
|
266
|
+
key,
|
|
267
|
+
keyHash,
|
|
268
|
+
kvKey: `${args.service}.${args.jobType}.${keyHash}`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Produces a stable SHA-256 hex hash for a display key. */
|
|
273
|
+
export async function stableKeyHash(value: string): Promise<string> {
|
|
274
|
+
const digest = await crypto.subtle.digest(
|
|
275
|
+
"SHA-256",
|
|
276
|
+
new TextEncoder().encode(value),
|
|
277
|
+
);
|
|
278
|
+
return Array.from(
|
|
279
|
+
new Uint8Array(digest),
|
|
280
|
+
(byte) => byte.toString(16).padStart(2, "0"),
|
|
281
|
+
).join("");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Applies keyed admission policy to an existing key state. */
|
|
285
|
+
export function reduceAdmission(args: {
|
|
286
|
+
state: JobKeyState | undefined;
|
|
287
|
+
derived: DerivedJobKey;
|
|
288
|
+
request: Omit<JobAdmissionRequest, "payload" | "policy">;
|
|
289
|
+
policy: NormalizedJobKeyPolicy;
|
|
290
|
+
}): JobAdmissionOutcome {
|
|
291
|
+
const state = args.state ?? emptyState({
|
|
292
|
+
service: args.request.service,
|
|
293
|
+
jobType: args.request.jobType,
|
|
294
|
+
key: args.derived.key,
|
|
295
|
+
keyHash: args.derived.keyHash,
|
|
296
|
+
now: args.request.createdAt,
|
|
297
|
+
policy: args.policy,
|
|
298
|
+
});
|
|
299
|
+
const active = state.active.length;
|
|
300
|
+
const queued = state.queued.length;
|
|
301
|
+
const queueLimit = args.policy.queue.maxQueuedPerKey +
|
|
302
|
+
Math.max(0, args.policy.maxActive - active);
|
|
303
|
+
|
|
304
|
+
if (queued < queueLimit) {
|
|
305
|
+
return {
|
|
306
|
+
kind: "accepted",
|
|
307
|
+
key: state.key,
|
|
308
|
+
keyHash: state.keyHash,
|
|
309
|
+
state: appendQueued(state, args.request, args.policy),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (args.request.strictCreate) {
|
|
314
|
+
const reason = active >= args.policy.maxActive
|
|
315
|
+
? "active-limit"
|
|
316
|
+
: "queue-depth";
|
|
317
|
+
return {
|
|
318
|
+
kind: "rejected",
|
|
319
|
+
key: state.key,
|
|
320
|
+
reason,
|
|
321
|
+
active,
|
|
322
|
+
queued,
|
|
323
|
+
limit: reason === "active-limit" ? args.policy.maxActive : queueLimit,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (args.policy.queue.whenFull === "coalesce") {
|
|
328
|
+
const existing = state.queued[0] ?? state.active[0];
|
|
329
|
+
if (existing) {
|
|
330
|
+
return {
|
|
331
|
+
kind: "coalesced",
|
|
332
|
+
key: state.key,
|
|
333
|
+
existing: {
|
|
334
|
+
service: state.service,
|
|
335
|
+
jobType: state.jobType,
|
|
336
|
+
id: existing.jobId,
|
|
337
|
+
},
|
|
338
|
+
reason: state.queued[0] ? "queue-full" : "active-limit",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (args.policy.queue.whenFull === "replace-oldest" && state.queued[0]) {
|
|
344
|
+
const [replaced, ...remaining] = state.queued;
|
|
345
|
+
return {
|
|
346
|
+
kind: "replaced",
|
|
347
|
+
key: state.key,
|
|
348
|
+
keyHash: state.keyHash,
|
|
349
|
+
replaced: {
|
|
350
|
+
service: state.service,
|
|
351
|
+
jobType: state.jobType,
|
|
352
|
+
id: replaced.jobId,
|
|
353
|
+
createdAt: replaced.createdAt,
|
|
354
|
+
requestId: replaced.requestId,
|
|
355
|
+
context: replaced.context,
|
|
356
|
+
},
|
|
357
|
+
state: appendQueued(
|
|
358
|
+
{ ...state, queued: remaining },
|
|
359
|
+
args.request,
|
|
360
|
+
args.policy,
|
|
361
|
+
),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const reason = active >= args.policy.maxActive
|
|
366
|
+
? "active-limit"
|
|
367
|
+
: "queue-depth";
|
|
368
|
+
return {
|
|
369
|
+
kind: "rejected",
|
|
370
|
+
key: state.key,
|
|
371
|
+
reason,
|
|
372
|
+
active,
|
|
373
|
+
queued,
|
|
374
|
+
limit: reason === "active-limit" ? args.policy.maxActive : queueLimit,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Restores the replaced queued reservation when replacement lifecycle publish fails. */
|
|
379
|
+
export function reduceRestoreReplacedQueuedJob(args: {
|
|
380
|
+
state: JobKeyState | undefined;
|
|
381
|
+
derived: DerivedJobKey;
|
|
382
|
+
replacementJobId: string;
|
|
383
|
+
replaced: ReplacedQueuedJob;
|
|
384
|
+
now: string;
|
|
385
|
+
policy: NormalizedJobKeyPolicy;
|
|
386
|
+
}): QueuedJobRestoreOutcome {
|
|
387
|
+
const base = args.state ?? emptyState({
|
|
388
|
+
service: args.replaced.service,
|
|
389
|
+
jobType: args.replaced.jobType,
|
|
390
|
+
key: args.derived.key,
|
|
391
|
+
keyHash: args.derived.keyHash,
|
|
392
|
+
now: args.now,
|
|
393
|
+
policy: args.policy,
|
|
394
|
+
});
|
|
395
|
+
const queuedWithoutReplacement = base.queued.filter((entry) =>
|
|
396
|
+
entry.jobId !== args.replacementJobId
|
|
397
|
+
);
|
|
398
|
+
const alreadyPresent =
|
|
399
|
+
queuedWithoutReplacement.some((entry) =>
|
|
400
|
+
entry.jobId === args.replaced.id
|
|
401
|
+
) || base.active.some((slot) => slot.jobId === args.replaced.id);
|
|
402
|
+
const restoredQueued = alreadyPresent ? queuedWithoutReplacement : [{
|
|
403
|
+
jobId: args.replaced.id,
|
|
404
|
+
createdAt: args.replaced.createdAt,
|
|
405
|
+
requestId: args.replaced.requestId,
|
|
406
|
+
context: args.replaced.context,
|
|
407
|
+
}, ...queuedWithoutReplacement];
|
|
408
|
+
return {
|
|
409
|
+
kind: "restored",
|
|
410
|
+
state: {
|
|
411
|
+
...base,
|
|
412
|
+
maxActive: args.policy.maxActive,
|
|
413
|
+
maxQueuedPerKey: args.policy.queue.maxQueuedPerKey,
|
|
414
|
+
queued: restoredQueued,
|
|
415
|
+
updatedAt: args.now,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Applies active slot acquisition policy to an existing key state. */
|
|
421
|
+
export function reduceAcquireActiveSlot(args: {
|
|
422
|
+
state: JobKeyState | undefined;
|
|
423
|
+
derived: DerivedJobKey;
|
|
424
|
+
request: Omit<ActiveSlotAcquireRequest, "payload" | "policy">;
|
|
425
|
+
policy: NormalizedJobKeyPolicy;
|
|
426
|
+
slotToken: string;
|
|
427
|
+
}): ActiveSlotAcquireOutcome {
|
|
428
|
+
const base = args.state ?? emptyState({
|
|
429
|
+
service: args.request.service,
|
|
430
|
+
jobType: args.request.jobType,
|
|
431
|
+
key: args.derived.key,
|
|
432
|
+
keyHash: args.derived.keyHash,
|
|
433
|
+
now: args.request.now,
|
|
434
|
+
policy: args.policy,
|
|
435
|
+
});
|
|
436
|
+
const nowMs = Date.parse(args.request.now);
|
|
437
|
+
const expired = base.active.filter((slot) =>
|
|
438
|
+
Date.parse(slot.leaseExpiresAt) <= nowMs
|
|
439
|
+
);
|
|
440
|
+
if (
|
|
441
|
+
expired.length > 0 && args.policy.stalePolicy === "block" &&
|
|
442
|
+
base.active.length >= args.policy.maxActive
|
|
443
|
+
) {
|
|
444
|
+
return {
|
|
445
|
+
kind: "blocked",
|
|
446
|
+
key: base.key,
|
|
447
|
+
reason: "stale-blocked",
|
|
448
|
+
active: base.active.length,
|
|
449
|
+
queued: base.queued.length,
|
|
450
|
+
limit: args.policy.maxActive,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const active = args.policy.stalePolicy === "fail-stale"
|
|
454
|
+
? base.active.filter((slot) => Date.parse(slot.leaseExpiresAt) > nowMs)
|
|
455
|
+
: base.active;
|
|
456
|
+
const isQueued = base.queued.some((entry) =>
|
|
457
|
+
entry.jobId === args.request.jobId
|
|
458
|
+
);
|
|
459
|
+
const isAlreadyActive = base.active.some((slot) =>
|
|
460
|
+
slot.jobId === args.request.jobId
|
|
461
|
+
);
|
|
462
|
+
const isRetry = args.request.lifecycleState === "retry";
|
|
463
|
+
if (!isQueued && !isAlreadyActive && !isRetry) {
|
|
464
|
+
return {
|
|
465
|
+
kind: "blocked",
|
|
466
|
+
key: base.key,
|
|
467
|
+
reason: "not-queued",
|
|
468
|
+
active: active.length,
|
|
469
|
+
queued: base.queued.length,
|
|
470
|
+
limit: args.policy.queue.maxQueuedPerKey,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (active.length >= args.policy.maxActive) {
|
|
474
|
+
return {
|
|
475
|
+
kind: "blocked",
|
|
476
|
+
key: base.key,
|
|
477
|
+
reason: "active-limit",
|
|
478
|
+
active: active.length,
|
|
479
|
+
queued: base.queued.length,
|
|
480
|
+
limit: args.policy.maxActive,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const leaseExpiresAt = new Date(nowMs + args.policy.heartbeatTtlMs)
|
|
485
|
+
.toISOString();
|
|
486
|
+
const queued = base.queued.filter((entry) =>
|
|
487
|
+
entry.jobId !== args.request.jobId
|
|
488
|
+
);
|
|
489
|
+
const slot: JobKeyActiveSlot = {
|
|
490
|
+
jobId: args.request.jobId,
|
|
491
|
+
slotToken: args.slotToken,
|
|
492
|
+
instanceId: args.request.instanceId,
|
|
493
|
+
startedAt: args.request.now,
|
|
494
|
+
heartbeatAt: args.request.now,
|
|
495
|
+
leaseExpiresAt,
|
|
496
|
+
tries: args.request.tries,
|
|
497
|
+
context: args.request.context,
|
|
498
|
+
};
|
|
499
|
+
const state: JobKeyState = {
|
|
500
|
+
...base,
|
|
501
|
+
maxActive: args.policy.maxActive,
|
|
502
|
+
maxQueuedPerKey: args.policy.queue.maxQueuedPerKey,
|
|
503
|
+
active: [...active, slot],
|
|
504
|
+
queued,
|
|
505
|
+
staleTakeoverCount: base.staleTakeoverCount + expired.length,
|
|
506
|
+
updatedAt: args.request.now,
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
kind: "acquired",
|
|
510
|
+
key: base.key,
|
|
511
|
+
keyHash: base.keyHash,
|
|
512
|
+
slotToken: args.slotToken,
|
|
513
|
+
stale: args.policy.stalePolicy === "fail-stale" ? expired : [],
|
|
514
|
+
state,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Removes a queued keyed-job reservation when work is terminal before start. */
|
|
519
|
+
export function reduceRemoveQueuedJob(args: {
|
|
520
|
+
state: JobKeyState | undefined;
|
|
521
|
+
jobId: string;
|
|
522
|
+
now: string;
|
|
523
|
+
}): QueuedJobRemovalOutcome {
|
|
524
|
+
if (!args.state) {
|
|
525
|
+
return { kind: "not-found" };
|
|
526
|
+
}
|
|
527
|
+
const queued = args.state.queued.filter((entry) =>
|
|
528
|
+
entry.jobId !== args.jobId
|
|
529
|
+
);
|
|
530
|
+
if (queued.length === args.state.queued.length) {
|
|
531
|
+
return { kind: "not-found" };
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
kind: "removed",
|
|
535
|
+
state: { ...args.state, queued, updatedAt: args.now },
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Renews a matching active slot lease in key state. */
|
|
540
|
+
export function reduceRenewHeartbeat(args: {
|
|
541
|
+
state: JobKeyState | undefined;
|
|
542
|
+
jobId: string;
|
|
543
|
+
slotToken: string;
|
|
544
|
+
now: string;
|
|
545
|
+
policy: NormalizedJobKeyPolicy;
|
|
546
|
+
}): ActiveSlotRenewOutcome {
|
|
547
|
+
if (!args.state) {
|
|
548
|
+
return { kind: "lost" };
|
|
549
|
+
}
|
|
550
|
+
const nowMs = Date.parse(args.now);
|
|
551
|
+
let renewed = false;
|
|
552
|
+
const active = args.state.active.map((slot) => {
|
|
553
|
+
if (slot.jobId !== args.jobId || slot.slotToken !== args.slotToken) {
|
|
554
|
+
return slot;
|
|
555
|
+
}
|
|
556
|
+
renewed = true;
|
|
557
|
+
return {
|
|
558
|
+
...slot,
|
|
559
|
+
heartbeatAt: args.now,
|
|
560
|
+
leaseExpiresAt: new Date(nowMs + args.policy.heartbeatTtlMs)
|
|
561
|
+
.toISOString(),
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
if (!renewed) {
|
|
565
|
+
return { kind: "lost" };
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
kind: "renewed",
|
|
569
|
+
state: { ...args.state, active, updatedAt: args.now },
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Releases a matching active slot or reports a stale completion. */
|
|
574
|
+
export function reduceReleaseActiveSlot(args: {
|
|
575
|
+
state: JobKeyState | undefined;
|
|
576
|
+
jobId: string;
|
|
577
|
+
slotToken: string;
|
|
578
|
+
now: string;
|
|
579
|
+
}): ActiveSlotReleaseOutcome {
|
|
580
|
+
if (!args.state) {
|
|
581
|
+
return { kind: "staleCompletion" };
|
|
582
|
+
}
|
|
583
|
+
const nextActive = args.state.active.filter((slot) =>
|
|
584
|
+
slot.jobId !== args.jobId || slot.slotToken !== args.slotToken
|
|
585
|
+
);
|
|
586
|
+
if (nextActive.length === args.state.active.length) {
|
|
587
|
+
return { kind: "staleCompletion" };
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
kind: "released",
|
|
591
|
+
state: { ...args.state, active: nextActive, updatedAt: args.now },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Creates a NATS KV backed coordinator for the Trellis-owned JOBS_KEYS bucket. */
|
|
596
|
+
export function createNatsJobKeyCoordinator(
|
|
597
|
+
nats: NatsConnection,
|
|
598
|
+
bucketPrefix = JOBS_KEYS_BUCKET,
|
|
599
|
+
): JobKeyCoordinator {
|
|
600
|
+
const kvPromises = new Map<string, Promise<KV>>();
|
|
601
|
+
const openKv = (service: string): Promise<KV> => {
|
|
602
|
+
const bucket = jobKeysBucketName(bucketPrefix, service);
|
|
603
|
+
const existing = kvPromises.get(bucket);
|
|
604
|
+
if (existing) {
|
|
605
|
+
return existing;
|
|
606
|
+
}
|
|
607
|
+
const opened = new Kvm(nats).open(bucket);
|
|
608
|
+
kvPromises.set(bucket, opened);
|
|
609
|
+
return opened;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
async admitCreate(request) {
|
|
614
|
+
const derived = await deriveJobKey({
|
|
615
|
+
service: request.service,
|
|
616
|
+
jobType: request.jobType,
|
|
617
|
+
payload: request.payload,
|
|
618
|
+
template: request.policy.key,
|
|
619
|
+
});
|
|
620
|
+
return await updateState(
|
|
621
|
+
() => openKv(request.service),
|
|
622
|
+
derived,
|
|
623
|
+
(state) =>
|
|
624
|
+
reduceAdmission({
|
|
625
|
+
state,
|
|
626
|
+
derived,
|
|
627
|
+
request,
|
|
628
|
+
policy: request.policy,
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
},
|
|
632
|
+
async restoreReplacedQueuedJob(args) {
|
|
633
|
+
const derived = await deriveJobKey({
|
|
634
|
+
service: args.service,
|
|
635
|
+
jobType: args.jobType,
|
|
636
|
+
payload: args.payload,
|
|
637
|
+
template: args.policy.key,
|
|
638
|
+
});
|
|
639
|
+
return await updateState(
|
|
640
|
+
() => openKv(args.service),
|
|
641
|
+
derived,
|
|
642
|
+
(state) =>
|
|
643
|
+
reduceRestoreReplacedQueuedJob({
|
|
644
|
+
state,
|
|
645
|
+
derived,
|
|
646
|
+
replacementJobId: args.replacementJobId,
|
|
647
|
+
replaced: args.replaced,
|
|
648
|
+
now: args.now,
|
|
649
|
+
policy: args.policy,
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
},
|
|
653
|
+
async removeQueuedJob(args) {
|
|
654
|
+
const derived = await deriveJobKey({
|
|
655
|
+
service: args.service,
|
|
656
|
+
jobType: args.jobType,
|
|
657
|
+
payload: args.payload,
|
|
658
|
+
template: args.policy.key,
|
|
659
|
+
});
|
|
660
|
+
return await updateState(
|
|
661
|
+
() => openKv(args.service),
|
|
662
|
+
derived,
|
|
663
|
+
(state) =>
|
|
664
|
+
reduceRemoveQueuedJob({
|
|
665
|
+
state,
|
|
666
|
+
jobId: args.jobId,
|
|
667
|
+
now: args.now,
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
},
|
|
671
|
+
async acquireActiveSlot(request) {
|
|
672
|
+
const derived = await deriveJobKey({
|
|
673
|
+
service: request.service,
|
|
674
|
+
jobType: request.jobType,
|
|
675
|
+
payload: request.payload,
|
|
676
|
+
template: request.policy.key,
|
|
677
|
+
});
|
|
678
|
+
const slotToken = crypto.randomUUID();
|
|
679
|
+
return await updateState(
|
|
680
|
+
() => openKv(request.service),
|
|
681
|
+
derived,
|
|
682
|
+
(state) =>
|
|
683
|
+
reduceAcquireActiveSlot({
|
|
684
|
+
state,
|
|
685
|
+
derived,
|
|
686
|
+
request,
|
|
687
|
+
policy: request.policy,
|
|
688
|
+
slotToken,
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
},
|
|
692
|
+
async renewHeartbeat(args) {
|
|
693
|
+
const derived = {
|
|
694
|
+
service: args.service,
|
|
695
|
+
jobType: args.jobType,
|
|
696
|
+
key: args.lease.key,
|
|
697
|
+
keyHash: args.lease.keyHash,
|
|
698
|
+
kvKey: `${args.service}.${args.jobType}.${args.lease.keyHash}`,
|
|
699
|
+
};
|
|
700
|
+
return await updateState(
|
|
701
|
+
() => openKv(args.service),
|
|
702
|
+
derived,
|
|
703
|
+
(state) =>
|
|
704
|
+
reduceRenewHeartbeat({
|
|
705
|
+
state,
|
|
706
|
+
jobId: args.jobId,
|
|
707
|
+
slotToken: args.lease.slotToken,
|
|
708
|
+
now: args.now,
|
|
709
|
+
policy: args.lease.policy,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
},
|
|
713
|
+
async releaseActiveSlot(args) {
|
|
714
|
+
const derived = {
|
|
715
|
+
service: args.service,
|
|
716
|
+
jobType: args.jobType,
|
|
717
|
+
key: args.lease.key,
|
|
718
|
+
keyHash: args.lease.keyHash,
|
|
719
|
+
kvKey: `${args.service}.${args.jobType}.${args.lease.keyHash}`,
|
|
720
|
+
};
|
|
721
|
+
return await updateState(
|
|
722
|
+
() => openKv(args.service),
|
|
723
|
+
derived,
|
|
724
|
+
(state) =>
|
|
725
|
+
reduceReleaseActiveSlot({
|
|
726
|
+
state,
|
|
727
|
+
jobId: args.jobId,
|
|
728
|
+
slotToken: args.lease.slotToken,
|
|
729
|
+
now: args.now,
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function jobKeysBucketName(prefix: string, service: string): string {
|
|
737
|
+
return `${prefix}_${service}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function appendQueued(
|
|
741
|
+
state: JobKeyState,
|
|
742
|
+
request: Omit<JobAdmissionRequest, "payload" | "policy">,
|
|
743
|
+
policy: NormalizedJobKeyPolicy,
|
|
744
|
+
): JobKeyState {
|
|
745
|
+
return {
|
|
746
|
+
...state,
|
|
747
|
+
maxActive: policy.maxActive,
|
|
748
|
+
maxQueuedPerKey: policy.queue.maxQueuedPerKey,
|
|
749
|
+
queued: [
|
|
750
|
+
...state.queued,
|
|
751
|
+
{
|
|
752
|
+
jobId: request.jobId,
|
|
753
|
+
createdAt: request.createdAt,
|
|
754
|
+
requestId: request.context.requestId,
|
|
755
|
+
context: request.context,
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
updatedAt: request.createdAt,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function emptyState(args: {
|
|
763
|
+
service: string;
|
|
764
|
+
jobType: string;
|
|
765
|
+
key: string;
|
|
766
|
+
keyHash: string;
|
|
767
|
+
now: string;
|
|
768
|
+
policy: NormalizedJobKeyPolicy;
|
|
769
|
+
}): JobKeyState {
|
|
770
|
+
return {
|
|
771
|
+
version: 1,
|
|
772
|
+
service: args.service,
|
|
773
|
+
jobType: args.jobType,
|
|
774
|
+
key: args.key,
|
|
775
|
+
keyHash: args.keyHash,
|
|
776
|
+
maxActive: args.policy.maxActive,
|
|
777
|
+
maxQueuedPerKey: args.policy.queue.maxQueuedPerKey,
|
|
778
|
+
active: [],
|
|
779
|
+
queued: [],
|
|
780
|
+
staleTakeoverCount: 0,
|
|
781
|
+
updatedAt: args.now,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function resolveJsonPointer(payload: unknown, pointer: string): unknown {
|
|
786
|
+
const tokens = pointer.slice(1).split("/").map((token) =>
|
|
787
|
+
token.replaceAll("~1", "/").replaceAll("~0", "~")
|
|
788
|
+
);
|
|
789
|
+
let current = payload;
|
|
790
|
+
for (const token of tokens) {
|
|
791
|
+
if (Array.isArray(current)) {
|
|
792
|
+
if (!/^0$|^[1-9][0-9]*$/.test(token)) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
current = current[Number(token)];
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (current !== null && typeof current === "object") {
|
|
799
|
+
current = (current as Record<string, unknown>)[token];
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
return current;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isScalarKeySegment(
|
|
808
|
+
value: unknown,
|
|
809
|
+
): value is string | number | boolean {
|
|
810
|
+
return typeof value === "string" ||
|
|
811
|
+
(typeof value === "number" && Number.isFinite(value)) ||
|
|
812
|
+
typeof value === "boolean";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function updateState<TOutcome>(
|
|
816
|
+
openKv: () => Promise<KV>,
|
|
817
|
+
derived: DerivedJobKey,
|
|
818
|
+
reducer: (state: JobKeyState | undefined) => TOutcome,
|
|
819
|
+
): Promise<TOutcome> {
|
|
820
|
+
const kv = await openKv();
|
|
821
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) {
|
|
822
|
+
const entry = await getStateEntry(kv, derived.kvKey);
|
|
823
|
+
const current = entry ? parseState(entry, derived) : undefined;
|
|
824
|
+
const outcome = reducer(current);
|
|
825
|
+
const next = outcomeState(outcome);
|
|
826
|
+
if (!next) {
|
|
827
|
+
return outcome;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
if (entry) {
|
|
831
|
+
await kv.update(derived.kvKey, JSON.stringify(next), entry.revision);
|
|
832
|
+
} else {
|
|
833
|
+
await kv.create(derived.kvKey, JSON.stringify(next));
|
|
834
|
+
}
|
|
835
|
+
return outcome;
|
|
836
|
+
} catch (error) {
|
|
837
|
+
if (!isCasConflict(error)) {
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
throw new Error(`Could not update keyed job state '${derived.kvKey}'`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function getStateEntry(
|
|
846
|
+
kv: KV,
|
|
847
|
+
key: string,
|
|
848
|
+
): Promise<KvEntry | undefined> {
|
|
849
|
+
const entry = await kv.get(key);
|
|
850
|
+
if (!entry || entry.operation === "DEL" || entry.operation === "PURGE") {
|
|
851
|
+
return undefined;
|
|
852
|
+
}
|
|
853
|
+
return entry;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function parseState(
|
|
857
|
+
entry: KvEntry,
|
|
858
|
+
derived: DerivedJobKey,
|
|
859
|
+
): JobKeyState | undefined {
|
|
860
|
+
const decoded = entry.json<unknown>();
|
|
861
|
+
if (!isJobKeyState(decoded)) {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
assertStateMatchesDerived(decoded, derived);
|
|
865
|
+
return decoded;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function assertStateMatchesDerived(
|
|
869
|
+
state: JobKeyState,
|
|
870
|
+
derived: DerivedJobKey,
|
|
871
|
+
): void {
|
|
872
|
+
if (
|
|
873
|
+
state.service !== derived.service || state.jobType !== derived.jobType ||
|
|
874
|
+
state.keyHash !== derived.keyHash
|
|
875
|
+
) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`keyed job state '${derived.kvKey}' does not match derived service, job type, and key hash`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Returns whether an unknown value is a well-formed keyed job coordination state. */
|
|
883
|
+
export function isJobKeyState(value: unknown): value is JobKeyState {
|
|
884
|
+
if (value === null || typeof value !== "object") return false;
|
|
885
|
+
const state = value as Partial<JobKeyState>;
|
|
886
|
+
return state.version === 1 && isNonEmptyString(state.service) &&
|
|
887
|
+
isNonEmptyString(state.jobType) && typeof state.key === "string" &&
|
|
888
|
+
isNonEmptyString(state.keyHash) && isPositiveInteger(state.maxActive) &&
|
|
889
|
+
(state.maxQueuedPerKey === undefined ||
|
|
890
|
+
isNonNegativeInteger(state.maxQueuedPerKey)) &&
|
|
891
|
+
Array.isArray(state.active) && state.active.every(isJobKeyActiveSlot) &&
|
|
892
|
+
Array.isArray(state.queued) && state.queued.every(isJobKeyQueued) &&
|
|
893
|
+
isNonNegativeInteger(state.staleTakeoverCount) &&
|
|
894
|
+
isValidIsoTimestamp(state.updatedAt);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function isJobKeyQueued(value: unknown): value is JobKeyQueued {
|
|
898
|
+
if (value === null || typeof value !== "object") return false;
|
|
899
|
+
const entry = value as Partial<JobKeyQueued>;
|
|
900
|
+
return isNonEmptyString(entry.jobId) &&
|
|
901
|
+
isValidIsoTimestamp(entry.createdAt) &&
|
|
902
|
+
isNonEmptyString(entry.requestId) && isJobContext(entry.context);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function isJobKeyActiveSlot(value: unknown): value is JobKeyActiveSlot {
|
|
906
|
+
if (value === null || typeof value !== "object") return false;
|
|
907
|
+
const slot = value as Partial<JobKeyActiveSlot>;
|
|
908
|
+
return isNonEmptyString(slot.jobId) && isNonEmptyString(slot.slotToken) &&
|
|
909
|
+
isNonEmptyString(slot.instanceId) && isValidIsoTimestamp(slot.startedAt) &&
|
|
910
|
+
isValidIsoTimestamp(slot.heartbeatAt) &&
|
|
911
|
+
isValidIsoTimestamp(slot.leaseExpiresAt) &&
|
|
912
|
+
isNonNegativeInteger(slot.tries) && isJobContext(slot.context);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function isJobContext(value: unknown): value is JobContext {
|
|
916
|
+
if (value === null || typeof value !== "object") return false;
|
|
917
|
+
const context = value as Partial<JobContext>;
|
|
918
|
+
return isNonEmptyString(context.requestId) &&
|
|
919
|
+
isNonEmptyString(context.traceId) &&
|
|
920
|
+
isNonEmptyString(context.traceparent) &&
|
|
921
|
+
(context.tracestate === undefined ||
|
|
922
|
+
typeof context.tracestate === "string");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
926
|
+
return typeof value === "string" && value.length > 0;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
930
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
934
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function isValidIsoTimestamp(value: unknown): value is string {
|
|
938
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function outcomeState(outcome: unknown): JobKeyState | undefined {
|
|
942
|
+
if (outcome === null || typeof outcome !== "object") return undefined;
|
|
943
|
+
const candidate = outcome as { state?: unknown };
|
|
944
|
+
return isJobKeyState(candidate.state) ? candidate.state : undefined;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function isCasConflict(error: unknown): boolean {
|
|
948
|
+
if (!(error instanceof Error)) return false;
|
|
949
|
+
const message = error.message.toLowerCase();
|
|
950
|
+
return message.includes("wrong last sequence") ||
|
|
951
|
+
message.includes("wrong last_seq") ||
|
|
952
|
+
message.includes("sequence mismatch") ||
|
|
953
|
+
message.includes("entry exists") ||
|
|
954
|
+
message.includes("already exists");
|
|
955
|
+
}
|