@furystack/redis-task-runner 1.0.0
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 +128 -0
- package/esm/cross-process-smoke.spec.d.ts +2 -0
- package/esm/cross-process-smoke.spec.d.ts.map +1 -0
- package/esm/cross-process-smoke.spec.js +463 -0
- package/esm/cross-process-smoke.spec.js.map +1 -0
- package/esm/define-redis-task-runner.d.ts +38 -0
- package/esm/define-redis-task-runner.d.ts.map +1 -0
- package/esm/define-redis-task-runner.js +60 -0
- package/esm/define-redis-task-runner.js.map +1 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +4 -0
- package/esm/index.js.map +1 -0
- package/esm/multi-service-smoke.spec.d.ts +2 -0
- package/esm/multi-service-smoke.spec.d.ts.map +1 -0
- package/esm/multi-service-smoke.spec.js +468 -0
- package/esm/multi-service-smoke.spec.js.map +1 -0
- package/esm/redis-queue-adapter.d.ts +125 -0
- package/esm/redis-queue-adapter.d.ts.map +1 -0
- package/esm/redis-queue-adapter.js +566 -0
- package/esm/redis-queue-adapter.js.map +1 -0
- package/esm/redis-queue-adapter.unit.spec.d.ts +2 -0
- package/esm/redis-queue-adapter.unit.spec.d.ts.map +1 -0
- package/esm/redis-queue-adapter.unit.spec.js +860 -0
- package/esm/redis-queue-adapter.unit.spec.js.map +1 -0
- package/esm/redis-task-runner.d.ts +33 -0
- package/esm/redis-task-runner.d.ts.map +1 -0
- package/esm/redis-task-runner.integration.spec.d.ts +2 -0
- package/esm/redis-task-runner.integration.spec.d.ts.map +1 -0
- package/esm/redis-task-runner.integration.spec.js +536 -0
- package/esm/redis-task-runner.integration.spec.js.map +1 -0
- package/esm/redis-task-runner.js +43 -0
- package/esm/redis-task-runner.js.map +1 -0
- package/esm/redis-task-runner.unit.spec.d.ts +2 -0
- package/esm/redis-task-runner.unit.spec.d.ts.map +1 -0
- package/esm/redis-task-runner.unit.spec.js +97 -0
- package/esm/redis-task-runner.unit.spec.js.map +1 -0
- package/package.json +63 -0
- package/src/cross-process-smoke.spec.ts +459 -0
- package/src/define-redis-task-runner.ts +91 -0
- package/src/index.ts +8 -0
- package/src/multi-service-smoke.spec.ts +511 -0
- package/src/redis-queue-adapter.ts +733 -0
- package/src/redis-queue-adapter.unit.spec.ts +613 -0
- package/src/redis-task-runner.integration.spec.ts +440 -0
- package/src/redis-task-runner.ts +67 -0
- package/src/redis-task-runner.unit.spec.ts +110 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-service smoke (PRD Milestone 6).
|
|
3
|
+
*
|
|
4
|
+
* Two services × two worker pods each, a single Redis broker, an S3
|
|
5
|
+
* (MinIO) blob store, and a per-service `topicPrefix`. Same-process by
|
|
6
|
+
* design — every pod is its own injector with its own Redis clients,
|
|
7
|
+
* `RedisTaskRunner`, `RedisCrossNodeBus`, and Redis-store-backed Task
|
|
8
|
+
* datasets. This mirrors the accepted shape of the cross-node-bus
|
|
9
|
+
* multi-service smoke: cross-process isolation only proves V8 matches
|
|
10
|
+
* injector isolation, which is uninteresting; the value is the full DI +
|
|
11
|
+
* adapter wiring against real infrastructure.
|
|
12
|
+
*
|
|
13
|
+
* Per service, isolation lives in four prefixes sharing one run root so a
|
|
14
|
+
* single `KEYS ${runId}*` sweeps the broker clean:
|
|
15
|
+
* - queue `${runId}:${svc}:q/` (RedisTaskRunner streams / ZSETs)
|
|
16
|
+
* - store `${runId}:${svc}:s/` (RedisStore Task + replay rows)
|
|
17
|
+
* - bus `${runId}:${svc}:bus/` (RedisCrossNodeBus topics)
|
|
18
|
+
* - count `${runId}:cnt:${svc}:` (shared assertion counters)
|
|
19
|
+
* S3 blobs are scoped per service via `keyPrefix` inside one run bucket.
|
|
20
|
+
*
|
|
21
|
+
* Assertions (PRD §13 M6):
|
|
22
|
+
* 1. Tasks submitted to service A run only on service A's workers.
|
|
23
|
+
* 2. Cross-service submission is explicit — B's store never sees A's task.
|
|
24
|
+
* 3. Fleet caps bind fleet-wide across a service's pods.
|
|
25
|
+
* 4. Draining one pod does not lose work (the other pod picks it up).
|
|
26
|
+
* + S3-backed produced blobs round-trip across the fleet.
|
|
27
|
+
* + §4 success metrics (submit-to-claim, progress delivery) measured.
|
|
28
|
+
*
|
|
29
|
+
* Gated on a reachable Redis at `REDIS_URL` (default redis://localhost:6379)
|
|
30
|
+
* and MinIO at `MINIO_URL` (default http://localhost:9000).
|
|
31
|
+
*/
|
|
32
|
+
import { randomUUID } from 'node:crypto'
|
|
33
|
+
import { CreateBucketCommand, DeleteBucketCommand, S3Client } from '@aws-sdk/client-s3'
|
|
34
|
+
import { BlobStore } from '@furystack/blob-store'
|
|
35
|
+
import type { PhysicalStore } from '@furystack/core'
|
|
36
|
+
import { CrossNodeBus, type BusMessage } from '@furystack/cross-node-bus'
|
|
37
|
+
import { createInjector, type Injector } from '@furystack/inject'
|
|
38
|
+
import { defineRedisCrossNodeBusAdapter, RedisCrossNodeBus } from '@furystack/redis-cross-node-bus'
|
|
39
|
+
import { RedisStore } from '@furystack/redis-store'
|
|
40
|
+
import { defineS3BlobStore } from '@furystack/s3-blob-store'
|
|
41
|
+
import {
|
|
42
|
+
defineTaskHandler,
|
|
43
|
+
isTerminalStatus,
|
|
44
|
+
Task,
|
|
45
|
+
TaskReplayLogEntry,
|
|
46
|
+
TaskReplayLogStore,
|
|
47
|
+
TaskRunner,
|
|
48
|
+
TaskStore,
|
|
49
|
+
type AnyTaskHandlerDescriptor,
|
|
50
|
+
type TaskStatus,
|
|
51
|
+
type TaskUpdate,
|
|
52
|
+
} from '@furystack/task-runner'
|
|
53
|
+
import { createClient, type RedisClientType } from 'redis'
|
|
54
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
55
|
+
import { defineRedisTaskRunner } from './define-redis-task-runner.js'
|
|
56
|
+
|
|
57
|
+
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'
|
|
58
|
+
const MINIO_URL = process.env.MINIO_URL ?? 'http://localhost:9000'
|
|
59
|
+
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY ?? 'minioadmin'
|
|
60
|
+
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY ?? 'minioadmin'
|
|
61
|
+
|
|
62
|
+
/** §4 budgets. The targets are 50 ms (submit→claim) and 100 ms (progress);
|
|
63
|
+
* the assert budget is intentionally generous so the smoke does not flake on
|
|
64
|
+
* loaded CI — the real measured p95 is logged for the record either way. */
|
|
65
|
+
const SUBMIT_TO_CLAIM_BUDGET_MS = 1000
|
|
66
|
+
const PROGRESS_DELIVERY_BUDGET_MS = 1000
|
|
67
|
+
|
|
68
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
69
|
+
|
|
70
|
+
const MAX_SCRIPT = `
|
|
71
|
+
local m = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
72
|
+
local v = tonumber(ARGV[1])
|
|
73
|
+
if v > m then redis.call('SET', KEYS[1], v) end
|
|
74
|
+
return v
|
|
75
|
+
`
|
|
76
|
+
|
|
77
|
+
const evalScript = (client: RedisClientType, script: string, keys: string[], args: string[]): Promise<unknown> => {
|
|
78
|
+
const evalFn = client.eval as (s: string, opts: { keys: string[]; arguments: string[] }) => Promise<unknown>
|
|
79
|
+
return evalFn.call(client, script, { keys, arguments: args })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const percentile = (samples: readonly number[], p: number): number => {
|
|
83
|
+
if (samples.length === 0) return Number.NaN
|
|
84
|
+
const sorted = [...samples].sort((a, b) => a - b)
|
|
85
|
+
const rank = Math.ceil((p / 100) * sorted.length) - 1
|
|
86
|
+
return sorted[Math.max(0, Math.min(sorted.length - 1, rank))]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type ServicePrefixes = { queue: string; store: string; bus: string; count: string; blob: string }
|
|
90
|
+
|
|
91
|
+
const buildHandlers = (control: RedisClientType, ns: string, podId: string): AnyTaskHandlerDescriptor[] => {
|
|
92
|
+
const echo = defineTaskHandler<Record<string, never>, { ok: true }>({
|
|
93
|
+
type: 'echo',
|
|
94
|
+
version: 1,
|
|
95
|
+
handler: async (ctx) => {
|
|
96
|
+
await control.set(`${ns}start:${ctx.taskId}`, String(ctx.now().getTime()))
|
|
97
|
+
await control.set(`${ns}ranBy:${ctx.taskId}`, podId)
|
|
98
|
+
await control.incr(`${ns}execBy:${podId}`)
|
|
99
|
+
return { ok: true }
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const capped = defineTaskHandler<Record<string, never>, void>({
|
|
104
|
+
type: 'capped',
|
|
105
|
+
version: 1,
|
|
106
|
+
handler: async () => {
|
|
107
|
+
const live = await control.incr(`${ns}live`)
|
|
108
|
+
await evalScript(control, MAX_SCRIPT, [`${ns}max`], [String(live)])
|
|
109
|
+
await sleep(250)
|
|
110
|
+
await control.decr(`${ns}live`)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const slow = defineTaskHandler<Record<string, never>, void>({
|
|
115
|
+
type: 'slow',
|
|
116
|
+
version: 1,
|
|
117
|
+
handler: async (ctx) => {
|
|
118
|
+
await control.incr(`${ns}slow:started`)
|
|
119
|
+
await control.set(`${ns}slowBy:${ctx.taskId}`, podId)
|
|
120
|
+
await sleep(700)
|
|
121
|
+
await control.incr(`${ns}slow:done`)
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const blobbed = defineTaskHandler<Record<string, never>, { key: string }>({
|
|
126
|
+
type: 'blobbed',
|
|
127
|
+
version: 1,
|
|
128
|
+
handler: async (ctx) => {
|
|
129
|
+
await control.set(`${ns}start:${ctx.taskId}`, String(ctx.now().getTime()))
|
|
130
|
+
ctx.reportProgress({ percent: 50, meta: { sentAt: ctx.now().getTime() } })
|
|
131
|
+
const key = `out/${ctx.taskId}.txt`
|
|
132
|
+
await ctx.blobStore.put(key, Buffer.from(`blob-${ctx.taskId}`))
|
|
133
|
+
return { key }
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return [echo, capped, slow, blobbed]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Pod = {
|
|
141
|
+
podId: string
|
|
142
|
+
injector: Injector
|
|
143
|
+
runner: TaskRunner
|
|
144
|
+
client: RedisClientType
|
|
145
|
+
busClient: RedisClientType
|
|
146
|
+
drain: () => Promise<void>
|
|
147
|
+
[Symbol.asyncDispose]: () => Promise<void>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const setupPod = async (options: {
|
|
151
|
+
serviceName: string
|
|
152
|
+
podId: string
|
|
153
|
+
prefixes: ServicePrefixes
|
|
154
|
+
control: RedisClientType
|
|
155
|
+
s3: S3Client
|
|
156
|
+
bucket: string
|
|
157
|
+
concurrency: number
|
|
158
|
+
concurrencyLimits?: Record<string, number>
|
|
159
|
+
visibilityTimeoutMs?: number
|
|
160
|
+
}): Promise<Pod> => {
|
|
161
|
+
const client = createClient({ url: REDIS_URL })
|
|
162
|
+
const busClient = createClient({ url: REDIS_URL })
|
|
163
|
+
await client.connect()
|
|
164
|
+
await busClient.connect()
|
|
165
|
+
|
|
166
|
+
const injector = createInjector()
|
|
167
|
+
injector.bind(
|
|
168
|
+
BlobStore,
|
|
169
|
+
defineS3BlobStore({
|
|
170
|
+
client: options.s3,
|
|
171
|
+
bucket: options.bucket,
|
|
172
|
+
keyPrefix: options.prefixes.blob,
|
|
173
|
+
name: `${options.serviceName}-s3`,
|
|
174
|
+
manageLifecycle: false,
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
injector.bind(
|
|
178
|
+
CrossNodeBus,
|
|
179
|
+
defineRedisCrossNodeBusAdapter({
|
|
180
|
+
client: busClient,
|
|
181
|
+
serviceName: options.serviceName,
|
|
182
|
+
topicPrefix: options.prefixes.bus,
|
|
183
|
+
nodeId: options.podId,
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
// `as PhysicalStore` bridges the RedisStore `TWriteableData` (id required)
|
|
187
|
+
// vs. the StoreToken's `WithOptionalId` param-variance gap — the same shape
|
|
188
|
+
// `defineStore` accepts via bivariance.
|
|
189
|
+
injector.bind(
|
|
190
|
+
TaskStore,
|
|
191
|
+
() =>
|
|
192
|
+
new RedisStore({
|
|
193
|
+
model: Task,
|
|
194
|
+
primaryKey: 'id',
|
|
195
|
+
client,
|
|
196
|
+
keyPrefix: `${options.prefixes.store}t`,
|
|
197
|
+
}) as PhysicalStore<Task, 'id'>,
|
|
198
|
+
)
|
|
199
|
+
injector.bind(
|
|
200
|
+
TaskReplayLogStore,
|
|
201
|
+
() =>
|
|
202
|
+
new RedisStore({
|
|
203
|
+
model: TaskReplayLogEntry,
|
|
204
|
+
primaryKey: 'id',
|
|
205
|
+
client,
|
|
206
|
+
keyPrefix: `${options.prefixes.store}r`,
|
|
207
|
+
}) as PhysicalStore<TaskReplayLogEntry, 'id'>,
|
|
208
|
+
)
|
|
209
|
+
injector.bind(
|
|
210
|
+
TaskRunner,
|
|
211
|
+
defineRedisTaskRunner({
|
|
212
|
+
client,
|
|
213
|
+
serviceName: options.serviceName,
|
|
214
|
+
topicPrefix: options.prefixes.queue,
|
|
215
|
+
visibilityTimeoutMs: options.visibilityTimeoutMs ?? 60_000,
|
|
216
|
+
blockTimeoutMs: 25,
|
|
217
|
+
retryBackoffMs: 50,
|
|
218
|
+
schedulerIntervalMs: 50,
|
|
219
|
+
reconcilerIntervalMs: 1_000_000,
|
|
220
|
+
sweepIntervalMs: 200,
|
|
221
|
+
concurrencyLimits: options.concurrencyLimits,
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const runner = injector.get(TaskRunner)
|
|
226
|
+
const worker = runner.registerWorker({
|
|
227
|
+
name: options.podId,
|
|
228
|
+
handlers: buildHandlers(options.control, options.prefixes.count, options.podId),
|
|
229
|
+
concurrency: options.concurrency,
|
|
230
|
+
tags: [],
|
|
231
|
+
compatibleVersions: {},
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
podId: options.podId,
|
|
236
|
+
injector,
|
|
237
|
+
runner,
|
|
238
|
+
client,
|
|
239
|
+
busClient,
|
|
240
|
+
drain: () => worker.drain({ timeoutMs: 5000 }),
|
|
241
|
+
async [Symbol.asyncDispose]() {
|
|
242
|
+
worker[Symbol.dispose]()
|
|
243
|
+
await injector[Symbol.asyncDispose]()
|
|
244
|
+
if (client.isOpen) await client.quit()
|
|
245
|
+
if (busClient.isOpen) await busClient.quit()
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type Observer = {
|
|
251
|
+
bus: RedisCrossNodeBus
|
|
252
|
+
injector: Injector
|
|
253
|
+
client: RedisClientType
|
|
254
|
+
[Symbol.asyncDispose]: () => Promise<void>
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const setupObserver = async (serviceName: string, prefixes: ServicePrefixes): Promise<Observer> => {
|
|
258
|
+
const client = createClient({ url: REDIS_URL })
|
|
259
|
+
await client.connect()
|
|
260
|
+
const injector = createInjector()
|
|
261
|
+
injector.bind(
|
|
262
|
+
CrossNodeBus,
|
|
263
|
+
defineRedisCrossNodeBusAdapter({ client, serviceName, topicPrefix: prefixes.bus, nodeId: `${serviceName}-obs` }),
|
|
264
|
+
)
|
|
265
|
+
const bus = injector.get(CrossNodeBus)
|
|
266
|
+
if (!(bus instanceof RedisCrossNodeBus)) throw new Error('expected RedisCrossNodeBus binding')
|
|
267
|
+
return {
|
|
268
|
+
bus,
|
|
269
|
+
injector,
|
|
270
|
+
client,
|
|
271
|
+
async [Symbol.asyncDispose]() {
|
|
272
|
+
await injector[Symbol.asyncDispose]()
|
|
273
|
+
if (client.isOpen) await client.quit()
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const prefixesFor = (runId: string, svc: string): ServicePrefixes => ({
|
|
279
|
+
queue: `${runId}:${svc}:q/`,
|
|
280
|
+
store: `${runId}:${svc}:s/`,
|
|
281
|
+
bus: `${runId}:${svc}:bus/`,
|
|
282
|
+
count: `${runId}:cnt:${svc}:`,
|
|
283
|
+
blob: `${runId}/${svc}/blobs/`,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const makeS3 = (): S3Client =>
|
|
287
|
+
new S3Client({
|
|
288
|
+
endpoint: MINIO_URL,
|
|
289
|
+
region: 'us-east-1',
|
|
290
|
+
forcePathStyle: true,
|
|
291
|
+
credentials: { accessKeyId: MINIO_ACCESS_KEY, secretAccessKey: MINIO_SECRET_KEY },
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const cleanupRedis = async (runId: string): Promise<void> => {
|
|
295
|
+
const client = createClient({ url: REDIS_URL })
|
|
296
|
+
try {
|
|
297
|
+
await client.connect()
|
|
298
|
+
const keys = await client.keys(`${runId}*`)
|
|
299
|
+
if (keys.length > 0) await client.del(keys)
|
|
300
|
+
} catch {
|
|
301
|
+
// Redis unreachable — the test bodies already failed loudly.
|
|
302
|
+
} finally {
|
|
303
|
+
if (client.isOpen) await client.quit()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const num = (value: string | null): number => (value ? Number(value) : 0)
|
|
308
|
+
|
|
309
|
+
const pollStatus = async (runner: TaskRunner, taskId: string, timeoutMs: number): Promise<TaskStatus | undefined> => {
|
|
310
|
+
const deadline = Date.now() + timeoutMs
|
|
311
|
+
while (Date.now() < deadline) {
|
|
312
|
+
const task = await runner.get(taskId)
|
|
313
|
+
if (task && isTerminalStatus(task.status)) return task.status
|
|
314
|
+
await sleep(60)
|
|
315
|
+
}
|
|
316
|
+
return (await runner.get(taskId))?.status
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const waitFor = async (predicate: () => Promise<boolean>, timeoutMs: number): Promise<void> => {
|
|
320
|
+
const deadline = Date.now() + timeoutMs
|
|
321
|
+
while (Date.now() < deadline) {
|
|
322
|
+
if (await predicate()) return
|
|
323
|
+
await sleep(60)
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`waitFor: timed out after ${timeoutMs}ms`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
describe('multi-service smoke (PRD M6)', () => {
|
|
329
|
+
let runId: string
|
|
330
|
+
let control: RedisClientType
|
|
331
|
+
let s3: S3Client
|
|
332
|
+
let bucket: string
|
|
333
|
+
let aPods: Pod[]
|
|
334
|
+
let bPods: Pod[]
|
|
335
|
+
let aObserver: Observer
|
|
336
|
+
let bObserver: Observer
|
|
337
|
+
let aPrefixes: ServicePrefixes
|
|
338
|
+
let bPrefixes: ServicePrefixes
|
|
339
|
+
|
|
340
|
+
beforeAll(async () => {
|
|
341
|
+
runId = `m6-${randomUUID().slice(0, 8)}`
|
|
342
|
+
control = createClient({ url: REDIS_URL })
|
|
343
|
+
await control.connect()
|
|
344
|
+
|
|
345
|
+
s3 = makeS3()
|
|
346
|
+
bucket = `furystack-m6-${runId}`
|
|
347
|
+
await s3.send(new CreateBucketCommand({ Bucket: bucket }))
|
|
348
|
+
|
|
349
|
+
aPrefixes = prefixesFor(runId, 'svc-a')
|
|
350
|
+
bPrefixes = prefixesFor(runId, 'svc-b')
|
|
351
|
+
|
|
352
|
+
aPods = await Promise.all(
|
|
353
|
+
['a0', 'a1'].map((podId) =>
|
|
354
|
+
setupPod({
|
|
355
|
+
serviceName: 'svc-a',
|
|
356
|
+
podId,
|
|
357
|
+
prefixes: aPrefixes,
|
|
358
|
+
control,
|
|
359
|
+
s3,
|
|
360
|
+
bucket,
|
|
361
|
+
concurrency: 2,
|
|
362
|
+
concurrencyLimits: { capped: 2 },
|
|
363
|
+
}),
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
bPods = await Promise.all(
|
|
367
|
+
['b0', 'b1'].map((podId) =>
|
|
368
|
+
setupPod({ serviceName: 'svc-b', podId, prefixes: bPrefixes, control, s3, bucket, concurrency: 2 }),
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
aObserver = await setupObserver('svc-a', aPrefixes)
|
|
372
|
+
bObserver = await setupObserver('svc-b', bPrefixes)
|
|
373
|
+
|
|
374
|
+
// Give the Redis Streams consumer groups + bus cursors a beat to go live.
|
|
375
|
+
await sleep(300)
|
|
376
|
+
}, 40_000)
|
|
377
|
+
|
|
378
|
+
afterAll(async () => {
|
|
379
|
+
await Promise.all([...(aPods ?? []), ...(bPods ?? [])].map((p) => p[Symbol.asyncDispose]()))
|
|
380
|
+
if (aObserver) await aObserver[Symbol.asyncDispose]()
|
|
381
|
+
if (bObserver) await bObserver[Symbol.asyncDispose]()
|
|
382
|
+
if (control?.isOpen) await control.quit()
|
|
383
|
+
if (s3 && bucket) {
|
|
384
|
+
try {
|
|
385
|
+
await s3.send(new DeleteBucketCommand({ Bucket: bucket }))
|
|
386
|
+
} catch {
|
|
387
|
+
// Bucket may still hold sweeper-orphaned objects; best-effort.
|
|
388
|
+
}
|
|
389
|
+
s3.destroy()
|
|
390
|
+
}
|
|
391
|
+
await cleanupRedis(runId)
|
|
392
|
+
}, 40_000)
|
|
393
|
+
|
|
394
|
+
it('runs each task only on the submitting service, and B never sees A tasks', async () => {
|
|
395
|
+
const aTasks = await Promise.all(
|
|
396
|
+
Array.from({ length: 6 }, () => aPods[0].runner.submit({ type: 'echo', payload: {}, handlerVersion: 1 })),
|
|
397
|
+
)
|
|
398
|
+
const bTasks = await Promise.all(
|
|
399
|
+
Array.from({ length: 6 }, () => bPods[0].runner.submit({ type: 'echo', payload: {}, handlerVersion: 1 })),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
for (const task of aTasks) expect(await pollStatus(aPods[0].runner, task.id, 20_000)).toBe('succeeded')
|
|
403
|
+
for (const task of bTasks) expect(await pollStatus(bPods[0].runner, task.id, 20_000)).toBe('succeeded')
|
|
404
|
+
|
|
405
|
+
for (const task of aTasks) {
|
|
406
|
+
const ranBy = await control.get(`${aPrefixes.count}ranBy:${task.id}`)
|
|
407
|
+
expect(['a0', 'a1']).toContain(ranBy)
|
|
408
|
+
// Cross-service: service B's store has no record of an A-submitted task.
|
|
409
|
+
expect(await bPods[0].runner.get(task.id)).toBeUndefined()
|
|
410
|
+
}
|
|
411
|
+
for (const task of bTasks) {
|
|
412
|
+
const ranBy = await control.get(`${bPrefixes.count}ranBy:${task.id}`)
|
|
413
|
+
expect(['b0', 'b1']).toContain(ranBy)
|
|
414
|
+
expect(await aPods[0].runner.get(task.id)).toBeUndefined()
|
|
415
|
+
}
|
|
416
|
+
}, 60_000)
|
|
417
|
+
|
|
418
|
+
it('enforces a fleet-wide concurrency cap across a service\u2019s pods', async () => {
|
|
419
|
+
await control.del(`${aPrefixes.count}max`)
|
|
420
|
+
await control.del(`${aPrefixes.count}live`)
|
|
421
|
+
|
|
422
|
+
const tasks = await Promise.all(
|
|
423
|
+
Array.from({ length: 6 }, () => aPods[0].runner.submit({ type: 'capped', payload: {}, handlerVersion: 1 })),
|
|
424
|
+
)
|
|
425
|
+
for (const task of tasks) expect(await pollStatus(aPods[0].runner, task.id, 25_000)).toBe('succeeded')
|
|
426
|
+
|
|
427
|
+
const maxConcurrent = num(await control.get(`${aPrefixes.count}max`))
|
|
428
|
+
expect(maxConcurrent).toBeLessThanOrEqual(2)
|
|
429
|
+
expect(maxConcurrent).toBe(2)
|
|
430
|
+
}, 60_000)
|
|
431
|
+
|
|
432
|
+
it('round-trips an S3-backed produced blob and measures \u00a74 metrics', async () => {
|
|
433
|
+
await aObserver.bus.whenReady(`tasks/progress/blobbed`)
|
|
434
|
+
const progressLags: number[] = []
|
|
435
|
+
using _sub = aObserver.bus.subscribe(`tasks/progress/blobbed`, (msg: BusMessage) => {
|
|
436
|
+
const payload = msg.payload as TaskUpdate
|
|
437
|
+
if (payload.kind !== 'progress') return
|
|
438
|
+
const sentAt = (payload.meta as { sentAt?: number } | undefined)?.sentAt
|
|
439
|
+
if (typeof sentAt === 'number') progressLags.push(Date.now() - sentAt)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
const SAMPLES = 24
|
|
443
|
+
const submitTimes = new Map<string, number>()
|
|
444
|
+
const blobbedTasks = await Promise.all(
|
|
445
|
+
Array.from({ length: SAMPLES }, async () => {
|
|
446
|
+
const task = await aPods[0].runner.submit({ type: 'blobbed', payload: {}, handlerVersion: 1 })
|
|
447
|
+
submitTimes.set(task.id, Date.now())
|
|
448
|
+
return task
|
|
449
|
+
}),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
for (const task of blobbedTasks) expect(await pollStatus(aPods[0].runner, task.id, 30_000)).toBe('succeeded')
|
|
453
|
+
|
|
454
|
+
// S3 round-trip: read a produced blob straight from the service's store.
|
|
455
|
+
const sample = blobbedTasks[0]
|
|
456
|
+
const finished = await aPods[0].runner.get(sample.id)
|
|
457
|
+
const producedKey = (finished?.result as { key: string }).key
|
|
458
|
+
const blobStore = aPods[1].injector.get(BlobStore)
|
|
459
|
+
const got = await blobStore.get(producedKey)
|
|
460
|
+
const body = await new Response(got.stream).text()
|
|
461
|
+
expect(body).toBe(`blob-${sample.id}`)
|
|
462
|
+
|
|
463
|
+
// §4 metric: submit → claim (proxied by submit → handler entry).
|
|
464
|
+
const claimLags: number[] = []
|
|
465
|
+
for (const task of blobbedTasks) {
|
|
466
|
+
const startedAt = num(await control.get(`${aPrefixes.count}start:${task.id}`))
|
|
467
|
+
const submittedAt = submitTimes.get(task.id)
|
|
468
|
+
if (startedAt > 0 && submittedAt !== undefined) claimLags.push(startedAt - submittedAt)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Wait briefly for any in-flight progress frames to land on the observer.
|
|
472
|
+
await waitFor(async () => Promise.resolve(progressLags.length >= 1), 5_000)
|
|
473
|
+
|
|
474
|
+
const claimP95 = percentile(claimLags, 95)
|
|
475
|
+
const progressP95 = percentile(progressLags, 95)
|
|
476
|
+
console.log(
|
|
477
|
+
`[M6 §4 metrics] submit→claim: p50=${percentile(claimLags, 50)}ms p95=${claimP95}ms (target <50ms) | ` +
|
|
478
|
+
`progress delivery: p50=${percentile(progressLags, 50)}ms p95=${progressP95}ms (target <100ms) | ` +
|
|
479
|
+
`samples claim=${claimLags.length} progress=${progressLags.length}`,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
expect(claimLags.length).toBeGreaterThan(0)
|
|
483
|
+
expect(progressLags.length).toBeGreaterThan(0)
|
|
484
|
+
expect(claimP95).toBeLessThan(SUBMIT_TO_CLAIM_BUDGET_MS)
|
|
485
|
+
expect(progressP95).toBeLessThan(PROGRESS_DELIVERY_BUDGET_MS)
|
|
486
|
+
}, 60_000)
|
|
487
|
+
|
|
488
|
+
it('does not lose work when a pod drains mid-flight', async () => {
|
|
489
|
+
await control.del(`${aPrefixes.count}slow:started`)
|
|
490
|
+
await control.del(`${aPrefixes.count}slow:done`)
|
|
491
|
+
|
|
492
|
+
const inFlight = await aPods[0].runner.submit({ type: 'slow', payload: {}, handlerVersion: 1 })
|
|
493
|
+
await waitFor(async () => num(await control.get(`${aPrefixes.count}slow:started`)) >= 1, 15_000)
|
|
494
|
+
const ranBy = await control.get(`${aPrefixes.count}slowBy:${inFlight.id}`)
|
|
495
|
+
const drainingPod = aPods.find((p) => p.podId === ranBy) ?? aPods[0]
|
|
496
|
+
|
|
497
|
+
// Drain the pod holding the in-flight task; submit a second task that the
|
|
498
|
+
// sibling pod must pick up.
|
|
499
|
+
const drained = drainingPod.drain()
|
|
500
|
+
const queued = await aPods[0].runner.submit({ type: 'slow', payload: {}, handlerVersion: 1 })
|
|
501
|
+
await drained
|
|
502
|
+
|
|
503
|
+
// The in-flight task completed despite the drain.
|
|
504
|
+
expect((await aPods[0].runner.get(inFlight.id))?.status).toBe('succeeded')
|
|
505
|
+
|
|
506
|
+
// The queued task is picked up by the other pod and finishes.
|
|
507
|
+
expect(await pollStatus(aPods[0].runner, queued.id, 25_000)).toBe('succeeded')
|
|
508
|
+
const queuedRanBy = await control.get(`${aPrefixes.count}slowBy:${queued.id}`)
|
|
509
|
+
expect(queuedRanBy).not.toBe(drainingPod.podId)
|
|
510
|
+
}, 60_000)
|
|
511
|
+
})
|