|
2 | 2 | * @vitest-environment node |
3 | 3 | */ |
4 | 4 | import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing' |
| 5 | +import { sleep } from '@sim/utils/helpers' |
5 | 6 | import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' |
6 | 7 | import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' |
7 | 8 | import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' |
@@ -368,6 +369,259 @@ describe('execution event buffer', () => { |
368 | 369 | expect(persistedEntries).toEqual([]) |
369 | 370 | }) |
370 | 371 |
|
| 372 | + /** |
| 373 | + * Requeueing a batch the budget rejected is what grew `pending` for a whole |
| 374 | + * run, each retry re-serializing an ever-larger array. Rejected bytes must be |
| 375 | + * dropped, not retained. |
| 376 | + */ |
| 377 | + it('drops rejected batches instead of growing a backlog when the Redis budget is exhausted', async () => { |
| 378 | + mockRedis.incrby.mockResolvedValue(100000) |
| 379 | + let budgetExhausted = true |
| 380 | + mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => { |
| 381 | + if (isFlushScript(script)) { |
| 382 | + if (budgetExhausted) return [0, 'execution_redis_bytes', 64 * 1024 * 1024] |
| 383 | + const { zaddArgs } = parseFlushEvalArgs(args) |
| 384 | + for (let i = 0; i < zaddArgs.length; i += 2) { |
| 385 | + persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry) |
| 386 | + } |
| 387 | + return [1, 1, 0] |
| 388 | + } |
| 389 | + return [1, 'ok', 0, 0] |
| 390 | + }) |
| 391 | + |
| 392 | + const writer = createExecutionEventWriter('exec-1') |
| 393 | + |
| 394 | + for (let i = 0; i < 2500; i++) { |
| 395 | + await writer.write(makeEvent(`block-${i}`)).catch(() => {}) |
| 396 | + } |
| 397 | + |
| 398 | + // Once the budget frees the writer recovers, but only whatever accumulated |
| 399 | + // since the last rejection — never a run-length backlog. |
| 400 | + budgetExhausted = false |
| 401 | + await writer.flush() |
| 402 | + |
| 403 | + expect(persistedEntries.length).toBeLessThanOrEqual(200) |
| 404 | + }) |
| 405 | + |
| 406 | + /** |
| 407 | + * Individual events are capped well below the single-write limit, but a burst |
| 408 | + * of large ones coalesces into a batch above it. Splitting is the only way the |
| 409 | + * buffer makes progress: no retry can shrink a batch it keeps whole. |
| 410 | + */ |
| 411 | + it('splits a batch that exceeds the single-write cap instead of stalling on it', async () => { |
| 412 | + mockRedis.incrby.mockResolvedValue(100) |
| 413 | + // Built from many modest fields rather than one huge one: compaction offloads |
| 414 | + // individual values over its threshold, so a single large string would leave a |
| 415 | + // tiny ref behind and never reach the batch cap. Each event stays under the |
| 416 | + // 8MiB per-event cap; two of them do not. |
| 417 | + const chunk = 'x'.repeat(100_000) |
| 418 | + const wideEvent = () => { |
| 419 | + const event = makeEvent('wide') |
| 420 | + const data = event.data as Record<string, unknown> |
| 421 | + for (let i = 0; i < 45; i++) data[`field${i}`] = chunk |
| 422 | + return event |
| 423 | + } |
| 424 | + |
| 425 | + const writer = createExecutionEventWriter('exec-1') |
| 426 | + await writer.write(wideEvent()) |
| 427 | + await writer.write(wideEvent()) |
| 428 | + await writer.flush() |
| 429 | + |
| 430 | + expect(persistedEntries).toHaveLength(2) |
| 431 | + expect( |
| 432 | + mockRedis.eval.mock.calls.filter(([script]) => isFlushScript(script as string)) |
| 433 | + ).toHaveLength(2) |
| 434 | + }) |
| 435 | + |
| 436 | + it('drops the terminal entry rather than leaving it queued when the budget is exhausted', async () => { |
| 437 | + mockRedis.incrby.mockResolvedValue(100) |
| 438 | + let budgetExhausted = true |
| 439 | + mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => { |
| 440 | + if (isFlushScript(script)) { |
| 441 | + if (budgetExhausted) return [0, 'execution_redis_bytes', 64 * 1024 * 1024] |
| 442 | + const { zaddArgs } = parseFlushEvalArgs(args) |
| 443 | + for (let i = 0; i < zaddArgs.length; i += 2) { |
| 444 | + persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry) |
| 445 | + } |
| 446 | + return [1, 1, 0] |
| 447 | + } |
| 448 | + return [1, 'ok', 0, 0] |
| 449 | + }) |
| 450 | + |
| 451 | + const writer = createExecutionEventWriter('exec-1') |
| 452 | + |
| 453 | + await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).rejects.toThrow( |
| 454 | + 'Execution memory limit exceeded' |
| 455 | + ) |
| 456 | + |
| 457 | + // The failed terminal write stays surfaced through flush(), but its entry must |
| 458 | + // not linger in the backlog and reappear once the budget frees up. |
| 459 | + budgetExhausted = false |
| 460 | + await writer.flush().catch(() => {}) |
| 461 | + |
| 462 | + expect(persistedEntries).toEqual([]) |
| 463 | + }) |
| 464 | + |
| 465 | + /** |
| 466 | + * A timer-driven flush carries no terminal status of its own. If it is the |
| 467 | + * loop that drains the final chunk, the terminal event lands without a status |
| 468 | + * and readers poll an `active` stream forever — while `writeTerminal` reports |
| 469 | + * success, so nothing degrades. |
| 470 | + */ |
| 471 | + it('applies terminal status even when a concurrent scheduled flush drains the final chunk', async () => { |
| 472 | + mockRedis.incrby.mockResolvedValue(100) |
| 473 | + const observedTerminalStatuses: string[] = [] |
| 474 | + let releaseFirstFlush: (() => void) | undefined |
| 475 | + const firstFlushStarted = new Promise<void>((resolveStarted) => { |
| 476 | + let started = false |
| 477 | + mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => { |
| 478 | + if (!isFlushScript(script)) return [1, 'ok', 0, 0] |
| 479 | + const { terminalStatus, zaddArgs } = parseFlushEvalArgs(args) |
| 480 | + observedTerminalStatuses.push(terminalStatus) |
| 481 | + if (!started) { |
| 482 | + started = true |
| 483 | + resolveStarted() |
| 484 | + await new Promise<void>((resolve) => { |
| 485 | + releaseFirstFlush = resolve |
| 486 | + }) |
| 487 | + } |
| 488 | + for (let i = 0; i < zaddArgs.length; i += 2) { |
| 489 | + persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry) |
| 490 | + } |
| 491 | + return [1, 1, 0] |
| 492 | + }) |
| 493 | + }) |
| 494 | + |
| 495 | + const writer = createExecutionEventWriter('exec-1') |
| 496 | + await writer.write(makeEvent('first')) |
| 497 | + await firstFlushStarted |
| 498 | + |
| 499 | + const terminalWrite = writer.writeTerminal(makeEvent('terminal'), 'complete') |
| 500 | + // Let writeTerminal's queued body actually enqueue its entry before the |
| 501 | + // in-flight flush resolves — otherwise the scheduled loop finds nothing left |
| 502 | + // to drain and the race under test never forms. |
| 503 | + await sleep(5) |
| 504 | + releaseFirstFlush?.() |
| 505 | + await terminalWrite |
| 506 | + |
| 507 | + expect(observedTerminalStatuses).toContain('complete') |
| 508 | + }) |
| 509 | + |
| 510 | + /** |
| 511 | + * The backlog ahead of a terminal event can exceed the budget while the |
| 512 | + * terminal event itself still fits. Discarding it alongside the backlog would |
| 513 | + * leave readers without the final status for a run that could have published |
| 514 | + * one. |
| 515 | + */ |
| 516 | + it('still publishes the terminal event when the backlog ahead of it is dropped', async () => { |
| 517 | + mockRedis.incrby.mockResolvedValue(100) |
| 518 | + const observedTerminalStatuses: string[] = [] |
| 519 | + mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => { |
| 520 | + if (!isFlushScript(script)) return [1, 'ok', 0, 0] |
| 521 | + const { terminalStatus, zaddArgs } = parseFlushEvalArgs(args) |
| 522 | + // Reject anything but a lone entry, standing in for a budget with only |
| 523 | + // enough headroom left for one small write. |
| 524 | + if (zaddArgs.length > 2) return [0, 'execution_redis_bytes', 64 * 1024 * 1024] |
| 525 | + observedTerminalStatuses.push(terminalStatus) |
| 526 | + for (let i = 0; i < zaddArgs.length; i += 2) { |
| 527 | + persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry) |
| 528 | + } |
| 529 | + return [1, 1, 0] |
| 530 | + }) |
| 531 | + |
| 532 | + const writer = createExecutionEventWriter('exec-1') |
| 533 | + for (let i = 0; i < 5; i++) { |
| 534 | + await writer.write(makeEvent(`block-${i}`)).catch(() => {}) |
| 535 | + } |
| 536 | + |
| 537 | + await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).resolves.toMatchObject({ |
| 538 | + executionId: 'exec-1', |
| 539 | + }) |
| 540 | + expect(observedTerminalStatuses).toContain('complete') |
| 541 | + expect( |
| 542 | + persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId) |
| 543 | + ).toContain('terminal') |
| 544 | + }) |
| 545 | + |
| 546 | + /** |
| 547 | + * A terminal publish that threw must not be resurrected. Leaving the status |
| 548 | + * armed would let the next flush stamp the stream terminal for an event that |
| 549 | + * was discarded — telling readers the run ended cleanly while the caller was |
| 550 | + * told it failed. |
| 551 | + */ |
| 552 | + it('does not stamp terminal status on a later flush after the terminal publish failed', async () => { |
| 553 | + mockRedis.incrby.mockResolvedValue(100) |
| 554 | + const observedTerminalStatuses: string[] = [] |
| 555 | + let failNextFlush = false |
| 556 | + mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => { |
| 557 | + if (!isFlushScript(script)) return [1, 'ok', 0, 0] |
| 558 | + if (failNextFlush) throw new Error('redis unavailable') |
| 559 | + const { terminalStatus, zaddArgs } = parseFlushEvalArgs(args) |
| 560 | + observedTerminalStatuses.push(terminalStatus) |
| 561 | + for (let i = 0; i < zaddArgs.length; i += 2) { |
| 562 | + persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry) |
| 563 | + } |
| 564 | + return [1, 1, 0] |
| 565 | + }) |
| 566 | + |
| 567 | + const writer = createExecutionEventWriter('exec-1') |
| 568 | + await writer.write(makeEvent('a')) |
| 569 | + |
| 570 | + failNextFlush = true |
| 571 | + await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).rejects.toThrow() |
| 572 | + |
| 573 | + // flush() still surfaces the earlier terminal failure; what matters is that |
| 574 | + // the events it drains are not stamped terminal. |
| 575 | + failNextFlush = false |
| 576 | + await writer.flush().catch(() => {}) |
| 577 | + |
| 578 | + expect(observedTerminalStatuses).toEqual(['']) |
| 579 | + expect( |
| 580 | + persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId) |
| 581 | + ).toEqual(['a']) |
| 582 | + }) |
| 583 | + |
| 584 | + /** |
| 585 | + * A budget rejection must not colour a later, unrelated failure: reporting a |
| 586 | + * Redis outage as "reduce payload size" sends the user after the wrong thing. |
| 587 | + */ |
| 588 | + it('reports the generic failure, not a stale budget rejection, on the terminal path', async () => { |
| 589 | + mockRedis.incrby.mockResolvedValue(100) |
| 590 | + let mode: 'budget' | 'outage' = 'budget' |
| 591 | + mockRedis.eval.mockImplementation(async (script: string) => { |
| 592 | + if (!isFlushScript(script)) return [1, 'ok', 0, 0] |
| 593 | + if (mode === 'budget') return [0, 'execution_redis_bytes', 64 * 1024 * 1024] |
| 594 | + throw new Error('redis unavailable') |
| 595 | + }) |
| 596 | + |
| 597 | + const writer = createExecutionEventWriter('exec-1') |
| 598 | + for (let i = 0; i < 200; i++) { |
| 599 | + await writer.write(makeEvent(`block-${i}`)).catch(() => {}) |
| 600 | + } |
| 601 | + |
| 602 | + mode = 'outage' |
| 603 | + await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).rejects.toThrow( |
| 604 | + 'Failed to flush terminal execution event' |
| 605 | + ) |
| 606 | + }) |
| 607 | + |
| 608 | + it('settles a scheduled flush that hits the budget instead of rejecting later callers', async () => { |
| 609 | + mockRedis.incrby.mockResolvedValue(100) |
| 610 | + mockRedis.eval.mockImplementation(async (script: string) => { |
| 611 | + if (isFlushScript(script)) { |
| 612 | + return [0, 'execution_redis_bytes', 64 * 1024 * 1024] |
| 613 | + } |
| 614 | + return [1, 'ok', 0, 0] |
| 615 | + }) |
| 616 | + |
| 617 | + const writer = createExecutionEventWriter('exec-1') |
| 618 | + await writer.write(makeEvent('a')) |
| 619 | + |
| 620 | + await sleep(60) |
| 621 | + |
| 622 | + await expect(writer.flush()).resolves.toBeUndefined() |
| 623 | + }) |
| 624 | + |
371 | 625 | it('preserves requested UserFile base64 when buffering terminal events', async () => { |
372 | 626 | mockRedis.incrby.mockResolvedValue(100) |
373 | 627 | const base64 = Buffer.from('hello').toString('base64') |
|
0 commit comments