Skip to content

PersistedQueue hardening and optimization pass - #7547

Merged
tim-smart merged 15 commits into
mainfrom
agent/architect/f0e3f5086ee6
Sep 1, 2026
Merged

PersistedQueue hardening and optimization pass#7547
tim-smart merged 15 commits into
mainfrom
agent/architect/f0e3f5086ee6

Conversation

@tim-smart

@tim-smart tim-smart commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Hardening and optimization pass over PersistedQueue, covering the SQL, Redis, and memory stores plus the factory. SQL schema changes are applied by 0002_upgrade_schema, which upgrades existing 0001_create_table data in place.

Closes EFF-980

Semantics

  • Attempts count on claim, not on outcome. The claim bumps attempts, interrupt-release decrements it back, and the failure path no longer bumps. Handler metadata is now 1-based. A handler that crashes the process on its final attempt is flipped to failed by a periodic pass over exhausted, lock-expired pending rows (mirrored in the Redis reset script).
  • Retry backoff. New visible_at column set from a retry schedule. The poll filters visible_at <= now and orders by visible_at, sequence. Redis uses a delayed zset swept by the take script; memory delays re-eligibility.
  • Failed state. completed BOOLEAN becomes state (pending / completed / failed). Exhausted rows are marked failed instead of becoming invisible, matching the Redis :failed destination. Inspect/requeue APIs are a later pass.
  • Decode failures dead-letter immediately. A schema decode (or JSON parse) failure marks the row failed with the raw element preserved and the cause in last_failure, then takes the next item. Decode errors leave take's error channel.
  • Config moves to make(). maxAttempts and retrySchedule are queue-definition options; take() loses its options. This also removes maxAttempts from the mailbox key.
  • Patient acks. complete/retry retry with backoff up to the lock expiration window (the refresh fiber holds ownership meanwhile) instead of dying after ~1s.

Retention and cleanup

Completed rows are kept for de-duplication (DurableQueue replays re-offer the same id) and pruned by a new PersistedQueueStore.cleanup plus PersistedQueue.layerCleanup({ interval, timeToLive, failedTimeToLive }). Defaults: 30 days for completed; failed rows are the dead-letter record and exempt unless failedTimeToLive is set. SQL batch-deletes with small LIMITs, Redis trims the ids zset and failed lists, memory prunes its entries.

Bug fixes

  • id/queue_name widened to 255 (DurableQueue ids like ${queueName}/${idempotencyKey} overflowed VARCHAR(36)).
  • sequence is 64-bit on all dialects (was int32 on pg and mssql).
  • MySQL element/last_failure are MEDIUMTEXT (64KB cap before).
  • Memory store take race: two takers passing the latch together no longer crash on an empty set.
  • Redis :ids dedup set no longer grows forever (timestamp-scored zset trimmed by cleanup).
  • mssql: GETDATE()SYSDATETIME() (1/300s rounding could put fresh visible_at values in the future, hiding them from the poll), and two mssql migration statements had invalid identifier interpolation.

Optimizations

  • offer nudges the local poll latch, so same-process work skips the up-to-pollInterval wait. No LISTEN/NOTIFY this pass.
  • Partial index on pending rows (pg/sqlite/mssql) so pollers stop scanning completed rows.

Docs

At-least-once delivery (idempotent handlers) is now stated on the module and take, and DurableQueue documents that a dead-lettered item parks its workflow until the future requeue API, plus the single-cleanup-instance recommendation.

Testing

Shared queue behavior runs against memory, SQLite, PostgreSQL, MySQL, MSSQL, and Redis. Migration coverage exercises fresh installs and upgrades from the original 0001 on every SQL backend, including preservation of pending and completed rows, de-duplication, widened identifiers, and payloads over 64 KB. pnpm check and pnpm lint pass.

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7eca51

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added bug Something isn't working 4.0 labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 33.76 KB 33.76 KB 0.00 KB (0.00%)
basic.ts 7.00 KB 7.00 KB 0.00 KB (0.00%)
batching.ts 9.87 KB 9.87 KB 0.00 KB (0.00%)
brand.ts 6.56 KB 6.56 KB 0.00 KB (0.00%)
cache.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
config.ts 21.45 KB 21.45 KB 0.00 KB (0.00%)
differ.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
http-client.ts 21.77 KB 21.77 KB 0.00 KB (0.00%)
logger.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
pubsub.ts 15.12 KB 15.12 KB 0.00 KB (0.00%)
queue.ts 11.84 KB 11.84 KB 0.00 KB (0.00%)
schedule.ts 10.86 KB 10.86 KB 0.00 KB (0.00%)
schema-binary.ts 39.42 KB 39.42 KB 0.00 KB (0.00%)
schema-class.ts 20.07 KB 20.07 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 31.02 KB 31.02 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.21 KB 26.21 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.66 KB 13.66 KB 0.00 KB (0.00%)
schema-string.ts 11.18 KB 11.18 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.47 KB 15.47 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.31 KB 33.31 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.51 KB 24.51 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.29 KB 19.29 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.42 KB 19.42 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.51 KB 19.51 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.45 KB 23.45 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.56 KB 19.56 KB 0.00 KB (0.00%)
schema.ts 19.28 KB 19.28 KB 0.00 KB (0.00%)
stm.ts 12.74 KB 12.74 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

Comment thread packages/effect/src/unstable/persistence/PersistedQueue.ts Outdated
- Redis: park pending dedupe ids at +inf so timeToLive trims only completed
  entries, matching SQL/memory (with shared suite coverage)
- Redis: trimFailed pops corrupt heads instead of blocking the whole list
- Unify the unreachable configs maxAttempts fallback across stores
- Fix takers counter leak when a waiting take is interrupted (onExit)
- mssql: atomic MERGE WITH (HOLDLOCK) offer instead of racy IF NOT EXISTS
- Memory store: keep a pending subset so take stops scanning the
  completed backlog (was O(all entries ever offered) per take)
- Merge the configs/nudges maps and fallback constant into one per-queue
  state record shared by offer/take/mailbox in both stores
- One ack retry schedule and one SQL ack pipeline instead of six copies
- Collapse secondsAgo/secondsFromNow into a single signed offset helper
- Factory decode without the Exit round-trip; drop unused Exit/Arr imports
- Redis: compute queue keys once per take, reuse the raw wire payload for
  requeue/retry, drain the failed-list trim, bounded-concurrent cleanup,
  poller failure logging parity with SQL
- SQL: stop selecting the unused queue_name column, mutate polled rows in
  place instead of cloning (twice on mysql)
- Tests: assertNotDelivered/advancePastTtl helpers, per-test timeout
  derived from the suite timeout instead of 12 magic literals
Comment thread packages/effect/src/unstable/persistence/PersistedQueue.ts Outdated
Comment thread packages/effect/src/unstable/persistence/PersistedQueue.ts Outdated
The persisted attempt count is the schedule state: retryDelay replays a
fresh schedule step up to the current attempt on every call, so delays
keep progressing across takers and process restarts. Document that on
make() and in the implementation, stop stepping a schedule that reported
done, and add a memory-store test that fails if delays reset between
retries.
@tim-smart
tim-smart force-pushed the agent/architect/f0e3f5086ee6 branch from 3dd795a to c5b2cee Compare September 1, 2026 01:05
Comment thread packages/effect/test/unstable/persistence/PersistedQueueTest.ts Outdated
Comment thread packages/effect/src/unstable/persistence/PersistedQueue.ts Outdated
- Shared suite: concurrency soak test — 24 elements, 3 workers, forced
  first-delivery failures, asserting exactly-once success and exact
  delivery counts on every backend
- pg: exactly-once across two store instances with four workers; crashed
  worker recovery after lock expiration preserving the attempt count;
  reaper dead-lettering a row whose final attempt crashed
- Redis: crashed worker recovery via the reset pass preserving the
  attempt count; crash on the final attempt landing in the failed list
  with the lock-expired reason
- mysql: 200KB payload round-trip through the MEDIUMTEXT column

Also fixes a shared-suite flake: advancePastTtl jumped the virtual clock
by 2 minutes, which could fire the SQL client pool timers and time out
in-flight connection acquisitions during cleanup. The jump only needs to
outrun a 1 second virtual ttl, so it is now 2 seconds.
@tim-smart
tim-smart merged commit 9b517ad into main Sep 1, 2026
18 checks passed
@tim-smart
tim-smart deleted the agent/architect/f0e3f5086ee6 branch September 1, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant