From 4319d689cfe5ecef1925d2626cd0d189880a81a7 Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Wed, 19 Aug 2026 22:38:14 -0300 Subject: [PATCH 1/8] docs: improve public API TSDoc --- src/NodeRedisPool.ts | 121 ++++++++++++++++++++- src/Observability.ts | 78 +++++++++++++ src/RedisPool.ts | 84 +++++++++++++- src/Scheduler.ts | 90 ++++++++++++++- src/StorageProtocol.ts | 131 +++++++++++++++++++++- src/Task.ts | 95 +++++++++++++--- src/TaskEngine.ts | 159 +++++++++++++++++++++++++-- src/TaskQueue.ts | 241 +++++++++++++++++++++++++++++++++++++++-- src/Worker.ts | 61 ++++++++++- src/index.ts | 66 +++++++++++ 10 files changed, 1067 insertions(+), 59 deletions(-) diff --git a/src/NodeRedisPool.ts b/src/NodeRedisPool.ts index b2d698e..1cce94d 100644 --- a/src/NodeRedisPool.ts +++ b/src/NodeRedisPool.ts @@ -25,16 +25,38 @@ import { RedisPool, } from "./RedisPool.js"; +/** + * node-redis connection options accepted by a standalone EffectMQ pool. + * + * Both RESP2 and RESP3 are covered by the compatibility suite. + * + * @category Configuration + * @since 0.2.0 + */ export type RedisPoolOptions = Omit< RedisClientOptions, "clientSideCache" | "RESP" > & { - /** RESP2 and RESP3 are both covered by the compatibility suite. */ readonly RESP?: 2 | 3; }; +/** + * The workload assigned to one isolated Redis connection service. + * + * @category Models + * @since 0.3.0 + */ export type RedisRole = "producer" | "worker" | "maintenance"; -/** Explicitly bounded node-redis pool settings. */ +/** + * Bounds a standalone node-redis connection pool. + * + * Defaults are one minimum connection, 100 maximum connections, and 3-second + * acquire and cleanup delays. Invalid or unbounded settings fail when the + * layer is built. + * + * @category Configuration + * @since 0.3.0 + */ export interface BoundedPoolOptions { readonly minimum?: number; readonly maximum?: number; @@ -42,28 +64,58 @@ export interface BoundedPoolOptions { readonly cleanupDelay?: number; } -/** Standalone configuration. Legacy node-redis options remain source-compatible. */ +/** + * Configures standalone Redis while retaining node-redis option compatibility. + * + * @category Configuration + * @since 0.3.0 + */ export type StandaloneRedisConfig = RedisPoolOptions & { readonly topology?: "standalone"; readonly pool?: BoundedPoolOptions; }; -/** Sentinel discovers and reconnects to the current writable primary. */ +/** + * Configures Sentinel discovery of the current writable Redis primary. + * + * @category Configuration + * @since 0.3.0 + */ export interface SentinelRedisConfig { readonly topology: "sentinel"; readonly sentinel: RedisSentinelOptions; } -/** Accepted only so unsupported production configuration fails as typed data. */ +/** + * Represents Redis Cluster so it can be rejected as a typed configuration error. + * + * EffectMQ scripts operate atomically across multiple keys, which Redis + * Cluster cannot guarantee unless every key shares a hash slot. + * + * @category Configuration + * @since 0.3.0 + */ export interface ClusterRedisConfig { readonly topology: "cluster"; } +/** + * Redis topology configuration accepted by {@link layer}. + * + * @category Configuration + * @since 0.3.0 + */ export type RedisConfig = | StandaloneRedisConfig | SentinelRedisConfig | ClusterRedisConfig; +/** + * Indicates that Redis Cluster was configured or detected. + * + * @category Errors + * @since 0.3.0 + */ export class UnsupportedRedisTopology extends Data.TaggedError( "UnsupportedRedisTopology", )<{ @@ -71,10 +123,22 @@ export class UnsupportedRedisTopology extends Data.TaggedError( readonly reason: string; }> {} +/** + * Indicates that bounded pool settings are inconsistent or outside safe limits. + * + * @category Errors + * @since 0.3.0 + */ export class InvalidRedisConfiguration extends Data.TaggedError( "InvalidRedisConfiguration", )<{ readonly reason: string }> {} +/** + * Passive connection and command health recorded for one Redis role. + * + * @category Models + * @since 0.3.0 + */ export interface RedisRoleHealth { readonly state: "disconnected" | "connecting" | "ready" | "degraded"; readonly commandErrors: number; @@ -82,18 +146,38 @@ export interface RedisRoleHealth { readonly lastChangeAt: number; } +/** + * A secret-free snapshot of all EffectMQ Redis connection roles. + * + * @category Models + * @since 0.3.0 + */ export interface RedisHealthSnapshot { readonly topology: "standalone" | "sentinel"; readonly ready: boolean; readonly roles: Readonly>; } +/** + * Exposes passive connection state and an active readiness probe. + * + * `snapshot` reads locally recorded state. `readiness` sends `PING` through all + * three role services and succeeds with `false` when any probe fails. + * + * @category Services + * @since 0.3.0 + */ export interface RedisConnectionHealthService { readonly snapshot: Effect.Effect; readonly readiness: Effect.Effect; } -/** Secret-free passive readiness and command-health state. */ +/** + * Effect service tag for Redis connection health and readiness. + * + * @category Services + * @since 0.3.0 + */ export class RedisConnectionHealth extends Context.Service< RedisConnectionHealth, RedisConnectionHealthService @@ -370,6 +454,31 @@ const make = Effect.fnUntraced(function* (config: RedisConfig = {}) { ); }); +/** + * Creates scoped Redis services for EffectMQ. + * + * The layer establishes independent producer, worker, and maintenance + * connections before exposing any service. All connections close with the + * layer scope. Standalone Redis and Sentinel are supported; Cluster fails with + * {@link UnsupportedRedisTopology}. + * + * **Example: Provide a standalone Redis connection** + * + * ```ts + * import { Effect } from "effect" + * import { NodeRedisPool, RedisPool } from "@effectmq/core" + * + * const program = Effect.gen(function* () { + * const redis = yield* RedisPool.RedisPool + * return yield* redis.send("PING") + * }).pipe( + * Effect.provide(NodeRedisPool.layer({ url: "redis://127.0.0.1:6379" })) + * ) + * ``` + * + * @category Layers + * @since 0.2.0 + */ export const layer = ( config: RedisConfig = {}, ): Layer.Layer< diff --git a/src/Observability.ts b/src/Observability.ts index a78c0cc..0be5609 100644 --- a/src/Observability.ts +++ b/src/Observability.ts @@ -1,46 +1,118 @@ /** Effect metrics emitted by EffectMQ's Redis and queue runtime. @module */ import { Effect, Metric } from "effect"; +/** + * Gauge of runnable, delayed, and leased tasks, attributed by queue. + * + * @category Metrics + * @since 0.3.0 + */ export const queueDepth = Metric.gauge("effectmq_queue_depth", { description: "Runnable, delayed, and leased tasks in a queue", }); +/** + * Gauge of the oldest retained task record's age in milliseconds. + * + * @category Metrics + * @since 0.3.0 + */ export const oldestTaskAgeMs = Metric.gauge("effectmq_oldest_task_age_ms", { description: "Age of the oldest retained task record", }); +/** + * Gauge of the oldest due maintenance item's age in milliseconds. + * + * @category Metrics + * @since 0.3.0 + */ export const maintenanceSweepLagMs = Metric.gauge( "effectmq_maintenance_sweep_lag_ms", { description: "Age of the oldest due maintenance item" }, ); +/** + * Gauge of delayed tasks currently due for promotion. + * + * @category Metrics + * @since 0.3.0 + */ export const dueBacklog = Metric.gauge("effectmq_due_backlog", { description: "Delayed tasks currently due for promotion", }); +/** + * Gauge of expired leases awaiting a maintenance recovery pass. + * + * @category Metrics + * @since 0.3.0 + */ export const expiredLeaseBacklog = Metric.gauge( "effectmq_expired_lease_backlog", { description: "Expired leases still awaiting recovery" }, ); +/** + * Gauge of due retention expirations across queue-owned resources. + * + * @category Metrics + * @since 0.3.0 + */ export const retentionBacklog = Metric.gauge("effectmq_retention_backlog", { description: "Due task, result, terminal-index, and dead-letter expirations", }); +/** + * Counter of Redis command and connection errors observed by EffectMQ. + * + * @category Metrics + * @since 0.3.0 + */ export const redisErrors = Metric.counter("effectmq_redis_errors_total", { incremental: true, }); +/** + * Counter of Lua scripts reloaded after Redis reports `NOSCRIPT`. + * + * @category Metrics + * @since 0.3.0 + */ export const scriptReloads = Metric.counter("effectmq_script_reloads_total", { incremental: true, }); +/** + * Counter of reconnect attempts and Sentinel topology changes. + * + * @category Metrics + * @since 0.3.0 + */ export const redisReconnects = Metric.counter( "effectmq_redis_reconnects_total", { incremental: true }, ); +/** + * Counter of task attempts that lose their lease ownership. + * + * @category Metrics + * @since 0.3.0 + */ export const ownershipLosses = Metric.counter( "effectmq_ownership_losses_total", { incremental: true }, ); +/** + * Counter of maintenance sweeps that fail before reporting queue health. + * + * @category Metrics + * @since 0.3.0 + */ export const retentionFailures = Metric.counter( "effectmq_retention_failures_total", { incremental: true }, ); +/** + * Queue health values returned by one bounded maintenance invocation. + * + * @category Models + * @since 0.3.0 + */ export interface QueueHealth { readonly depth: number; readonly oldestTaskAgeMs: number; @@ -57,6 +129,12 @@ const forQueue = ( queue: string, ) => Metric.withAttributes(metric, { queue }); +/** + * Records a maintenance health snapshot on queue-attributed gauges. + * + * @category Metrics + * @since 0.3.0 + */ export const recordQueueHealth = (queue: string, health: QueueHealth) => Effect.all([ Metric.update(forQueue(queueDepth, queue), health.depth), diff --git a/src/RedisPool.ts b/src/RedisPool.ts index 66d3bb3..2761a60 100644 --- a/src/RedisPool.ts +++ b/src/RedisPool.ts @@ -1,7 +1,7 @@ /** * The Redis service `TaskEngine` depends on: a minimal command and cached * script surface over a (possibly pooled) Redis connection. Provide it with - * {@link NodeRedisPool} or any custom implementation. + * `NodeRedisPool` or any custom implementation. * * @module */ @@ -9,13 +9,31 @@ import { Context, Effect, Metric, Ref } from "effect"; import type * as Redis from "effect/unstable/persistence/Redis"; import * as Observability from "./Observability.js"; +/** + * A text or binary argument accepted by the EffectMQ Redis boundary. + * + * @category Models + * @since 0.3.0 + */ export type RedisArgument = string | Uint8Array; +/** + * Sends one Redis command and preserves failures as Effect Redis errors. + * + * @category Models + * @since 0.3.0 + */ export type RedisSend = ( command: string, ...args: ReadonlyArray ) => Effect.Effect; +/** + * Controls key partitioning and reply decoding for a Lua script invocation. + * + * @category Configuration + * @since 0.3.0 + */ export interface RedisScriptOptions { /** Number of leading script arguments Redis should expose through `KEYS`. */ readonly numberOfKeys?: number; @@ -23,6 +41,19 @@ export interface RedisScriptOptions { readonly binaryReply?: boolean; } +/** + * The minimal Redis command and cached-script surface used by EffectMQ. + * + * **Details** + * + * `evalScript` loads exact Lua source with `SCRIPT LOAD`, caches its digest, + * invokes it with `EVALSHA`, and reloads once after a `NOSCRIPT` response. + * Binary arguments are supported by every operation; `sendBinary` and the + * `binaryReply` option additionally preserve binary replies. + * + * @category Services + * @since 0.3.0 + */ export interface RedisPoolService { readonly send: RedisSend; readonly sendBinary: RedisSend; @@ -40,23 +71,51 @@ export interface RedisPoolService { ) => Effect.Effect; } +/** + * Effect service tag for the producer-facing Redis command pool. + * + * Provide it with `NodeRedisPool.layer` or a custom service built by + * {@link make}. + * + * @category Services + * @since 0.2.0 + */ export class RedisPool extends Context.Service()( "effectmq/RedisPool", ) {} -/** Physically isolated command pools for producer, worker, and maintenance work. */ +/** + * Physically isolated Redis command services for each queue workload. + * + * Producer traffic cannot consume the connections reserved for worker + * acquisition or maintenance sweeps. + * + * @category Services + * @since 0.3.0 + */ export interface RedisConnectionRolesService { readonly producer: RedisPoolService; readonly worker: RedisPoolService; readonly maintenance: RedisPoolService; } +/** + * Effect service tag for producer, worker, and maintenance Redis roles. + * + * @category Services + * @since 0.3.0 + */ export class RedisConnectionRoles extends Context.Service< RedisConnectionRoles, RedisConnectionRolesService >()("effectmq/RedisConnectionRoles") {} -/** Build role routing, typically with three independently managed pools. */ +/** + * Creates role routing from three independently managed Redis services. + * + * @category Constructors + * @since 0.3.0 + */ export const makeConnectionRoles = ( producer: RedisPoolService, worker: RedisPoolService, @@ -66,7 +125,24 @@ export const makeConnectionRoles = ( const isNoScript = (error: Redis.RedisError) => String(error.cause).includes("NOSCRIPT"); -/** Build a RedisPool service from text and binary command senders. */ +/** + * Creates a {@link RedisPool} service from text and binary command senders. + * + * **When to use** + * + * Use this constructor when integrating a Redis client other than the bundled + * node-redis adapter. Most Node.js applications can provide + * `NodeRedisPool.layer` directly. + * + * **Gotchas** + * + * The two senders must share the same Redis server and command semantics. The + * binary sender must preserve bulk-string replies as `Uint8Array`-compatible + * values. + * + * @category Constructors + * @since 0.3.0 + */ export const make = Effect.fnUntraced(function* ( send: RedisSend, sendBinary: RedisSend, diff --git a/src/Scheduler.ts b/src/Scheduler.ts index 65c671a..c6bfe16 100644 --- a/src/Scheduler.ts +++ b/src/Scheduler.ts @@ -7,7 +7,16 @@ import * as TaskQueue from "./TaskQueue.js"; const TypeId = "~effectmq/Scheduler" as const; -/** The nominal interval represented by one durable scheduled task. */ +/** + * The nominal cron interval represented by one durable scheduled task. + * + * For a coalesced tick, `missedFrom` and `missedTo` describe the interval that + * one task represents. For ordinary and backfilled ticks, both equal + * `scheduledAt`. + * + * @category Models + * @since 0.3.0 + */ export interface Tick { readonly scheduleName: string; readonly scheduledAt: Date; @@ -15,11 +24,29 @@ export interface Tick { readonly missedTo: Date; } +/** + * Selects what a scheduler materializes after downtime. + * + * `skip` discards missed ticks, `coalesce` creates one task for the most recent + * missed tick, and `backfill` creates up to `maxBackfill` recent tasks. + * + * @category Configuration + * @since 0.3.0 + */ export type MissedTickPolicy = | { readonly _tag: "skip" } | { readonly _tag: "coalesce" } | { readonly _tag: "backfill"; readonly maxBackfill: number }; +/** + * Configures durable cron tick materialization into a task queue. + * + * `name` identifies the durable schedule cursor and should remain stable. + * Generated task identifiers combine that name with the nominal tick time. + * + * @category Configuration + * @since 0.3.0 + */ export interface SchedulerConfig< Payload extends Schema.Top, Success extends Schema.Top, @@ -48,8 +75,19 @@ type SchedulerFailure = | Schema.SchemaError; /** - * A scheduler only materializes deterministic queue tasks. Execution belongs - * to a normal managed worker and therefore has at-least-once semantics. + * A long-running Effect that materializes deterministic cron tasks. + * + * A scheduler persists its cursor in Redis and offers each selected tick before + * advancing it. Competing schedulers and crash recovery can therefore re-offer + * the same deterministic task identity without creating duplicate generations. + * + * **Gotchas** + * + * The scheduler does not execute tasks. A managed worker processes them with + * normal queue leases, retries, and at-least-once delivery. + * + * @category Models + * @since 0.1.0 */ export interface Scheduler extends Effect.Effect< @@ -83,7 +121,17 @@ const recentBackfill = ( return ticks.reverse(); }; -/** Materialize all work selected by one bounded scheduler observation. */ +/** + * Materializes the work selected by one bounded scheduler observation. + * + * This operation is useful for deterministic tests and custom scheduler loops. + * It initializes or reads the durable schedule cursor, applies the missed-tick + * policy, offers deterministic tasks, and advances the cursor only after every + * selected offer succeeds. + * + * @category Operations + * @since 0.3.0 + */ export const materializeDue = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, @@ -149,7 +197,39 @@ export const materializeDue = Effect.fnUntraced(function* < return consumed.next ?? nextFuture; }); -/** Create a long-running durable tick materializer. */ +/** + * Creates a long-running durable tick materializer. + * + * Run the returned value as an Effect alongside a `Worker.Worker`. It + * sleeps until the next cron tick, with a minimum polling delay of 100 ms, and + * repeats indefinitely. + * + * **Example: Materialize a coalesced daily task** + * + * ```ts + * import { Cron, Schema } from "effect" + * import { Scheduler, Task, TaskQueue } from "@effectmq/core" + * + * const report = Task.make({ + * name: "report", + * payload: { scheduledAt: Schema.String }, + * success: Schema.Void, + * error: Schema.String + * }) + * const reports = TaskQueue.make("reports", report) + * + * const daily = Scheduler.make({ + * name: "daily-report", + * cron: Cron.parseUnsafe("0 2 * * *", "UTC"), + * queue: reports, + * payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }), + * missed: { _tag: "coalesce" } + * }) + * ``` + * + * @category Constructors + * @since 0.1.0 + */ export const make = < Payload extends Schema.Top, Success extends Schema.Top, diff --git a/src/StorageProtocol.ts b/src/StorageProtocol.ts index 3338a9d..cbce9f4 100644 --- a/src/StorageProtocol.ts +++ b/src/StorageProtocol.ts @@ -2,17 +2,53 @@ import { Data, Effect } from "effect"; import { Packr } from "msgpackr"; +/** + * The storage protocol version written by this release. + * + * @category Protocol + * @since 0.3.0 + */ export const protocolVersion = 1 as const; +/** + * Storage protocol versions this release can decode. + * + * @category Protocol + * @since 0.3.0 + */ export const readableProtocolVersions = [1] as const; +/** + * The only storage protocol version this release writes. + * + * @category Protocol + * @since 0.3.0 + */ export const writableProtocolVersion = 1 as const; +/** + * Stable tags reserved for failures created by the queue runtime. + * + * @category Protocol + * @since 0.3.0 + */ export const builtInErrorTags = { stalled: "~effectmq/Error/Stalled", canceled: "~effectmq/Error/Canceled", } as const; +/** + * Identifies the semantic value carried by a storage envelope. + * + * @category Protocol + * @since 0.3.0 + */ export type ValueKind = "payload" | "success" | "failure"; +/** + * Bounds persisted values and queue-owned collections. + * + * @category Configuration + * @since 0.3.0 + */ export interface StorageLimits { readonly maxValueBytes: number; readonly maxErrorEntries: number; @@ -20,6 +56,15 @@ export interface StorageLimits { readonly maxEventEntries: number; } +/** + * Production defaults for storage bytes, error history, relationships, and events. + * + * Values are limited to 1 MiB, error history to 100 entries, retention + * relationships to 1,000, and event history to 10,000 entries. + * + * @category Configuration + * @since 0.3.0 + */ export const defaultStorageLimits: StorageLimits = { maxValueBytes: 1024 * 1024, maxErrorEntries: 100, @@ -27,10 +72,22 @@ export const defaultStorageLimits: StorageLimits = { maxEventEntries: 10_000, }; +/** + * Indicates that a value falls outside EffectMQ's lossless storage domain. + * + * @category Errors + * @since 0.3.0 + */ export class UnsupportedStorageValue extends Data.TaggedError( "UnsupportedStorageValue", )<{ readonly path: string; readonly valueType: string }> {} +/** + * Indicates that an encoded payload, success, or failure exceeds its byte limit. + * + * @category Errors + * @since 0.3.0 + */ export class StorageLimitExceeded extends Data.TaggedError( "StorageLimitExceeded", )<{ @@ -39,6 +96,12 @@ export class StorageLimitExceeded extends Data.TaggedError( readonly maxBytes: number; }> {} +/** + * Indicates that a bounded queue-owned collection exceeded its configured size. + * + * @category Errors + * @since 0.3.0 + */ export class StorageCountLimitExceeded extends Data.TaggedError( "StorageCountLimitExceeded", )<{ @@ -48,18 +111,42 @@ export class StorageCountLimitExceeded extends Data.TaggedError( readonly maxCount: number; }> {} +/** + * Indicates that stored data is malformed or has the wrong value kind. + * + * @category Errors + * @since 0.3.0 + */ export class CorruptStorageValue extends Data.TaggedError( "CorruptStorageValue", )<{ readonly message: string; readonly cause?: unknown }> {} +/** + * Indicates that an envelope uses a protocol version this release cannot read. + * + * @category Errors + * @since 0.3.0 + */ export class UnsupportedProtocolVersion extends Data.TaggedError( "UnsupportedProtocolVersion", )<{ readonly encountered: number; readonly supported: readonly number[] }> {} +/** + * Indicates that an envelope was written for a different task schema identity. + * + * @category Errors + * @since 0.3.0 + */ export class SchemaIdentityMismatch extends Data.TaggedError( "SchemaIdentityMismatch", )<{ readonly expected: string; readonly encountered: string }> {} +/** + * Every typed failure produced by EffectMQ's storage boundary. + * + * @category Errors + * @since 0.3.0 + */ export type StorageProtocolError = | UnsupportedStorageValue | StorageLimitExceeded @@ -132,7 +219,39 @@ const normalizeDecoded = (value: unknown): unknown => { return value; }; -/** Encode the documented value domain into an ASCII-safe MessagePack envelope. */ +/** + * Encodes a lossless JavaScript value into an ASCII-safe MessagePack envelope. + * + * Supported values are `null`, strings, booleans, finite safe numbers, + * `Uint8Array`, arrays, and plain objects composed recursively from those + * values. Cycles, class instances, unsafe numbers, `undefined`, `bigint`, + * functions, and symbols fail with {@link UnsupportedStorageValue}. + * + * The size limit applies to the MessagePack bytes before base64 encoding. + * + * **Example: Round-trip an opaque payload** + * + * ```ts + * import { Effect } from "effect" + * import { StorageProtocol } from "@effectmq/core" + * + * const roundTrip = Effect.gen(function* () { + * const encoded = yield* StorageProtocol.encodeValue( + * "invoice/v1", + * "payload", + * { invoiceId: "inv-42", digest: new Uint8Array([1, 2, 3]) } + * ) + * return yield* StorageProtocol.decodeValue( + * encoded, + * "invoice/v1", + * "payload" + * ) + * }) + * ``` + * + * @category Encoding + * @since 0.3.0 + */ export const encodeValue = ( schemaId: string, kind: ValueKind, @@ -155,7 +274,15 @@ export const encodeValue = ( return `${prefix}${Buffer.from(bytes).toString("base64")}`; }); -/** Decode and validate an envelope before schema decoding the enclosed value. */ +/** + * Decodes and validates an envelope before application schema decoding. + * + * The protocol version, schema identity, and value kind must all match the + * caller's expectations. Binary values are normalized to `Uint8Array`. + * + * @category Encoding + * @since 0.3.0 + */ export const decodeValue = ( encoded: unknown, expectedSchemaId: string, diff --git a/src/Task.ts b/src/Task.ts index c47a631..68205e4 100644 --- a/src/Task.ts +++ b/src/Task.ts @@ -1,6 +1,6 @@ /** * Typed task definitions: the schema-bearing description of a unit of work - * (payload, success, and error types) that a {@link TaskQueue} processes. + * (payload, success, and error types) that a `TaskQueue` processes. * * @module */ @@ -14,7 +14,15 @@ const TypeId = "~effectmq/Task" as const; /** Default retry cap applied when `maxRetries` is not set, so an unbounded schedule can't loop forever. */ const DEFAULT_MAX_RETRIES = 5; -/** Finite retention windows, in milliseconds, for one task generation. */ +/** + * Finite retention windows, in milliseconds, for one task generation. + * + * Each resource expires independently during bounded maintenance sweeps. A + * result can therefore expire before its task record or terminal index. + * + * @category Configuration + * @since 0.3.0 + */ export interface RetentionPolicy { readonly taskRecordMs: number; readonly resultMs: number; @@ -23,6 +31,15 @@ export interface RetentionPolicy { readonly eventMs: number; } +/** + * Default retention windows for task records, results, indexes, and events. + * + * Task records, terminal indexes, and events default to seven days; results to + * one day; and dead-letter entries to 30 days. + * + * @category Configuration + * @since 0.3.0 + */ export const defaultRetentionPolicy: RetentionPolicy = { taskRecordMs: 7 * 24 * 60 * 60 * 1000, resultMs: 24 * 60 * 60 * 1000, @@ -57,14 +74,25 @@ const resolveStorageLimits = ( }; /** - * A decoded task as seen by a handler: the typed payload/success/error fields - * plus the engine-assigned `id` and `name`. + * A decoded task generation as seen by a handler. + * + * It includes the typed payload, optional terminal value, attempt and stall + * counters, retry policy, retention policy, and engine-assigned identity. + * + * @category Models + * @since 0.1.0 */ export type { Task } from "./Schemas.js"; /** - * The schema-bearing definition of a task type: its name, payload/success/error - * schemas, and how to derive an idempotency key from a payload. + * The schema-bearing definition of one task family. + * + * A definition owns payload, success, and failure schemas; retry behavior; + * storage and retention limits; and the idempotency-key function used by + * `TaskQueue.offer`. + * + * @category Models + * @since 0.1.0 */ export interface TaskDefinition< Payload extends Schema.Top, @@ -92,11 +120,26 @@ export interface TaskDefinition< readonly idempotencyKey: (payload: Payload["Type"]) => string; } +/** + * Resolves either a struct schema or bare struct fields to a struct schema. + * + * @category Schemas + * @since 0.2.0 + */ export type ResolvePayload = T extends AnyStructSchema ? T : Schema.Struct; +/** + * Normalizes a task payload declaration to a struct schema. + * + * Existing schemas are returned unchanged; bare fields are wrapped with + * `Schema.Struct`. + * + * @category Schemas + * @since 0.2.0 + */ export const resolvePayloadSchema = < T extends AnyStructSchema | Schema.Struct.Fields, >( @@ -151,17 +194,39 @@ const makeInternal = < }; /** - * Define a task type. + * Defines a typed task family. + * + * `payload` accepts either a `Schema.Struct` or bare fields. `success` and + * `error` explicitly define the two terminal channels. `retry` accepts an + * Effect `Schedule` or repeat-style options, while `maxRetries` independently + * caps retries at five by default; pass `null` only for an intentionally + * unbounded cap. + * + * **Gotchas** + * + * Without `idempotencyKey`, every call derives a random key. Identical payloads + * are therefore distinct offers unless the caller supplies a stable key or an + * explicit task identifier. + * + * **Example: Define an idempotent task with bounded retries** + * + * ```ts + * import { Schema } from "effect" + * import { Task } from "@effectmq/core" * - * `payload` may be either a `Schema.Struct` or a bare fields object (which is - * wrapped into a struct). `success`/`error` default to - * `Schema.Void`/`Schema.Never`. When `idempotencyKey` is omitted, a random - * key is generated per offer, so identical payloads are treated as distinct. - * `retry` is a `Schedule` (or `{ while, until, times, schedule }` options) - * that drives when a failed task is retried; `maxRetries` caps the attempts - * (default 5; `null` for unbounded). + * const sendInvoice = Task.make({ + * name: "send-invoice", + * schemaId: "send-invoice/v1", + * payload: { invoiceId: Schema.String }, + * success: Schema.Void, + * error: Schema.Struct({ reason: Schema.String }), + * idempotencyKey: ({ invoiceId }) => invoiceId, + * maxRetries: 3 + * }) + * ``` * - * @returns A {@link TaskDefinition} to pass to `TaskQueue.make`. + * @category Constructors + * @since 0.1.0 */ export const make: { < diff --git a/src/TaskEngine.ts b/src/TaskEngine.ts index d0165a8..fbb9390 100644 --- a/src/TaskEngine.ts +++ b/src/TaskEngine.ts @@ -33,13 +33,33 @@ import { const TypeId = "~effectmq/TaskEngine" as const; +/** + * Configures Redis key namespacing, deterministic test time, and sweep bounds. + * + * `maintenanceBatchSize` defaults to 100 and cannot exceed + * {@link maxMaintenanceBatchSize}. The default key prefix is `~effectmq:v1`. + * + * @category Configuration + * @since 0.3.0 + */ export type TaskEngineConfig = { debugMode?: boolean; prefix?: string; maintenanceBatchSize?: number; }; -/** Largest supported number of records processed by one atomic maintenance call. */ +/** + * Largest supported number of records processed by one atomic maintenance call. + * + * @category Configuration + * @since 0.3.0 + */ export const maxMaintenanceBatchSize = 1_000; +/** + * Wraps a Redis, script, encoding, or decoding failure at the engine boundary. + * + * @category Errors + * @since 0.1.0 + */ export class TaskEngineError extends Data.TaggedError("TaskEngineError")<{ readonly message?: string; readonly cause: unknown; @@ -49,32 +69,70 @@ export class TaskEngineError extends Data.TaggedError("TaskEngineError")<{ } } +/** + * Reports whether an offer created a generation or returned an existing one. + * + * @category Models + * @since 0.3.0 + */ export interface TaskCreateResult { readonly status: "created" | "existing"; readonly cursor: string; readonly task: EngineTask; } -/** One acquired execution attempt and its opaque ownership credential. */ +/** + * One acquired execution attempt and its opaque ownership credential. + * + * The token belongs to this specific acquisition, not to the worker or task. + * Every ownership-sensitive transition must present it unchanged. + * + * @category Models + * @since 0.3.0 + */ export interface TaskAttempt { readonly task: EngineTask; readonly leaseToken: string; } +/** + * Indicates that an attempt no longer owns the task generation it tried to mutate. + * + * @category Errors + * @since 0.3.0 + */ export class LeaseLost extends Data.TaggedError("LeaseLost")<{ readonly prefix: string; readonly taskId: string; readonly cause: TaskEngineError; }> {} +/** + * Redis Stream cursor bounds for one queue's retained lifecycle events. + * + * @category Models + * @since 0.3.0 + */ export interface EventCursors { readonly first: string; readonly earliest: string; readonly latest: string; } +/** + * A task index that can be inspected through `TaskEngine.listTasks`. + * + * @category Models + * @since 0.3.0 + */ export type TaskList = "wait" | "scheduled" | "active" | "failed" | "success"; +/** + * One bounded page of task identifiers from an engine index. + * + * @category Models + * @since 0.3.0 + */ export interface TaskListPage { /** Task ids in FIFO order for `wait`, otherwise ascending score then id. */ readonly items: readonly string[]; @@ -82,6 +140,14 @@ export interface TaskListPage { readonly nextCursor?: string; } +/** + * Indicates that event retention trimmed the stream position a reader requested. + * + * Resume from `earliest` when skipping the missing interval is acceptable. + * + * @category Errors + * @since 0.3.0 + */ export class CursorExpired extends Data.TaggedError("CursorExpired")<{ readonly requested: string; readonly earliest: string; @@ -110,52 +176,72 @@ const compareStreamIds = (left: string, right: string): number => { }; /** - * The task engine service. Provides the atomic queue operations (create, take, - * write success/error, lock management) and schedule coordination, backed by - * Redis. Obtain an implementation via {@link layer}. + * Low-level atomic queue, lease, retention, schedule, and event operations. + * + * Most applications should use `TaskQueue`, `Worker`, and `Scheduler`. Use the + * engine directly for administration, inspection, or custom runtimes that can + * uphold its generation and lease-token invariants. + * + * **Gotchas** + * + * A successful Redis write followed by a lost connection can be indeterminate. + * Ownership-sensitive operations fail with {@link LeaseLost} when their exact + * per-attempt token no longer owns the generation. + * + * @category Services + * @since 0.1.0 */ export class TaskEngine extends Context.Service< TaskEngine, { readonly [TypeId]: typeof TypeId; + /** Compatibility offer that returns only the created or existing task. */ readonly createTask: ( task: EngineTaskInsert, ) => Effect.Effect; + /** Idempotently offers a task and reports whether its generation was new. */ readonly offerTask: ( task: EngineTaskInsert, ) => Effect.Effect; + /** Reads a task generation, or `null` when no task record remains. */ readonly getTask: ( prefix: string, id: string, ) => Effect.Effect; + /** Reads the latest generation number for a task identity. */ readonly getGeneration: ( prefix: string, id: string, ) => Effect.Effect; + /** Reads a retained terminal result for an exact generation. */ readonly getResult: ( prefix: string, id: string, generation: number, ) => Effect.Effect; + /** Lists at most 1,000 task ids using an opaque pagination cursor. */ readonly listTasks: ( prefix: string, list: TaskList, options?: { readonly cursor?: string; readonly limit?: number }, ) => Effect.Effect; - /** Run one bounded maintenance sweep for a queue. */ + /** Runs one bounded promotion, lease-recovery, and retention sweep. */ readonly maintain: ( prefix: string, ) => Effect.Effect; + /** Reads the first, earliest-retained, and latest event stream cursors. */ readonly eventCursors: ( prefix: string, ) => Effect.Effect; + /** Settles an owned attempt successfully using its exact lease token. */ readonly writeSuccess: ( prefix: string, id: string, leaseToken: string, result: unknown, ) => Effect.Effect; + /** Records an owned attempt failure and optionally schedules its retry. */ readonly writeError: ( prefix: string, id: string, @@ -163,21 +249,25 @@ export class TaskEngine extends Context.Service< error: unknown, retryAt?: Duration.Input, ) => Effect.Effect; + /** Renews an owned attempt's lease for `lockTimeout` milliseconds. */ readonly extendLock: ( prefix: string, id: string, leaseToken: string, lockTimeout: number, ) => Effect.Effect; + /** Voluntarily releases an owned attempt and returns it to runnable work. */ readonly removeLock: ( prefix: string, id: string, leaseToken: string, ) => Effect.Effect; + /** Acquires the next runnable task with a fresh lease token. */ readonly takeTask: ( prefix: string, lockTimeout: number, ) => Effect.Effect; + /** Removes an unretained task generation and all queue memberships. */ readonly removeTask: ( prefix: string, id: string, @@ -188,10 +278,12 @@ export class TaskEngine extends Context.Service< id: string, ) => Effect.Effect; + /** Initializes a durable schedule cursor without moving an existing cursor. */ readonly setSchedule: ( id: string, next: Date, ) => Effect.Effect; + /** Compare-and-advances a durable schedule cursor. */ readonly consumeSchedule: ( name: string, toConsume: Date, @@ -217,6 +309,12 @@ export class TaskEngine extends Context.Service< } >()("TaskEngine") {} +/** + * The service interface represented by the {@link TaskEngine} tag. + * + * @category Services + * @since 0.1.0 + */ export type TaskEngineService = TaskEngine["Service"]; // must match MOCKTIME_KEY in src/lua/taskEngine.lua const MOCKTIME_KEY = "$$$effectmq/debug/mocktime"; @@ -224,6 +322,14 @@ const MOCKTIME_KEY = "$$$effectmq/debug/mocktime"; /** * Override the engine's notion of "now" (only honored when the engine is built * with `debugMode`). Intended for deterministic tests of delays and schedules. + * + * **Gotchas** + * + * The mock clock is a Redis-global debug key, not a queue- or prefix-local + * clock. Never enable `debugMode` in production. + * + * @category Testing + * @since 0.1.0 */ export const setMockTime = (time: Duration.Input) => Effect.gen(function* () { @@ -231,7 +337,14 @@ export const setMockTime = (time: Duration.Input) => yield* redis.send("SET", MOCKTIME_KEY, String(Duration.toMillis(time))); }).pipe(Effect.mapError(TaskEngineError.of("Failed to set mock time"))); -/** Advance the mock clock by `time` (debug-mode only). See {@link setMockTime}. */ +/** + * Advances the Redis-global mock clock by a duration. + * + * Only engines built with `debugMode` read this clock. See {@link setMockTime}. + * + * @category Testing + * @since 0.1.0 + */ export const stepMockTime = (time: Duration.Input) => Effect.gen(function* () { const redis = yield* RedisPool; @@ -274,16 +387,28 @@ const pack = (value: unknown) => const decodeEvents = Schema.decodeUnknownEffect(Schema.Array(EventSchema)); +/** + * Joins Redis key namespace segments with a colon. + * + * Segments are not escaped; callers should avoid embedded colons when they + * need unambiguous composition. + * + * @category Utilities + * @since 0.1.0 + */ export const makePrefix = (...prefixes: string[]) => prefixes.join(":"); /** - * Build a {@link TaskEngine} implementation against the ambient - * {@link RedisPool} service. The script is loaded lazily by exact content and + * Builds a {@link TaskEngine} implementation against a concrete Redis service. + * + * The script is loaded lazily by exact content and * invoked through cached `EVALSHA`, with one transparent `NOSCRIPT` recovery. * `debugMode` enables the mockable clock (see {@link setMockTime}); * `prefix` namespaces all keys. Every acquisition creates a fresh opaque lease * token; callers must present it for every ownership-sensitive transition. - * Usually consumed via {@link layer}. + * + * @category Constructors + * @since 0.3.0 */ export const makeWithRedis = ( redis: RedisPoolService, @@ -781,13 +906,23 @@ export const makeWithRedis = ( }); }); -/** Build a task engine from the ambient producer Redis pool. */ +/** + * Builds a task engine from the ambient producer {@link RedisPool} service. + * + * @category Constructors + * @since 0.1.0 + */ export const make = (config?: TaskEngineConfig) => Effect.gen(function* () { const redis = yield* RedisPool; return yield* makeWithRedis(redis, config); }); -/** A `Layer` providing the {@link TaskEngine} service; requires a `RedisPool` service. */ +/** + * Provides {@link TaskEngine} from an ambient {@link RedisPool} service. + * + * @category Layers + * @since 0.1.0 + */ export const layer = (config?: TaskEngineConfig) => Layer.effect(TaskEngine, make(config)); diff --git a/src/TaskQueue.ts b/src/TaskQueue.ts index d092509..df05656 100644 --- a/src/TaskQueue.ts +++ b/src/TaskQueue.ts @@ -1,5 +1,5 @@ /** - * The high-level, typed queue API over {@link TaskEngine}. A `TaskQueue` pairs + * The high-level, typed queue API over `TaskEngine`. A `TaskQueue` pairs * a queue name with a {@link Task} definition; use {@link offer} to enqueue * work and {@link complete} to process a task end-to-end (take, run the handler, * and report the outcome, applying the definition's retry policy on failure). @@ -28,7 +28,15 @@ import { nextRunAt } from "./utils.js"; const TypeId = "~effectmq/TaskQueue" as const; -/** A named queue bound to a typed {@link Task.TaskDefinition}. */ +/** + * A queue name bound to one typed {@link Task.TaskDefinition}. + * + * This is a pure descriptor; it does not allocate Redis state or start a + * worker. Queue state is created by the first operation that needs it. + * + * @category Models + * @since 0.1.0 + */ export interface TaskQueue< Payload extends Schema.Top, Success extends Schema.Top = Schema.Void, @@ -39,7 +47,12 @@ export interface TaskQueue< readonly name: string; readonly task: Task.TaskDefinition; } -/** Create a {@link TaskQueue} from a queue `name` and a task definition. */ +/** + * Creates a typed queue descriptor from a stable name and task definition. + * + * @category Constructors + * @since 0.1.0 + */ export const make = < Payload extends Schema.Top, Success extends Schema.Top = Schema.Void, @@ -132,6 +145,16 @@ const takeUnsafe = Effect.fnUntraced(function* < return attempt; }); +/** + * Controls the identity, timing, retry cap, and terminal retention of one offer. + * + * `delay` is measured in milliseconds. Completion policies default to + * `delete`, duplicate offers default to `return-existing`, and + * `maxStalledCount` defaults to one. + * + * @category Configuration + * @since 0.1.0 + */ export interface TaskOptions { /** Explicit task identity override, used by durable scheduler tick tasks. */ readonly taskId?: string; @@ -150,6 +173,9 @@ export interface TaskOptions { * Redis connection loss made it impossible to determine whether an offer was * committed. Retry with the same queue payload/idempotency identity; the * default duplicate behavior will return the committed generation unchanged. + * + * @category Errors + * @since 0.3.0 */ export class IndeterminateWriteError extends Data.TaggedError( "IndeterminateWriteError", @@ -159,7 +185,12 @@ export class IndeterminateWriteError extends Data.TaggedError( readonly cause: TaskEngine.TaskEngineError; }> {} -/** Explicit result retention was requested outside a managed task handler. */ +/** + * Indicates that current-task result retention was requested outside a handler. + * + * @category Errors + * @since 0.3.0 + */ export class RetentionContextRequired extends Data.TaggedError( "RetentionContextRequired", )<{ @@ -201,7 +232,16 @@ const relationshipLimit = ( declare const TaskHandleSuccess: unique symbol; declare const TaskHandleError: unique symbol; -/** A durable, schema-aware reference to exactly one offered task generation. */ +/** + * A durable, schema-aware reference to exactly one offered task generation. + * + * Persist the entire handle when a later process will call {@link wait}. Its + * protocol and schema identities prevent a different decoder from silently + * interpreting the stored result. + * + * @category Models + * @since 0.3.0 + */ export interface TaskHandle { readonly _tag: "TaskHandle"; readonly queue: string; @@ -215,25 +255,61 @@ export interface TaskHandle { readonly [TaskHandleError]?: (_: Error) => Error; } +/** + * Carries the typed terminal failure observed by {@link wait}. + * + * @category Errors + * @since 0.3.0 + */ export class TaskFailed extends Data.TaggedError("TaskFailed")<{ readonly handle: TaskHandle; readonly failure: Failure; }> {} +/** + * Indicates that neither a task record nor a result exists for a handle. + * + * @category Errors + * @since 0.3.0 + */ export class TaskNotFound extends Data.TaggedError("TaskNotFound")<{ readonly handle: TaskHandle; }> {} +/** + * Indicates that a handle's exact generation no longer has a retained result. + * + * `latestGeneration` distinguishes expiry or removal from replacement by a + * newer generation. + * + * @category Errors + * @since 0.3.0 + */ export class ResultExpired extends Data.TaggedError("ResultExpired")<{ readonly handle: TaskHandle; readonly latestGeneration: number; }> {} +/** + * Indicates that the caller's wait deadline elapsed without canceling the task. + * + * @category Errors + * @since 0.3.0 + */ export class CallerTimeout extends Data.TaggedError("CallerTimeout")<{ readonly handle: TaskHandle; readonly timeout: Duration.Input; }> {} +/** + * The generation-safe result of offering a task. + * + * `TaskExisting` means the configured identity already had a stored generation; + * its state is returned unchanged. `TaskCreated` identifies a new generation. + * + * @category Models + * @since 0.3.0 + */ export type OfferOutcome< Payload extends Schema.Top, Success extends Schema.Top, @@ -244,7 +320,9 @@ export type OfferOutcome< readonly handle: TaskHandle; }; /** - * Enqueue `payload` onto the queue. The payload is encoded via the task's + * Enqueues a typed payload and returns its exact generation handle. + * + * The payload is encoded via the task's * payload schema and the task id is derived from the definition's * idempotency key. Honors `delay` and the success/failure policy options. * @@ -252,6 +330,36 @@ export type OfferOutcome< * provenance. Nested offers remain execution-independent by default. Request * `retainResultUntil: "current-task-settles"` only when the spawned task's * terminal record must remain readable until the current task settles. + * + * **Gotchas** + * + * If this operation fails with {@link IndeterminateWriteError}, retry the same + * payload and identity with the default duplicate policy. Creating a new + * identity could enqueue the work twice. + * + * **Example: Offer and retain a handle** + * + * ```ts + * import { Effect, Schema } from "effect" + * import { Task, TaskEngine, TaskQueue } from "@effectmq/core" + * + * const resize = Task.make({ + * name: "resize-image", + * payload: { imageId: Schema.String }, + * success: Schema.String, + * error: Schema.String, + * idempotencyKey: ({ imageId }) => imageId + * }) + * const images = TaskQueue.make("images", resize) + * + * const enqueue = TaskQueue.offer(images, { imageId: "img-42" }).pipe( + * Effect.map(({ handle }) => handle), + * Effect.provide(TaskEngine.layer()) + * ) + * ``` + * + * @category Operations + * @since 0.1.0 */ export const offer = Effect.fnUntraced(function* < Payload extends Schema.Top, @@ -355,6 +463,16 @@ export const offer = Effect.fnUntraced(function* < } satisfies OfferOutcome; }); +/** + * Renews a low-level task attempt using its exact lease token. + * + * Managed handlers receive heartbeat supervision automatically. This operation + * is intended for custom worker integrations that already hold an acquired + * attempt and are prepared to handle {@link TaskEngine.LeaseLost}. + * + * @category Operations + * @since 0.1.0 + */ export const extendLock = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, @@ -378,6 +496,15 @@ export const extendLock = Effect.fnUntraced(function* < ); }); +/** + * Voluntarily releases a low-level attempt back to runnable work. + * + * The exact lease token is required. Releasing does not record a stalled + * failure; lease expiry recovery does. + * + * @category Operations + * @since 0.1.0 + */ export const release = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, @@ -465,6 +592,17 @@ const fail = Effect.fnUntraced(function* < ); }); +/** + * Processes one acquired task and returns its typed success or failure. + * + * Handler failure is persisted through the task's retry and terminal policy; + * it is not re-emitted as the processing operation's infrastructure failure. + * Handlers may execute more than once after lease loss and must make external + * side effects idempotent. + * + * @category Models + * @since 0.1.0 + */ export type TaskHandler< Payload extends Schema.Top, Success extends Schema.Top, @@ -474,6 +612,16 @@ export type TaskHandler< task: Task.Task, ) => Effect.Effect; +/** + * Configures acquisition leases and bounded heartbeat recovery. + * + * Defaults are a 30-second lease, a 10-second refresh interval, 250 ms between + * heartbeat retries, and at most three transport retries within the remaining + * lease safety window. + * + * @category Configuration + * @since 0.3.0 + */ export interface ProcessingOptions { readonly lockTimeout?: Duration.Input; readonly lockRefresh?: Duration.Input; @@ -545,10 +693,40 @@ const processAttempt = Effect.fnUntraced(function* < }); /** - * Take the next task and run it to completion: it locks the task, keeps the - * lock alive with a background heartbeat, runs `handler`, then reports the - * outcome to the engine. - * @returns The task id + * Takes the next task, supervises its lease, and persists the handler outcome. + * + * This operation polls until work is available, then returns the processed task + * identifier. It is available in data-first and data-last forms. Handler + * failures are recorded on the task and may schedule a retry; only decoding, + * storage, Redis, or ownership failures remain in the Effect failure channel. + * + * **Gotchas** + * + * Use `Worker.run` for production worker loops. A handler can run more + * than once if its lease expires or ownership is lost. + * + * **Example: Process one task** + * + * ```ts + * import { Effect, Schema } from "effect" + * import { Task, TaskQueue } from "@effectmq/core" + * + * const greet = Task.make({ + * name: "greet", + * payload: { name: Schema.String }, + * success: Schema.String, + * error: Schema.String + * }) + * const greetings = TaskQueue.make("greetings", greet) + * + * const processNext = TaskQueue.complete( + * greetings, + * ({ payload }) => Effect.succeed(`Hello, ${payload.name}!`) + * ) + * ``` + * + * @category Operations + * @since 0.1.0 */ export const complete: { < @@ -620,6 +798,9 @@ export const complete: { /** * Try to acquire and process one currently available task without polling. * Returns `false` when the queue is empty. Intended for managed worker loops. + * + * @category Operations + * @since 0.3.0 */ export const completeOne = Effect.fnUntraced(function* < Payload extends Schema.Top, @@ -649,6 +830,15 @@ export const completeOne = Effect.fnUntraced(function* < * `task.failed` yields a typed error, and `task.completed` yields a typed * success value. `cursor` resumes from a prior event id (defaults to now, so * only future events are delivered). + * + * **Gotchas** + * + * Event retention is finite. A cursor into a trimmed interval fails with + * {@link TaskEngine.CursorExpired}; resume from its `earliest` cursor only when + * skipping the missing events is acceptable. + * + * @category Streaming + * @since 0.2.0 */ export const stream = < Payload extends Schema.Top, @@ -738,11 +928,31 @@ export const stream = < ); }).pipe(Stream.unwrap); +/** + * Configures a caller-local deadline for {@link wait}. + * + * @category Configuration + * @since 0.3.0 + */ export interface WaitOptions { readonly timeout?: Duration.Input; } -/** Await the exact task generation named by a handle. */ +/** + * Awaits the exact task generation named by a durable handle. + * + * The operation checks retained state, subscribes to lifecycle events, and + * checks state again before awaiting an event. This closes the completion race + * around subscription while preserving a durable fast path for already-settled + * tasks. + * + * A terminal task failure becomes {@link TaskFailed}. Missing records, expired + * results, caller timeouts, incompatible storage metadata, and trimmed cursors + * remain distinct typed failures. A caller timeout does not cancel queue work. + * + * @category Operations + * @since 0.2.0 + */ export const wait = < Payload extends Schema.Top, Success extends Schema.Top, @@ -932,6 +1142,15 @@ export const wait = < /** * Offer a task and await its outcome through the same generation-safe handle * protocol as {@link wait}. + * + * **Gotchas** + * + * This convenience operation has no caller-timeout option. Use {@link offer} + * followed by {@link wait} when the waiting fiber needs its own deadline or the + * handle must be persisted elsewhere. + * + * @category Operations + * @since 0.2.0 */ export const execute = Effect.fnUntraced(function* < Payload extends Schema.Top, diff --git a/src/Worker.ts b/src/Worker.ts index 562907e..e8c5eb1 100644 --- a/src/Worker.ts +++ b/src/Worker.ts @@ -20,6 +20,12 @@ import * as TaskQueue from "./TaskQueue.js"; const TypeId = "~effectmq/Worker" as const; +/** + * Configures worker concurrency, polling, maintenance, and graceful shutdown. + * + * @category Configuration + * @since 0.3.0 + */ export interface WorkerOptions { /** Number of independent acquire/process loops. Defaults to `1`. */ readonly concurrency?: number; @@ -33,6 +39,15 @@ export interface WorkerOptions { readonly processing?: TaskQueue.ProcessingOptions; } +/** + * A queue, handler, and runtime policy ready to be run as a managed worker. + * + * This value is only a description; creating it does not acquire Redis + * connections or start background fibers. + * + * @category Models + * @since 0.3.0 + */ export interface Worker< Payload extends Schema.Top, Success extends Schema.Top, @@ -46,7 +61,33 @@ export interface Worker< readonly options: WorkerOptions; } -/** Describe a worker. Use {@link run} inside an application scope. */ +/** + * Describes a worker without starting it. + * + * **Example: Build a two-slot worker** + * + * ```ts + * import { Effect, Schema } from "effect" + * import { Task, TaskQueue, Worker } from "@effectmq/core" + * + * const email = Task.make({ + * name: "email", + * payload: { address: Schema.String }, + * success: Schema.Void, + * error: Schema.String + * }) + * const emails = TaskQueue.make("emails", email) + * + * const worker = Worker.make( + * emails, + * ({ payload }) => Effect.log(`Emailing ${payload.address}`), + * { concurrency: 2 } + * ) + * ``` + * + * @category Constructors + * @since 0.3.0 + */ export const make = < Payload extends Schema.Top, Success extends Schema.Top, @@ -67,9 +108,21 @@ export const make = < class WorkerSlotStopped extends Data.TaggedError("WorkerSlotStopped") {} /** - * Run until interrupted. Interruption stops new acquisitions, waits up to - * `drainTimeout` for active handlers (whose heartbeats keep running), then - * interrupts any remainder and releases the role-specific Redis resources. + * Runs a worker until interrupted. + * + * Independent acquisition fibers use the worker Redis role while a maintenance + * fiber uses the maintenance role. Interruption stops new acquisitions, keeps + * heartbeats alive while handlers drain, then interrupts any remainder after + * `drainTimeout`. + * + * **Gotchas** + * + * Queue handlers have at-least-once delivery and must make externally visible + * effects idempotent. Attempt and maintenance failures are logged and the loops + * continue, so this long-running Effect has `never` in its failure channel. + * + * @category Operations + * @since 0.3.0 */ export const run = < Payload extends Schema.Top, diff --git a/src/index.ts b/src/index.ts index f6af695..f957878 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,14 +8,80 @@ * @module */ +/** + * Scoped node-redis adapters for standalone Redis and Sentinel. + * + * @category Modules + * @since 0.2.0 + */ export * as NodeRedisPool from "./NodeRedisPool.js"; +/** + * Effect metrics emitted by Redis and queue operations. + * + * @category Modules + * @since 0.3.0 + */ export * as Observability from "./Observability.js"; +/** + * Minimal Redis command, script-cache, and workload-role services. + * + * @category Modules + * @since 0.2.0 + */ export * as RedisPool from "./RedisPool.js"; +/** + * Durable cron tick materialization into ordinary queue tasks. + * + * @category Modules + * @since 0.1.0 + */ export * as Scheduler from "./Scheduler.js"; +/** + * Versioned, lossless storage envelopes and storage limits. + * + * @category Modules + * @since 0.3.0 + */ export * as StorageProtocol from "./StorageProtocol.js"; +/** + * The schema-bearing definition of one task family. + * + * @category Models + * @since 0.1.0 + */ export type { TaskDefinition } from "./Task.js"; +/** + * Typed task definitions and retention policies. + * + * @category Modules + * @since 0.1.0 + */ export * as Task from "./Task.js"; +/** + * Low-level atomic queue, lease, schedule, and event operations. + * + * @category Modules + * @since 0.1.0 + */ export * as TaskEngine from "./TaskEngine.js"; +/** + * A typed queue task handler. + * + * @category Models + * @since 0.1.0 + */ export type { TaskHandler } from "./TaskQueue.js"; +/** + * High-level typed queue operations and generation-safe handles. + * + * @category Modules + * @since 0.1.0 + */ export * as TaskQueue from "./TaskQueue.js"; +/** + * Managed queue workers with bounded concurrency and graceful draining. + * + * @category Modules + * @since 0.3.0 + */ export * as Worker from "./Worker.js"; From b4bf781f0a9fefeb67aef0c42edac71c4170206d Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Thu, 20 Aug 2026 06:53:34 -0300 Subject: [PATCH 2/8] feat!: harden Effect contracts and architecture --- .changeset/clean-effect-contracts.md | 9 + .github/workflows/ci.yml | 6 +- CLAUDE.md | 18 +- CONTRIBUTING.md | 19 + README.md | 108 +- docs/api-reference.md | 23 +- docs/architecture.md | 37 + docs/operations.md | 6 + docs/runtime-boundaries.md | 23 + docs/scheduler.md | 6 +- .../.openspec.yaml | 2 + .../design.md | 71 + .../proposal.md | 25 + .../specs/effect-test-harness/spec.md | 61 + .../specs/production-release/spec.md | 12 + .../tasks.md | 48 + .../.openspec.yaml | 2 + .../design.md | 73 + .../proposal.md | 26 + .../specs/effect-runtime-boundaries/spec.md | 46 + .../specs/redis-operations/spec.md | 23 + .../specs/storage-protocol/spec.md | 9 + .../tasks.md | 41 + .../.openspec.yaml | 2 + .../design.md | 74 + .../proposal.md | 29 + .../specs/effect-api-contracts/spec.md | 50 + .../specs/redis-operations/spec.md | 16 + .../specs/scheduler-delivery/spec.md | 9 + .../specs/storage-protocol/spec.md | 17 + .../specs/task-events-stream/spec.md | 28 + .../tasks.md | 44 + .../.openspec.yaml | 2 + .../design.md | 81 + .../proposal.md | 26 + .../specs/effect-module-architecture/spec.md | 62 + .../tasks.md | 42 + openspec/specs/effect-api-contracts/spec.md | 52 + .../specs/effect-module-architecture/spec.md | 64 + .../specs/effect-runtime-boundaries/spec.md | 48 + openspec/specs/effect-test-harness/spec.md | 63 + openspec/specs/production-release/spec.md | 12 + openspec/specs/redis-operations/spec.md | 40 + openspec/specs/scheduler-delivery/spec.md | 9 + openspec/specs/storage-protocol/spec.md | 26 +- openspec/specs/task-events-stream/spec.md | 15 +- package.json | 23 +- pnpm-lock.yaml | 18 +- scripts/check-architecture.ts | 50 + scripts/verify-package.ts | 9 +- src/EngineRecord.ts | 155 ++ src/MessagePack.ts | 38 + src/NodeRedisPool.boundary.test.ts | 100 ++ src/NodeRedisPool.test.ts | 169 +- src/NodeRedisPool.ts | 134 +- src/Observability.ts | 3 +- src/PublicContracts.test.ts | 173 ++ src/RedisCompatibility.test.ts | 54 +- src/RedisPool.ts | 9 +- src/RedisReadiness.test.ts | 35 + src/RedisReadiness.ts | 11 + src/RedisRestart.test.ts | 331 ++-- src/RedisSentinel.test.ts | 324 ++-- src/{utils.ts => RetrySchedule.ts} | 32 +- src/Scheduler.test.ts | 476 +++--- src/Scheduler.ts | 175 +- src/Schemas.test.ts | 31 + src/Schemas.ts | 513 ------ src/StorageProtocol.test.ts | 209 ++- src/StorageProtocol.ts | 76 +- src/Task.test.ts | 61 + src/Task.ts | 273 ++- src/TaskContext.ts | 21 +- src/TaskEngine.locks.test.ts | 705 ++++---- src/TaskEngine.pinning.test.ts | 485 +++--- src/TaskEngine.replies.test.ts | 113 ++ src/TaskEngine.test.ts | 1462 +++++++++-------- src/TaskEngine.ts | 654 +++++--- src/TaskEvent.ts | 83 + src/TaskEvents.test.ts | 680 ++++---- src/TaskQueue.test.ts | 1346 ++++++++------- src/TaskQueue.ts | 536 +++--- src/TaskRecord.ts | 277 ++++ src/Worker.test.ts | 179 +- src/Worker.ts | 77 +- src/cli/InspectPreReleaseData.test.ts | 110 ++ src/cli/InspectPreReleaseData.ts | 108 ++ src/cli/inspect-pre-release-data.ts | 45 +- src/index.ts | 4 + src/testing/FaultInjection.test.ts | 179 +- src/testing/FaultInjection.ts | 10 +- src/testing/TaskStateProperty.test.ts | 433 ++--- src/testing/TypeAssertions.ts | 23 + src/testing/redisLayer.test.ts | 160 ++ src/testing/redisLayer.ts | 339 ++-- tsconfig.test.json | 11 + vitest.config.ts | 5 +- 97 files changed, 8467 insertions(+), 4565 deletions(-) create mode 100644 .changeset/clean-effect-contracts.md create mode 100644 docs/architecture.md create mode 100644 docs/runtime-boundaries.md create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/design.md create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/proposal.md create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/effect-test-harness/spec.md create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/production-release/spec.md create mode 100644 openspec/changes/archive/2026-08-20-adopt-effect-native-testing/tasks.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/design.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/proposal.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/effect-runtime-boundaries/spec.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/redis-operations/spec.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/storage-protocol/spec.md create mode 100644 openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/tasks.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/design.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/proposal.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/effect-api-contracts/spec.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/redis-operations/spec.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/scheduler-delivery/spec.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/storage-protocol/spec.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/task-events-stream/spec.md create mode 100644 openspec/changes/archive/2026-08-20-make-effect-contracts-honest/tasks.md create mode 100644 openspec/changes/archive/2026-08-20-restructure-effect-modules/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-20-restructure-effect-modules/design.md create mode 100644 openspec/changes/archive/2026-08-20-restructure-effect-modules/proposal.md create mode 100644 openspec/changes/archive/2026-08-20-restructure-effect-modules/specs/effect-module-architecture/spec.md create mode 100644 openspec/changes/archive/2026-08-20-restructure-effect-modules/tasks.md create mode 100644 openspec/specs/effect-api-contracts/spec.md create mode 100644 openspec/specs/effect-module-architecture/spec.md create mode 100644 openspec/specs/effect-runtime-boundaries/spec.md create mode 100644 openspec/specs/effect-test-harness/spec.md create mode 100644 scripts/check-architecture.ts create mode 100644 src/EngineRecord.ts create mode 100644 src/MessagePack.ts create mode 100644 src/NodeRedisPool.boundary.test.ts create mode 100644 src/PublicContracts.test.ts create mode 100644 src/RedisReadiness.test.ts create mode 100644 src/RedisReadiness.ts rename src/{utils.ts => RetrySchedule.ts} (63%) create mode 100644 src/Schemas.test.ts delete mode 100644 src/Schemas.ts create mode 100644 src/Task.test.ts create mode 100644 src/TaskEngine.replies.test.ts create mode 100644 src/TaskEvent.ts create mode 100644 src/TaskRecord.ts create mode 100644 src/cli/InspectPreReleaseData.test.ts create mode 100644 src/cli/InspectPreReleaseData.ts create mode 100644 src/testing/TypeAssertions.ts create mode 100644 src/testing/redisLayer.test.ts create mode 100644 tsconfig.test.json diff --git a/.changeset/clean-effect-contracts.md b/.changeset/clean-effect-contracts.md new file mode 100644 index 0000000..571dfce --- /dev/null +++ b/.changeset/clean-effect-contracts.md @@ -0,0 +1,9 @@ +--- +"@effectmq/core": major +--- + +Make public Effect contracts honest and restructure the package around focused +task-record and task-event modules. Task and scheduler construction is now +effectful, TaskEngine errors use semantic reason tags, the standard +`TaskEngine.layer` is fully wired, and malformed codec/Redis inputs remain in +typed error channels. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b81be0d..ff7fc57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: - run: pnpm install --frozen-lockfile - name: Format, lint, typecheck, Lua drift, docs run: pnpm check + - name: Effect test lifecycle gate + run: pnpm test:lifecycle - name: Build declarations and ESM run: pnpm build - name: Unit and Docker integration suite @@ -42,6 +44,8 @@ jobs: - run: pnpm install --frozen-lockfile - name: Install Redis server and Sentinel binary run: sudo apt-get update && sudo apt-get install -y redis-server + - name: Strictly compile test sources + run: pnpm typecheck:test - run: pnpm test:fault redis-compatibility: @@ -65,7 +69,7 @@ jobs: env: EFFECTMQ_COMPAT_REDIS_IMAGE: redis:${{ matrix.redis }}-alpine EFFECTMQ_COMPAT_RESP: ${{ matrix.resp }} - run: pnpm exec vitest run src/RedisCompatibility.test.ts + run: pnpm typecheck:test && pnpm exec vitest run src/RedisCompatibility.test.ts packed-consumer: name: Packed ESM consumer / Node ${{ matrix.node }} diff --git a/CLAUDE.md b/CLAUDE.md index a51f223..8249c0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash pnpm install # install (pnpm@10.x, see packageManager) pnpm build # tsc -> dist/ (tests and src/testing are excluded from the build) -pnpm exec tsc --noEmit # typecheck (what CI runs) +pnpm typecheck # strict production + complete test typecheck +pnpm typecheck:src # production sources only +pnpm typecheck:test # all tests and src/testing support pnpm exec biome check src/ # lint (what CI runs) pnpm lint:fix # biome check --write --unsafe pnpm test # vitest run (integration tests, needs Docker — see below) @@ -24,7 +26,9 @@ Note: the `pnpm lint` script runs `turbo run lint`, but turbo is not a dependenc ### Tests need Docker -Tests use `@testcontainers/redis` to spin up a real Redis container per vitest worker; `src/testing/redisLayer.ts` builds a shared `TestRuntime` (ManagedRuntime) at module import time so the container boot doesn't eat the first test's timeout. Test timeout is 30s (`vitest.config.ts`). The root `docker-compose.yml` Redis is for manual/local experimentation only — tests don't use it. +Effectful tests use `@effect/vitest`. Redis integration suites install the suite-scoped `TestLayer` from `src/testing/redisLayer.ts`; it acquires and releases containers, clients, child processes, listeners, and temporary directories through Effect scopes. Never add a module-level `ManagedRuntime`, direct `Effect.runPromise` test runner, or top-level resource warming. Container acquisition has an explicit 60s hook timeout and normal tests have a 30s timeout (`vitest.config.ts`). The root `docker-compose.yml` Redis is for manual/local experimentation only — tests don't use it. + +Use `it.effect` for Effectful unit tests and `layer(...)(..., (it) => ...)` for suites with dependencies. Unit-level timing uses `TestClock` and a `Deferred`/latch before advancing time. Tests that exercise Redis TTL, restart, or Sentinel failover are explicitly labeled “real Redis time,” exclude Effect test services, and use bounded polling with diagnostic timeouts rather than fixed sleeps. Pure value/schema tests remain ordinary Vitest tests with explicit assertions. ### Releases @@ -40,17 +44,19 @@ Flat `src/` with a strict layering, top to bottom: - **`TaskEngine.ts`** (the big one, ~1000 lines) — the low-level `Context.Service` implementing queue primitives as **atomic Lua scripts** (inline `/*lua*/` strings, built by `buildScripts`): create/take/writeSuccess/writeError, lock extend/remove, delayed + cron schedule state. Tasks move between Redis lists: `wait`, `scheduled`, `active`, `failed`, `success`. Every state change publishes to a per-queue Redis Stream (`::events`, via `XADD`); `stream` polls it with `XREAD` (default 1s). Consumers rarely call the engine directly — go through `TaskQueue`/`Scheduler`. - **`RedisPool.ts`** — the minimal service the engine depends on: just `send` + `eval`. Any Redis client can implement it. - **`NodeRedisPool.ts`** — the bundled `RedisPool` implementation using node-redis `createClientPool` (lazy connect, closed on layer scope end). Tests provide `RedisPool` via ioredis + testcontainers instead (`src/testing/redisLayer.ts`) — proof the service boundary works. -- **`Schemas.ts`** — shared task model: completion policies (`delete` | `keep` | `mark-as-success` | `mark-as-failure`), built-in `Stalled`/`Canceled` tagged errors, encode/decode between engine (Redis hash) representation and typed tasks. +- **`TaskRecord.ts`** — public typed task record schemas and storage codecs. +- **`TaskEvent.ts`** — public queue lifecycle event schemas. +- **`EngineRecord.ts` / `MessagePack.ts` / `RetrySchedule.ts`** — internal Redis record, binary codec, and retry-schedule concepts. -Wiring: `TaskEngine.layer()` requires `RedisPool`; the standard app layer is `Layer.provideMerge(TaskEngine.layer(), NodeRedisPool.layer())`. +Wiring: `TaskEngine.layer()` is the complete zero-requirement Node live graph and intentionally retains Redis operational services. `TaskEngine.layerNoDeps()` is the custom-client layer that requires `RedisPool`. The library deliberately has **no built-in concurrency/rate limiting** — one `complete` processes one task, and callers compose concurrency from Effect primitives (fibers, semaphores, schedules). Don't add worker-pool machinery. ## Conventions - **Effect 4 beta idioms**: `Context.Service` classes for services, `Schema.TaggedError` for schema-backed errors, `Effect.fnUntraced` for functions, `Data.TaggedError` for engine errors, imports from `effect/unstable/*` where needed (e.g. `effect/unstable/persistence/Redis`). Match these when adding code. -- Type IDs are string constants like `"~effectmq/TaskEngine"`; built-in error tags use the `~effectmq/Error/...` namespace. -- `effect` is a **peerDependency** (`>=4.0.0-beta.107`) and devDependency, never a hard dependency. Direct `@effect/*` development dependencies use the same beta baseline. The only runtime dependencies are `msgpackr` and `redis`. +- Service identifiers use `@effectmq/core/`; nominal type IDs and built-in error tags use the `~effectmq/...` namespace. +- `effect` is a **peerDependency** (`>=4.0.0-beta.107`) and devDependency. `@effect/platform-node` is a runtime dependency for the standard live graph; all Effect packages use the same beta baseline. - Public API (everything re-exported from `src/index.ts` as namespace exports) carries TSDoc, including `@module` headers per file. Keep new exports documented. - Formatting/linting is Biome (2-space indent); config in `biome.json`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d04a978..e3461e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,25 @@ use local `redis-server` processes and are enabled with: EFFECTMQ_TEST_REDIS=local EFFECTMQ_TEST_SENTINEL=local pnpm test ``` +Effectful tests use `@effect/vitest`: use `it.effect` for isolated Effects and +suite-scoped Layers for shared services. Do not call `Effect.runPromise` from a +test or keep a module-level `ManagedRuntime`. Test resources must be acquired +with `Effect.acquireRelease`; the lifecycle gate fails on leaked tracked +resources or a finalizer timeout. Run `pnpm typecheck:test` when changing any +test or `src/testing` support file. + +Use `TestClock` plus `Deferred`/latches for unit-level time and concurrency. +Only Redis-owned TTL/restart/failover tests use real time; label those suites +and prefer bounded polling with diagnostic timeouts over fixed sleeps. + +Production modules import supported narrow `effect/*` subpaths. Public +Effect-returning functions pin exact success, error, and service channels and +use `Effect.fnUntraced` for reusable generator implementations. Services are +`Context.Service` classes with `@effectmq/core/` identifiers; optional +fiber-local values are `Context.Reference`s. Use `TaskEngine.layerNoDeps()` for +custom Redis composition and reserve `TaskEngine.layer()` for the complete Node +live graph. + Edit `src/lua/taskEngine.lua`, then run `pnpm gen:lua`; never hand-edit the generated TypeScript module. CI rejects generated drift. Add committed golden fixtures for any declared storage compatibility pair and property/fault tests diff --git a/README.md b/README.md index 7c8af9e..27a7d10 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,19 @@ Node.js 22.19 or newer is required; the release matrix verifies Node.js 22 and 2 Define a task, enqueue work, process it. The whole loop: ```ts -import { Effect, Layer, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { NodeRuntime } from "@effect/platform-node"; -import { NodeRedisPool, Task, TaskEngine, TaskQueue } from "@effectmq/core"; - -const SendEmail = Task.make({ - name: "send-email", - payload: { to: Schema.String, subject: Schema.String }, - success: Schema.String, - error: Schema.Never, -}); - -const emails = TaskQueue.make("emails", SendEmail); +import { Task, TaskEngine, TaskQueue } from "@effectmq/core"; const program = Effect.gen(function* () { + const SendEmail = yield* Task.make({ + name: "send-email", + payload: { to: Schema.String, subject: Schema.String }, + success: Schema.String, + error: Schema.Never, + }); + const emails = TaskQueue.make("emails", SendEmail); + yield* TaskQueue.offer(emails, { to: "ada@example.com", subject: "Welcome" }); yield* TaskQueue.complete(emails, (task) => @@ -39,7 +38,9 @@ const program = Effect.gen(function* () { }); // The engine + its Redis layer: the only wiring you need to run the above. -const AppLayer = Layer.provideMerge(TaskEngine.layer(), NodeRedisPool.layer()); +const AppLayer = TaskEngine.layer({ + redis: { url: "redis://localhost:6379" }, +}); program.pipe(Effect.provide(AppLayer), NodeRuntime.runMain); ``` @@ -50,19 +51,25 @@ That's the shape of it. The rest of this README explains the pieces (typed error ## The setup, once -`TaskEngine.layer()` requires the `RedisPool` service. `NodeRedisPool` — bundled with the package, a connection pool backed by [node-redis](https://github.com/redis/node-redis) — provides it: +`TaskEngine.layer()` is the complete Node live graph: it provides the engine, +cryptographic identity generation, and the retained Redis pool, role, and +health services: ```ts -import { Layer } from "effect"; -import { NodeRedisPool, TaskEngine } from "@effectmq/core"; +import { TaskEngine } from "@effectmq/core"; -const AppLayer = Layer.provideMerge( - TaskEngine.layer(), - NodeRedisPool.layer({ url: "redis://localhost:6379" }), -); +const AppLayer = TaskEngine.layer({ + redis: { url: "redis://localhost:6379" }, +}); ``` -`NodeRedisPool.layer()` accepts node-redis client options and establishes separate producer, worker, and maintenance pools when the Layer starts. It supports standalone Redis and Sentinel; Redis Cluster fails startup because queue transitions use multi-key atomic scripts. See the [operations runbook](./docs/operations.md) for TLS, ACL, bounded-pool, persistence, failover, health, and shutdown guidance. Anything that provides the `RedisPool` service can still be used as a custom integration. +Use `TaskEngine.layerNoDeps()` when composing a custom `RedisPool` +implementation. `NodeRedisPool.layer()` remains available independently and +accepts node-redis client options. It establishes separate producer, worker, +and maintenance pools when the Layer starts. It supports standalone Redis and +Sentinel; Redis Cluster fails startup because queue transitions use multi-key +atomic scripts. See the [operations runbook](./docs/operations.md) for TLS, +ACL, bounded-pool, persistence, failover, health, and shutdown guidance. The tested platform matrix is in the [support policy](./docs/support-policy.md), and reproducible throughput/tail-latency results are published as [performance evidence](./docs/performance.md). @@ -77,7 +84,7 @@ A task is a *schema*, not a function. You declare what goes in (`payload`), what A tagged error makes failures pattern-matchable downstream, so reach for `Schema.TaggedErrorClass` rather than a bare struct. ```ts -import { Schedule, Schema } from "effect"; +import { Effect, Schedule, Schema } from "effect"; import { Task, TaskQueue } from "@effectmq/core"; class EmailRejected extends Schema.TaggedErrorClass()( @@ -85,18 +92,17 @@ class EmailRejected extends Schema.TaggedErrorClass()( { reason: Schema.String }, ) {} -const SendEmail = Task.make({ - name: "send-email", - payload: { to: Schema.String, subject: Schema.String }, - success: Schema.String, // e.g. a provider message id - error: EmailRejected, - // optional, but it's how you ensure the same job isn't enqueued twice - idempotencyKey: (p) => `email:${p.to}:${p.subject}`, - // retry with exponential backoff; maxRetries caps it (default 5) - retry: Schedule.exponential("1 second"), +const queues = Effect.gen(function* () { + const SendEmail = yield* Task.make({ + name: "send-email", + payload: { to: Schema.String, subject: Schema.String }, + success: Schema.String, + error: EmailRejected, + idempotencyKey: (p) => `email:${p.to}:${p.subject}`, + retry: Schedule.exponential("1 second"), + }); + return TaskQueue.make("emails", SendEmail); }); - -const emails = TaskQueue.make("emails", SendEmail); ``` ## Offer work, then do it @@ -107,6 +113,7 @@ const emails = TaskQueue.make("emails", SendEmail); import { Effect } from "effect"; const program = Effect.gen(function* () { + const emails = yield* queues; yield* TaskQueue.offer(emails, { to: "ada@example.com", subject: "Welcome", @@ -238,23 +245,26 @@ retries, and **at-least-once** delivery semantics. import { Cron, Schema } from "effect"; import { Scheduler, Task, TaskQueue, Worker } from "@effectmq/core"; -const reportTask = Task.make({ - name: "nightly-report-task", - payload: { scheduledAt: Schema.String }, - success: Schema.Void, - error: Schema.String, -}); -const reportQueue = TaskQueue.make("nightly-reports", reportTask); - -const nightlyReportSchedule = Scheduler.make({ - name: "nightly-report", - cron: Cron.parseUnsafe("0 2 * * *", "UTC"), - queue: reportQueue, - payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }), - missed: { _tag: "coalesce" }, +const scheduledReports = Effect.gen(function* () { + const reportTask = yield* Task.make({ + name: "nightly-report-task", + payload: { scheduledAt: Schema.String }, + success: Schema.Void, + error: Schema.String, + }); + const reportQueue = TaskQueue.make("nightly-reports", reportTask); + const schedule = yield* Scheduler.make({ + name: "nightly-report", + cron: Cron.parseUnsafe("0 2 * * *", "UTC"), + queue: reportQueue, + payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }), + missed: { _tag: "coalesce" }, + }); + return { + schedule, + worker: Worker.make(reportQueue, () => buildAndSendReport()), + }; }); - -const nightlyReportWorker = Worker.make(reportQueue, () => buildAndSendReport()); ``` --- @@ -269,6 +279,8 @@ const nightlyReportWorker = Worker.make(reportQueue, () => buildAndSendReport()) ## Production guides +- [Architecture](./docs/architecture.md) +- [Runtime boundaries](./docs/runtime-boundaries.md) - [API reference](./docs/api-reference.md) - [Delivery guarantees](./docs/delivery-guarantees.md) - [Idempotent offers](./docs/idempotent-offers.md) diff --git a/docs/api-reference.md b/docs/api-reference.md index cb1cfa5..a31ba92 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,7 +6,7 @@ page describes the intended entry points and their contracts. ## `Task` -- `Task.make(config)` defines payload, success, and typed-failure schemas, +- `Task.make(config)` effectfully validates and defines payload, success, and typed-failure schemas, stable `schemaId`, idempotency key, retry schedule/cap, storage limits, and retention. - `defaultRetentionPolicy` is 7 days for task records and terminal indexes, @@ -39,7 +39,7 @@ duration, heartbeat interval, and bounded heartbeat transport retry. ## `Scheduler` -- `make(config)` creates a long-running durable materializer Effect. +- `make(config)` validates configuration in a typed error channel and creates a long-running durable materializer Effect. - `materializeDue(config, now?)` performs one deterministic bounded observation, useful for tests and externally driven scheduler loops. - Missed policy is `skip`, `coalesce`, or bounded `backfill`. @@ -64,6 +64,20 @@ coordination, ordinary removal, and administrative force removal. Prefer `TaskQueue`, `Worker`, and `Scheduler` unless building tooling or an alternate runtime. +- `TaskEngine.layer(config?)` is the zero-requirement Node live graph and retains + Redis operational services in its output. +- `TaskEngine.layerNoDeps(config?)` requires an ambient `RedisPool` for custom + client compositions. +- Invalid configuration and Redis reply shapes use structured typed errors; + diagnostic strings are retained only as causes. + +## `TaskRecord` and `TaskEvent` + +`TaskRecord` owns public durable task identity/state schemas and typed record +codecs. `TaskEvent` owns public versioned lifecycle event schemas. MessagePack, +raw engine-record, and retry-schedule modules are internal and unsupported as +package subpaths. + ## `StorageProtocol` and `Observability` `StorageProtocol` owns the versioned opaque-value codec and typed corruption, @@ -71,5 +85,6 @@ version, schema, value, size, and count errors. `Observability` exports Effect metrics for depth/age/backlogs, Redis errors/reconnects/script reloads, ownership loss, and retention failure. -Stable subpaths are `./NodeRedisPool`, `./RedisPool`, `./Scheduler`, -`./StorageProtocol`, `./Task`, `./TaskEngine`, `./TaskQueue`, and `./Worker`. +Stable subpaths are `./NodeRedisPool`, `./Observability`, `./RedisPool`, +`./Scheduler`, `./StorageProtocol`, `./Task`, `./TaskEngine`, `./TaskEvent`, +`./TaskQueue`, `./TaskRecord`, and `./Worker`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8f2fb82 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,37 @@ +# Package architecture + +The public surface is concept-oriented and has matching root namespaces and +package subpaths: + +- `Task`, `TaskQueue`, `Worker`, and `Scheduler` own definition, queue, + processing, and scheduling APIs; +- `TaskRecord` owns durable typed task records and `TaskEvent` owns lifecycle + events; +- `TaskEngine` owns atomic queue storage behavior; +- `RedisPool`, `NodeRedisPool`, `StorageProtocol`, and `Observability` own the + external client, live Node adapter, value protocol, and metrics boundaries. + +Internal modules are deliberately not package subpaths. `MessagePack` owns the +binary transform, `EngineRecord` owns Redis-facing record schemas, +`RedisReadiness` owns readiness recovery policy, and `RetrySchedule` owns retry +schedule construction and stepping. + +The dependency direction is: + +```text +Task -> RetrySchedule +TaskQueue -> Task + TaskRecord + TaskContext + TaskEngine + StorageProtocol +Worker/Scheduler -> TaskQueue + TaskEngine +TaskEngine -> EngineRecord + TaskEvent + MessagePack + RedisPool +TaskEvent -> TaskRecord + EngineRecord + MessagePack +NodeRedisPool -> RedisPool + RedisReadiness + Observability +``` + +`TaskEngine.layer()` is the standard zero-requirement Node live graph. It +retains `TaskEngine`, `RedisPool`, `RedisConnectionRoles`, +`RedisConnectionHealth`, Effect Redis, and Crypto services. Custom Redis +integrations provide `RedisPool` to `TaskEngine.layerNoDeps()`. + +Public queue declarations use named exact aliases for success, typed failure, +and required services. The strict test compiler pins `complete`, `completeOne`, +`decodeTask`, `wait`, `execute`, and both TaskEngine layer modes. diff --git a/docs/operations.md b/docs/operations.md index fb1a118..174d7c0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -148,6 +148,12 @@ counters, and timestamps only; it never includes URLs, ACL users, secrets, or raw connection errors. A degraded role should remove the instance from ready traffic while liveness remains healthy long enough for reconnect. +Readiness converts only expected Redis command failures to `false`; defects and +interruption remain observable. Each scoped client removes its event listeners +before shutdown, awaits graceful close, and falls back to forced destroy when +close rejects. If acquisition of a later workload role fails, already-acquired +roles are released by the same scope. + On deployment shutdown, first stop accepting new offers, then stop scheduler materialization, drain workers within a bounded grace period, and close the application Effect scope. If the grace period expires, interrupt the process; diff --git a/docs/runtime-boundaries.md b/docs/runtime-boundaries.md new file mode 100644 index 0000000..eb5bb7a --- /dev/null +++ b/docs/runtime-boundaries.md @@ -0,0 +1,23 @@ +# Runtime boundaries + +EffectMQ contains foreign behavior at the narrowest owning module: + +| Foreign behavior | Owner | Policy | +| --- | --- | --- | +| MessagePack pack/unpack | `MessagePack` and `StorageProtocol` | Capture throws as schema/storage codec failures with stage, path, and cause. | +| Redis promises and reply values | `NodeRedisPool` and `TaskEngine` | Wrap promise rejection; validate every consumed scalar, tuple, collection, byte, and stream shape. | +| node-redis listeners | `NodeRedisPool` | Register before connect, use bounded service-free callback bridges, remove listeners before close. | +| Redis shutdown | `NodeRedisPool` | Await idempotent graceful close; force destroy after rejection; scoped partial acquisition unwinds. | +| Readiness | `RedisReadiness` | Convert only `RedisError` to `false`; preserve defects and interruption. | +| Wall-clock reads | `Scheduler`, `TaskQueue`, `NodeRedisPool` | Read Effect `Clock` during execution; explicit instants remain available to deterministic cores. | +| UUID generation | `Task` and `TaskEngine` | Require Effect `Crypto`; map platform failures to semantic task/engine errors. | +| CLI config/acquisition | `InspectPreReleaseData` | Use Effect `Config`, the scoped Redis layer, typed scan errors, and `NodeRuntime.runMain` only at the executable edge. | + +TaskEngine is also the only place that interprets vendor Redis diagnostics. It +immediately translates them to stable reason tags. Higher modules branch only +on those tags and retain the original value solely as diagnostic ancestry. + +Open-key stream dictionaries use null-prototype records. Tests cover RESP array +and Map representations, malformed values, prototype-sensitive keys, +interruption, rejected close promises, listener removal, and partial +multi-client acquisition. diff --git a/docs/scheduler.md b/docs/scheduler.md index 22134c5..6e84bde 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -11,7 +11,7 @@ cursor only after the offer. A normal `Worker` executes the task with the queue's leases, retries, failure policy, and at-least-once delivery. ```ts -const schedule = Scheduler.make({ +const schedule = yield* Scheduler.make({ name: "nightly-report", cron: Cron.parseUnsafe("0 2 * * *", "America/Sao_Paulo"), queue: reports, @@ -36,3 +36,7 @@ process loss. A scheduler outage does not lose the Redis cursor; on restart the configured missed policy decides what to materialize. Scheduler availability does not imply worker availability, and the reverse is also true. Monitor both cursor lag and target queue age. + +When `materializeDue` is called without `now`, it reads Effect's `Clock` when +the Effect executes. Tests may pass an explicit instant; construction time and +ambient `Date.now` do not influence materialization. diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/.openspec.yaml b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/design.md b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/design.md new file mode 100644 index 0000000..c6c2470 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/design.md @@ -0,0 +1,71 @@ +## Context + +See `proposal.md` for motivation. Vitest currently transpiles test TypeScript with an empty raw tsconfig while `tsconfig.json` excludes tests and testing support. The Redis harness warms a module-level `ManagedRuntime` to amortize container startup, but does not deliberately dispose it. At least five strict test type errors are therefore invisible to `pnpm typecheck`, and several suites coordinate through sleeps or direct runtime calls. + +## Goals / Non-Goals + +**Goals:** + +- Make Effect failures, defects, services, and scopes native to the test runner. +- Share expensive integration resources without process-global unmanaged state. +- Make the entire test surface part of the TypeScript and release contract. +- Make unit-level timing and concurrency deterministic. + +**Non-Goals:** + +- Eliminate real Redis integration tests or container reuse within a suite scope. +- Force Redis server clocks and TTLs onto Effect `TestClock`. +- Replace Vitest assertions or rewrite pure synchronous tests as Effects. +- Preserve the current `TestRuntime.runPromise` helper API. + +## Decisions + +### 1. Use `@effect/vitest` for Effectful tests + +Add the Effect-version-matched `@effect/vitest` package. Effectful cases use `it.effect`; suites needing services use its layer facility so acquisition and release are owned by the suite scope. Pure schema/value tests continue using ordinary Vitest `it`. Assertions remain explicit Vitest assertions inside the Effect body. + +A custom wrapper around `Effect.runPromise` was rejected because it recreates runner integration and hides structured Effect causes. A module-level `ManagedRuntime` was rejected because sharing and disposal are coupled to import lifetime rather than a test scope. + +### 2. Share Redis through a suite-scoped Layer + +`src/testing` exposes a Redis integration Layer that acquires the container or configured local server, clients, and package services with `Effect.acquireRelease`. Suites install that layer once at the narrowest useful scope. Long container startup is handled by an explicit suite/hook timeout and health check, not by top-level warming. Fault and Sentinel variants compose their own layers from the same acquisition primitives. + +Starting a container per test was rejected as unnecessarily slow. Process-global caching was rejected because failures and interruption cannot reliably release ownership. + +### 3. Add a dedicated strict test compiler program + +Create `tsconfig.test.json` extending production compiler options and including all `src/**/*.test.ts`, `src/testing/**/*.ts`, and type-contract fixtures. Add `typecheck:test` and make `typecheck`/`check`/CI run both production and test programs. Vitest's esbuild transformation remains an execution optimization, never the diagnostic typechecker. + +Expanding the production build config to emit tests was rejected because tests must not enter `dist`. Maintaining a one-off shell list of test files was rejected because it can silently miss new files. + +### 4. Assert public channels without suppression comments + +Dedicated `*.types.test.ts` files use compile-time equality/assignability helpers and Vitest's type assertions to inspect `Effect.Success`, `Effect.Error`, and `Effect.Context` for public operations. Tests cover both positive exact equality and guards that detect `any`/`unknown`. They do not rely on `@ts-expect-error`, casts, or runtime execution to prove types. + +Snapshotting generated declarations was rejected because textual snapshots are noisy and do not prove assignability. Type-suppression tests were rejected because they can continue passing after an unrelated error changes. + +### 5. Use virtual time and explicit latches by default + +Retry schedules, worker coordination, cancellation, and timeouts use `TestClock`, `Deferred`, `Latch`, `Queue`, or observable events. Tests advance time only after the relevant fiber is known to be waiting. Redis TTL, restart, and Sentinel failover tests remain real-time integration tests; they use bounded retry/poll effects with useful timeout diagnostics instead of fixed sleeps. File naming or test metadata distinguishes these suites. + +Mocking `Date.now` was rejected because it does not control Effect scheduling. Applying TestClock to Redis server expiration was rejected because the server owns that clock. + +### 6. Test infrastructure obeys production boundary rules + +Testcontainers, node-redis, and ioredis promises use `Effect.tryPromise` with focused `TestInfrastructureError` reasons. Acquired resources attach their finalizers immediately. Infallible `Effect.succeed` and `Effect.promise` are used only when the callback is demonstrably non-throwing/non-rejecting. Cleanup tests exercise success, failed acquisition, assertion failure, timeout, and interruption. + +## Risks / Trade-offs + +- [Suite-layer API changes across Effect beta versions] → Pin `@effect/vitest` to the exact Effect beta used by development dependencies and update them together. +- [Shared integration layers permit cross-test state leakage] → Allocate unique queue prefixes per test and reset only resources owned by that prefix; keep parallelism explicit. +- [Strict test compilation initially creates a large migration] → Fix the five known errors first, then migrate suites incrementally while keeping `typecheck:test` mandatory once green. +- [Leak detection hangs CI] → Use bounded finalizer timeouts and report the identities of remaining resources/fibers before failing. + +## Migration Plan + +1. Add `@effect/vitest`, `tsconfig.test.json`, type-contract helpers, and CI scripts; fix the five currently known strict test errors. +2. Build the scoped Redis/container Layers and lifecycle tests. +3. Migrate integration suites from `TestRuntime.runPromise` to Effect-aware suite layers, then delete the ManagedRuntime runner. +4. Migrate isolated direct runtime calls and unsafe test promise boundaries. +5. Replace unit sleeps with virtual time/latches and classify unavoidable real-time Redis suites. +6. Update contributor guidance and release checks; run typecheck, lint, unit, integration, fault, package, and leak gates. Rollback is a release revert because the old runner utility is removed. diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/proposal.md b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/proposal.md new file mode 100644 index 0000000..df53d55 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/proposal.md @@ -0,0 +1,25 @@ +## Why + +Production typechecking currently excludes tests, the test harness uses a warmed `ManagedRuntime` as a runner without disposing it, and timing tests rely on sleeps and wall-clock scheduling. Consequently, passing tests can conceal invalid Effect contracts, resource leaks, and nondeterministic behavior. + +## What Changes + +- **BREAKING** Replace direct `Effect.runPromise` and module-level `ManagedRuntime` execution with `@effect/vitest` Effect tests and suite-scoped test layers. +- Make Redis test infrastructure acquire and release containers, clients, and layers through Effect scopes with typed promise adaptation. +- Add strict compilation of every test and testing-support file, plus compile-time contract assertions for public Effect error and service channels. +- Replace arbitrary sleeps and wall-clock timing assertions with `TestClock`, `Deferred`, latches, or explicit real-time integration boundaries. +- Update repository guidance and CI so typecheck, lint, unit tests, integration tests, and lifecycle checks enforce the same model. + +## Capabilities + +### New Capabilities + +- `effect-test-harness`: Defines the Effect-native test runner, scoped layer lifecycle, strict typechecking, compile-time contract tests, and deterministic concurrency/timing rules. + +### Modified Capabilities + +- `production-release`: Requires the release gate to compile the complete test surface and detect leaked test resources in addition to executing the behavioral suites. + +## Impact + +The change adds `@effect/vitest`, replaces shared test-runtime utilities, touches all Effectful test suites, adds a dedicated strict test typecheck configuration, and changes CI and contributor documentation. Test setup may become structurally different, but runtime product behavior is unchanged. diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/effect-test-harness/spec.md b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/effect-test-harness/spec.md new file mode 100644 index 0000000..2c5c007 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/effect-test-harness/spec.md @@ -0,0 +1,61 @@ +## Purpose + +Defines an Effect-native test harness whose execution, typechecking, timing, and resource lifecycle provide trustworthy evidence about the package's public contracts. + +## ADDED Requirements + +### Requirement: Effect tests use the Effect-aware runner +Tests whose body is an Effect SHALL execute through the project's Effect-aware Vitest integration and SHALL receive dependencies through test layers. Test bodies SHALL NOT manually call Effect runtimes or use a `ManagedRuntime` as a general-purpose runner. + +#### Scenario: Queue integration test runs +- **WHEN** a queue integration test needs Redis and package services +- **THEN** the test declares an Effect body under a suite-scoped layer +- **AND** the runner reports Effect failures and defects with their structured causes + +### Requirement: Test resources are scoped and released +Containers, Redis clients, listeners, fibers, and test layers SHALL be acquired and released by Effect scopes. Suite completion SHALL dispose every acquired resource under success, test failure, timeout, and interruption. + +#### Scenario: Test assertion fails after Redis acquisition +- **WHEN** an assertion fails after a suite layer has acquired Redis resources +- **THEN** the suite scope closes all owned clients and containers + +#### Scenario: Test suite completes +- **WHEN** the final test using a shared suite layer finishes +- **THEN** no warmed runtime, client, container, listener, or supervised fiber remains live + +### Requirement: Complete test source is strictly typechecked +Every test and testing-support TypeScript file SHALL compile under strict settings compatible with production. The test runner's transpilation path SHALL NOT substitute for this diagnostic typecheck. + +#### Scenario: Test fixture passes a malformed option +- **WHEN** a test supplies an option shape that is not accepted by the public API +- **THEN** the test typecheck fails before the behavioral suite runs + +### Requirement: Public Effect contracts have compile-time assertions +The test suite SHALL assert the exact success, failure, and service channels of public Effect APIs whose contracts compose other operations. Assertions SHALL cover completion, task decoding, waiting, and execution and SHALL fail if a channel widens to `any`, `unknown`, or omits a required member. + +#### Scenario: Execute loses a service requirement +- **WHEN** an implementation annotation accidentally removes a schema service from `execute` +- **THEN** a compile-time contract test fails + +#### Scenario: CompleteOne widens to any +- **WHEN** the one-item completion failure channel becomes `any` +- **THEN** a compile-time assertion rejects the declaration + +### Requirement: Concurrency and timing tests are deterministic +Unit tests SHALL coordinate fibers with virtual time and explicit synchronization primitives rather than arbitrary sleeps or wall-clock race windows. Integration tests that necessarily exercise Redis server time SHALL be labeled as real-time tests and use bounded polling or event latches with documented timeouts. + +#### Scenario: Retry delay is tested +- **WHEN** a unit test verifies a retry scheduled after a duration +- **THEN** it advances virtual time and observes the transition without waiting for wall-clock time + +#### Scenario: Redis TTL is tested +- **WHEN** an integration test verifies server-owned expiration +- **THEN** it uses a bounded real-time wait classified as integration behavior +- **AND** a timeout produces a diagnostic failure rather than a flaky fixed sleep + +### Requirement: Test foreign APIs use typed adapters +Testing support that calls promise- or callback-based external APIs SHALL wrap them with Effect's fallible asynchronous boundaries and semantic test-infrastructure errors. It SHALL NOT place throwing work in infallible Effect constructors. + +#### Scenario: Container startup rejects +- **WHEN** the test container library rejects during startup +- **THEN** suite acquisition fails with a typed test-infrastructure error retaining the original cause diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/production-release/spec.md b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/production-release/spec.md new file mode 100644 index 0000000..9e97ff2 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/specs/production-release/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: The complete test program is type- and lifecycle-checked +Release CI SHALL strictly typecheck production source, every test, every testing-support module, and public contract assertions before executing behavioral suites. Test completion SHALL also verify that suite-owned runtimes, Redis clients, containers, listeners, and fibers have been released. + +#### Scenario: Test-only type error is introduced +- **WHEN** a release commit contains a strict TypeScript error only in an excluded test file +- **THEN** the release gate fails before publication + +#### Scenario: Shared integration resource leaks +- **WHEN** an integration suite completes while a suite-owned resource remains undisposed +- **THEN** the release gate fails with lifecycle diagnostics diff --git a/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/tasks.md b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/tasks.md new file mode 100644 index 0000000..63b3f09 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-adopt-effect-native-testing/tasks.md @@ -0,0 +1,48 @@ +## 1. Install and Enforce the Test Toolchain + +- [x] 1.1 Add `@effect/vitest` pinned to the exact Effect beta used by development dependencies. +- [x] 1.2 Add `tsconfig.test.json` extending strict production options and including every test, testing-support, and type-contract file without emitting them. +- [x] 1.3 Add `typecheck:test` and wire production plus test typechecks into `typecheck`, `check`, and CI before behavioral tests. +- [x] 1.4 Remove the empty `tsconfigRaw` diagnostic bypass from Vitest configuration or document it as transform-only after strict compilation is mandatory. + +## 2. Repair the Existing Test Type Surface + +- [x] 2.1 Fix the NodeRedisPool union narrowing error in `NodeRedisPool.test.ts` without a cast. +- [x] 2.2 Fix the invalid Redis restart socket option shape in `RedisRestart.test.ts`. +- [x] 2.3 Fix the conditional retry schedule overload mismatch in `Scheduler.test.ts` with a correctly typed test value. +- [x] 2.4 Fix the TaskQueue handler error-channel mismatch and unknown-to-number assignment in `TaskQueue.test.ts`. +- [x] 2.5 Run the strict test compiler and resolve any additional errors without assertions or suppressions. + +## 3. Add Public Contract Type Tests + +- [x] 3.1 Add reusable compile-time helpers that detect exact equality plus `any` and `unknown` channels without suppression comments. +- [x] 3.2 Add positive exact assertions for `complete`, `completeOne`, stored-task decoding, `wait`, and `execute` success/error/context types. +- [x] 3.3 Add mutation checks or focused fixtures proving the contract suite fails when a required service/error is erased or widened. + +## 4. Build Scoped Integration Layers + +- [x] 4.1 Define typed test-infrastructure errors and wrap Testcontainers, node-redis, and ioredis promises with fallible Effect adapters. +- [x] 4.2 Build suite-scoped Layers for container-backed Redis, configured local Redis, Sentinel, and fault injection using `Effect.acquireRelease`. +- [x] 4.3 Configure explicit acquisition/hook timeouts and health checks instead of module-import warming. +- [x] 4.4 Add lifecycle tests for successful release, partial acquisition failure, assertion failure, timeout, interruption, and listener/fiber cleanup. + +## 5. Migrate Test Execution + +- [x] 5.1 Migrate Redis-backed suites to `@effect/vitest` Effect cases and the narrowest shared suite layer. +- [x] 5.2 Migrate isolated async Vitest callbacks and direct `Effect.runPromise` calls, including the direct TaskQueue execution test, to Effect-aware cases. +- [x] 5.3 Replace unsafe `Effect.promise`/`Effect.succeed` test boundaries with typed adapters where callbacks can reject or throw. +- [x] 5.4 Delete the warmed ManagedRuntime runner and verify no test-owned runtime remains undisposed. +- [x] 5.5 Preserve ordinary Vitest cases for pure synchronous/value tests and keep assertions explicit. + +## 6. Make Timing and Concurrency Deterministic + +- [x] 6.1 Replace unit-test sleeps for retries, worker coordination, and cancellation letswith TestClock and explicit Deferred/Latch/Queue synchronization. +- [x] 6.2 Advance virtual time only after tested fibers signal that they are waiting, preventing scheduler races. +- [x] 6.3 Classify Redis TTL, restart, and Sentinel tests as real-time integration tests and replace fixed sleeps with bounded polling or event latches. +- [x] 6.4 Add actionable timeout diagnostics showing the awaited state, queue prefix, and outstanding resource/fiber identities. + +## 7. Documentation and Release Verification + +- [x] 7.1 Update `CLAUDE.md` and contributor documentation to require Effect-aware tests, suite-scoped layers, strict test typechecking, and deterministic timing. +- [x] 7.2 Add a release lifecycle gate that detects or times out on undisposed test resources. +- [x] 7.3 Run production/test typechecks, lint, unit/integration/fault suites, deterministic timing tests, lifecycle checks, docs checks, and packed-package verification. diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/.openspec.yaml b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/design.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/design.md new file mode 100644 index 0000000..493880c --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/design.md @@ -0,0 +1,73 @@ +## Context + +See `proposal.md` for motivation. The node-redis adapter already centralizes most client access, but its promise conversion, readiness recovery, finalization, and reply normalization are inconsistent. Domain modules also read `Date`, `Date.now`, and `crypto.randomUUID` directly, and the inspection CLI constructs and closes Redis imperatively. + +## Goals / Non-Goals + +**Goals:** + +- Make one module own each external API and translate its failures once. +- Preserve Effect cancellation, defects, and service substitution across every boundary. +- Make Redis resources leak-free under success, failure, and interruption. +- Validate untyped Redis data before domain construction. + +**Non-Goals:** + +- Replace node-redis or change supported Redis topologies. +- Treat Redis event callbacks as ordinary Effect APIs; a small foreign callback bridge remains necessary. +- Virtualize Redis server time or convert every integration timing test to `TestClock`. +- Change MessagePack bytes or the Redis key layout. + +## Decisions + +### 1. NodeRedisPool is the single node-redis boundary + +All client promises, event callbacks, reply normalization, and connection lifecycle stay inside `NodeRedisPool`. Helpers use `Effect.tryPromise` (or async with a canceler where the API supports cancellation) and map rejections into the semantic engine/Redis errors defined by `make-effect-contracts-honest`. Domain modules never inspect node-redis error classes or messages. + +Scattering `tryPromise` at call sites was rejected because it duplicates vendor translation and allows raw client values to leak into domain code. + +### 2. Recovery handles only typed failures + +Readiness and health operations catch only the explicit Redis failure channel. `Cause`-wide recovery is reserved for logging followed by re-failure when the policy truly applies to every cause. Interruption and defects therefore retain their original semantics. + +Returning `false` from `catchCause` was rejected because it makes cancellation and programmer defects indistinguishable from an unavailable server. + +### 3. Connections and listeners are acquired with Scope + +Pool construction uses `Effect.acquireRelease`/`acquireUseRelease` per owned client and composes them in one layer scope. Finalizers use typed promise adapters and an explicit shutdown policy: attempt graceful close, fall back to forced destruction only for the documented close failures, aggregate diagnostics, and never leave a floating promise. Listener registrations are removed on release. The existing short `runFork` event callbacks remain a confined foreign bridge; their bodies must require no services and do bounded, immediate work. If that changes, the adapter will capture a scoped runtime or FiberSet and supervise the fibers. + +A process-global `ManagedRuntime` was rejected because it weakens ownership and introduces manual disposal obligations. + +### 4. Clock and randomness are read inside Effects + +All timestamps use Effect `Clock` at the point of execution. UUID/default-id generation uses an Effect-owned cryptographic randomness capability rather than a default function that calls ambient `crypto`. Pure functions continue accepting explicit instants or identifiers as values. Scheduler materialization retains an explicit-time pure core, while its live wrapper reads Clock and passes the value in. + +Passing `new Date()` as a default parameter was rejected because default evaluation can occur before the Effect runs. Keeping injectable callback defaults was rejected because the default still bypasses Effect services. + +### 5. Redis replies pass through operation-specific decoders + +Each Redis command family has a decoder that accepts `unknown` and produces a validated domain value or `InvalidRedisReply`. Stream tuple shape, nullable replies, numeric bounds, buffers, and text values are checked explicitly. Dynamic keyed collections use `Map` by default; where an object is required for an API, it is created with a null prototype and encoded immediately. + +Broad casts and generic `String(value)` conversion were rejected because they silently accept protocol drift. Plain `{}` records were rejected for untrusted keys because prototype names have special behavior. + +### 6. The CLI is one scoped Effect program + +The inspection command reads its URL and options through Effect Config, acquires the same Redis adapter/layer as the library, runs the bounded scan, and releases through Scope. `NodeRuntime.runMain` is called only in the executable entry module. The scan loop may remain locally imperative inside one wrapped operation if that produces clearer code, but it cannot own connection lifecycle or leak untyped rejections. + +Keeping `try/finally` around raw awaits was rejected because it creates a second lifecycle and error model outside the package architecture. + +## Risks / Trade-offs + +- [Explicit Clock/randomness requirements widen public environments] → Publish exact aliases and provide them through the standard live layer; tests can substitute deterministic services. +- [Strict reply validation rejects values previously coerced] → Include operation and a bounded representation of the received value in errors, and add compatibility fixtures for every supported RESP form. +- [Shutdown errors obscure the primary failure] → Preserve the primary cause and attach finalizer diagnostics using Effect's cause composition/logging policy. +- [Removing listeners races an in-flight callback] → Keep callback bodies bounded and make release idempotent; add interruption and reconnect lifecycle tests. + +## Migration Plan + +1. Land the semantic error algebra from `make-effect-contracts-honest` or introduce compatible internal placeholders. +2. Add operation-specific Redis reply decoders and replace casts from the leaves inward. +3. Convert connection acquisition, listeners, readiness, and finalization to scoped typed adapters. +4. Move time and UUID reads into Effect services and update dependent public contracts. +5. Rewrite the CLI as a scoped Effect entry point. +6. Run malformed-reply, interruption, Redis restart, Sentinel, and resource-leak tests. Rollback is release-wide because the service requirements intentionally break source compatibility. diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/proposal.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/proposal.md new file mode 100644 index 0000000..98c07cb --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/proposal.md @@ -0,0 +1,26 @@ +## Why + +Redis, the CLI, wall-clock time, randomness, and JavaScript promise callbacks are external boundaries, but several paths currently bypass Effect services, trust unchecked replies, or erase defects and interruption. Those shortcuts make lifecycle, determinism, and failure behavior depend on ambient process state. + +## What Changes + +- Move CLI configuration, Redis acquisition, execution, and shutdown into one scoped Effect program with `NodeRuntime.runMain` at the process edge. +- Wrap every promise- or callback-based Redis operation at its owning boundary with semantic typed errors and scoped finalizers; never swallow defects or interruption while interpreting readiness failures. +- **BREAKING** Require Effect `Clock` and cryptographic randomness services for timestamps and generated task identities instead of reading `Date`, `Date.now`, or `crypto.randomUUID` directly. +- Validate all Redis replies before use, including stream replies and text conversion, and fail with a semantic invalid-reply error instead of asserting shapes. +- Replace prototype-bearing dynamic records with safe maps or null-prototype dictionaries at untrusted-key boundaries. + +## Capabilities + +### New Capabilities + +- `effect-runtime-boundaries`: Defines Effect-owned configuration, time, randomness, promise adaptation, resource lifetime, and external-data validation rules. + +### Modified Capabilities + +- `redis-operations`: Requires Redis connection lifecycle, readiness, and reply decoding to preserve semantic failures, defects, interruption, and scoped cleanup. +- `storage-protocol`: Requires externally sourced Redis values to be structurally validated before storage records or events are constructed. + +## Impact + +The change affects `NodeRedisPool`, `TaskEngine`, `TaskQueue`, `Scheduler`, `Task`, the inspection CLI, Redis adapters, and tests that currently rely on ambient time or UUID generation. Public effects gain explicit `Clock` and randomness requirements where those capabilities are actually used. diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/effect-runtime-boundaries/spec.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/effect-runtime-boundaries/spec.md new file mode 100644 index 0000000..771eb66 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/effect-runtime-boundaries/spec.md @@ -0,0 +1,46 @@ +## Purpose + +Defines how effectmq owns ambient capabilities, asynchronous JavaScript integrations, and resource lifetimes so execution remains typed, deterministic, and interruptible. + +## ADDED Requirements + +### Requirement: Ambient capabilities are explicit +Operations that observe time, generate task identities, or read process configuration SHALL obtain those capabilities from their Effect environment at execution time. They SHALL NOT capture wall-clock time when an Effect is constructed or read ambient randomness/configuration inside domain logic. + +#### Scenario: Delayed effect observes current execution time +- **WHEN** an Effect is constructed and executed after the clock has advanced +- **THEN** its timestamp is based on execution time rather than construction time + +#### Scenario: Generated task identity is deterministic in a test +- **WHEN** a caller supplies a deterministic cryptographic-randomness service +- **THEN** default task identity generation uses that service + +### Requirement: Asynchronous foreign APIs are adapted honestly +Promise and callback integrations SHALL be wrapped at the module that owns the foreign API. Predictable rejections SHALL become semantic typed errors, cancellation SHALL interrupt cancelable work when supported, and defects or interruption SHALL NOT be caught and reclassified as an expected negative result. + +#### Scenario: Readiness check is interrupted +- **WHEN** a fiber checking Redis readiness is interrupted +- **THEN** interruption propagates instead of being converted to `false` + +#### Scenario: Redis promise rejects +- **WHEN** a Redis client promise rejects with an expected connection failure +- **THEN** the owning adapter returns a semantic typed error containing the original cause + +### Requirement: Resources are scoped +Every acquired Redis client, runtime bridge, listener registration, and test or CLI resource SHALL have a release action attached to the same Effect scope. Release failures SHALL be observed according to the public shutdown policy rather than becoming untracked promise rejections. + +#### Scenario: CLI inspection is interrupted +- **WHEN** the inspection process is interrupted while scanning +- **THEN** the Redis connection is closed by scope finalization before process exit + +#### Scenario: Connection acquisition fails midway +- **WHEN** one resource in a multi-connection pool fails after earlier resources were acquired +- **THEN** every successfully acquired resource is released + +### Requirement: Process entry points stay at the edge +Command-line entry points SHALL compose configuration, layers, logging, and the application as a single Effect and invoke the runtime only once at the outermost process boundary. + +#### Scenario: Redis URL is absent +- **WHEN** the inspection command starts without required Redis configuration +- **THEN** it exits through a typed configuration failure rendered by the process runner +- **AND** no Redis client is constructed diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/redis-operations/spec.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/redis-operations/spec.md new file mode 100644 index 0000000..8d47f07 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/redis-operations/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Redis connection lifecycle is scope-safe +Every Redis connection used by the library SHALL be acquired and released within an Effect scope. Connection and close failures SHALL retain their semantic typed identity, while defects and interruption SHALL propagate unchanged. + +#### Scenario: Pool scope closes normally +- **WHEN** the Redis pool scope closes after successful use +- **THEN** each owned client is closed exactly once + +#### Scenario: Readiness command defects +- **WHEN** a readiness command terminates with a defect rather than an expected connection failure +- **THEN** the readiness operation preserves the defect instead of reporting the connection as merely unready + +### Requirement: Redis replies are validated before use +Every Redis reply crossing into queue logic SHALL be validated against its expected scalar, tuple, collection, or stream shape. An unexpected reply SHALL fail with a typed invalid-reply error containing operation context and SHALL NOT be coerced or accepted through a type assertion. + +#### Scenario: Stream reply has an invalid tuple +- **WHEN** a stream read returns an entry with a malformed field/value tuple +- **THEN** decoding fails with an invalid-reply error before an event is constructed + +#### Scenario: Text conversion receives an unsupported value +- **WHEN** a Redis result cannot be converted using the explicitly supported text representations +- **THEN** the adapter fails with an invalid-reply error rather than stringifying the value implicitly diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/storage-protocol/spec.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/storage-protocol/spec.md new file mode 100644 index 0000000..8799064 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/specs/storage-protocol/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Untrusted keyed data is prototype safe +Collections populated from externally controlled keys SHALL use a representation that cannot mutate or inherit JavaScript object prototypes. Key values such as `__proto__`, `constructor`, and `prototype` SHALL be preserved as ordinary data or rejected by an explicit schema rule. + +#### Scenario: External key is __proto__ +- **WHEN** a Redis field or decoded record contains the key `__proto__` +- **THEN** processing does not alter the collection's prototype +- **AND** the key is handled according to the collection's documented data semantics diff --git a/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/tasks.md b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/tasks.md new file mode 100644 index 0000000..fda56c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-harden-effect-runtime-boundaries/tasks.md @@ -0,0 +1,41 @@ +## 1. Boundary Characterization + +- [x] 1.1 Inventory every node-redis promise, callback, listener, cast, text conversion, ambient time/random read, and CLI resource acquisition and assign each to an owning boundary. +- [x] 1.2 Add regression tests for readiness interruption/defects, rejected close promises, partial pool acquisition, malformed replies, and prototype-sensitive keys. + +## 2. Redis Promise and Lifecycle Boundary + +- [x] 2.1 Add focused typed adapters for node-redis promise operations using the semantic errors from `make-effect-contracts-honest`. +- [x] 2.2 Convert each Redis connection to scoped acquisition with an immediately registered, idempotent typed finalizer. +- [x] 2.3 Define and implement graceful-close versus forced-destroy policy without floating promises. +- [x] 2.4 Ensure partial multi-client acquisition releases every already-acquired client. +- [x] 2.5 Restrict readiness recovery to expected Redis failures so defects and interruption propagate unchanged. +- [x] 2.6 Register and remove node-redis event listeners with pool scope; document and test the bounded service-free callback bridge. + +## 3. Redis Reply Validation + +- [x] 3.1 Implement operation-specific decoders for scalar, nullable, tuple, collection, buffer, and stream replies accepted from Redis. +- [x] 3.2 Replace `asText` coercion with explicit supported text representations and typed invalid-reply failures. +- [x] 3.3 Replace XREAD/stream and command-result casts with validation before domain event or record construction. +- [x] 3.4 Replace open-key plain objects with `Map` or null-prototype dictionaries and test `__proto__`, `constructor`, and `prototype` keys. +- [x] 3.5 Add fixtures for every supported RESP representation and unexpected reply shape. + +## 4. Explicit Time and Randomness + +- [x] 4.1 Replace scheduler default-parameter and `Date.now` reads with execution-time Clock access while retaining an explicit-time pure materialization core. +- [x] 4.2 Replace TaskQueue retry timestamps with Clock access inside the Effect. +- [x] 4.3 Replace ambient UUID defaults with an Effect-owned cryptographic randomness service and update public requirement aliases. +- [x] 4.4 Add deterministic clock/identity tests proving construction time and ambient process globals do not affect execution. + +## 5. Effect-Native CLI + +- [x] 5.1 Define typed CLI configuration for Redis URL and inspection options using Effect Config. +- [x] 5.2 Reuse the scoped Redis layer in the inspection command and move scan failures into semantic typed errors. +- [x] 5.3 Compose the command as one scoped Effect and call `NodeRuntime.runMain` only in the executable entry module. +- [x] 5.4 Add CLI tests for missing configuration, scan failure, normal shutdown, and interruption cleanup. + +## 6. Verification + +- [x] 6.1 Run malformed-reply, prototype-safety, lifecycle, interruption, Redis restart, and Sentinel failover suites. +- [x] 6.2 Run production/test typechecks, lint, unit/integration/fault tests, CLI smoke tests, and packed-package verification. +- [x] 6.3 Update boundary and operational documentation with the new Clock/randomness requirements, invalid-reply errors, and shutdown behavior. diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/.openspec.yaml b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/design.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/design.md new file mode 100644 index 0000000..0c11943 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/design.md @@ -0,0 +1,74 @@ +## Context + +See `proposal.md` for motivation. The package uses Effect's `Effect` type as its public contract, but several explicit return annotations and schema casts currently remove real `E` and `R` members. MessagePack and byte conversion can throw synchronously, and queue policy infers Redis conditions from diagnostic strings. Breaking changes are acceptable, so the design optimizes for one truthful model rather than compatibility shims. + +## Goals / Non-Goals + +**Goals:** + +- Make generated declarations a complete account of caller obligations and recoverable outcomes. +- Establish one semantic error boundary between Redis/client details and queue policy. +- Treat invalid public configuration and corrupt stored input as typed failures. +- Make compound APIs derive their contracts compositionally. + +**Non-Goals:** + +- Preserve source compatibility with current constructors or narrowed error unions. +- Change the current storage encoding or protocol version. +- Collapse all failures into one universal package error. +- Convert programmer invariants and impossible internal states into recoverable errors. + +## Decisions + +### 1. Infer implementations, publish named exact contracts + +Reusable implementations will be defined so TypeScript infers their real effects, then checked against exported operation-specific aliases such as `CompleteError`, `CompleteRequirements`, `WaitError`, and `ExecuteError`. `completeOne` will reuse the same aliases rather than introducing `any`. Declaration tests will inspect assignability of both `E` and `R`. + +The alternative—leaving every signature fully anonymous—would be truthful but difficult for consumers to read and for maintainers to regression-test. A single broad `EffectMqError` was rejected because it would erase recovery distinctions. + +### 2. Compound operations are algebraic unions of their steps + +`execute` will be implemented and typed as `offer` followed by `wait`, retaining the `TaskFailed` terminal wrapper and every protocol, retention, engine, storage, and schema failure from either step. Its requirements are the union of the engine plus payload encoding and success/failure decoding services. `complete` similarly includes the engine, payload decoding, result/failure encoding, and handler environment. + +Mapping the terminal wrapper back to raw task failure was rejected because it makes `execute` disagree with the documented handle protocol and loses attempt/generation context. + +### 3. Constructors validate in Effect + +`Task.make`, scheduler construction, and engine configuration will return Effects with focused tagged configuration errors. Values are checked once at construction; successfully constructed descriptors are valid by construction. There will be no implicit throwing compatibility overload. If an internal constant needs a non-effectful constructor, it will use a private helper after local proof of validity. + +Keeping public pure constructors that throw was rejected because callers cannot see or compose the failure. Adding public `unsafeMake` variants was rejected unless a concrete bootstrap use case appears. + +### 4. The engine owns Redis error translation + +The TaskEngine boundary will expose stable structured failures. Recoverable domain conditions such as relationship-limit and indeterminate-write remain distinct tagged errors. Lower-level operational failures use a `TaskEngineError` with a tagged `reason` union such as transport failure, script failure, invalid reply, and unsupported response, plus optional diagnostic cause. TaskQueue switches only on tags/reasons. + +Retaining a free-form `message`/`cause` error and helper regexes was rejected because client versions and Redis deployments can change wording without changing semantics. + +### 5. All codec exceptions are captured at the codec boundary + +MessagePack pack/unpack and byte conversion will use fallible schema transforms or `Effect.try` at the smallest boundary. Caught values are translated into the package's typed encoding/decoding errors with path and cause details. Structural schema validation remains separate from serializer failure so diagnostics retain the failed stage. No `as Uint8Array` cast is allowed to stand in for input validation. + +Replacing MessagePack with another codec was rejected because this change is about failure semantics and must not silently alter stored bytes. + +### 6. TaskQueue exposes complete lifecycles only + +The high-level TaskQueue module will stop exporting `extendLock` and `release` operations that accept its private attempt shape without exposing a corresponding acquisition operation. Worker continues to own that internal typed attempt lifecycle. Applications implementing a fully custom low-level worker use TaskEngine, whose public `take`, attempt, fencing, extension, and release operations form a complete abstraction. + +Adding another public TaskQueue take API was rejected because it would duplicate Worker/TaskEngine lifecycle policy and widen the high-level surface merely to justify two orphaned methods. + +## Risks / Trade-offs + +- [Large compile-time blast radius from newly honest `E` and `R`] → Migrate leaf codecs and engine errors first, then allow compiler failures to drive queue and consumer updates. +- [Effectful constructors add call-site ceremony] → Validate once and keep the resulting descriptors pure; provide clear examples using `yield*` and layers. +- [Error algebra becomes too granular] → Add public variants only when callers can act differently; keep vendor diagnostics inside a bounded engine error reason. +- [Codec wrapping accidentally changes wire bytes] → Add golden byte fixtures and round-trip/corruption tests before replacing the existing transforms. + +## Migration Plan + +1. Add failing declaration/type tests for the exact completion, decode, wait, and execute contracts. +2. Introduce typed configuration and codec errors, then make constructors/codecs effectful. +3. Define the semantic engine error algebra and translate Redis/client failures at their owning boundary. +4. Remove string parsing and service-erasing casts; let inferred contracts propagate through TaskQueue. +5. Remove the orphaned high-level attempt operations and point custom low-level integrations to TaskEngine. +6. Update all internal call sites, examples, and package declarations in one breaking release. +7. Run storage golden fixtures, strict typechecks, and the full Redis integration suite. Rollback requires reverting the release as a unit; no mixed old/new source API is supported. diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/proposal.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/proposal.md new file mode 100644 index 0000000..6a0677f --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/proposal.md @@ -0,0 +1,29 @@ +## Why + +Several public APIs claim narrower Effect error and service types than their implementations actually require, while predictable configuration, serialization, and storage failures can escape as defects. This makes successful compilation an unreliable description of what a caller must provide or handle. + +## What Changes + +- **BREAKING** Make every public `TaskQueue`, task-codec, and engine operation expose its complete error and service requirements, with named public aliases for reusable contracts and no `any` or service-erasing casts. +- **BREAKING** Make `execute` preserve the full offer-and-wait protocol, including typed wrapper failures, generation/cursor failures, retention failures, and all schema services. +- **BREAKING** Remove high-level queue operations that require an attempt value callers cannot obtain; custom low-level worker integrations use the coherent TaskEngine acquisition API. +- **BREAKING** Replace message- and regex-based recovery with a stable semantic TaskEngine error algebra translated at the Redis boundary. +- **BREAKING** Return typed configuration errors from public task, scheduler, and engine construction instead of throwing or dying for predictable invalid input. +- Route MessagePack, byte conversion, and schema failures through the typed error channel so malformed external data cannot become a defect. + +## Capabilities + +### New Capabilities + +- `effect-api-contracts`: Defines honest public Effect error/service contracts and typed validation behavior for constructors. + +### Modified Capabilities + +- `storage-protocol`: Strengthens typed corruption handling so serializer and byte-conversion exceptions cannot escape as defects. +- `task-events-stream`: Makes `execute` explicitly expose the same typed terminal protocol and schema requirements as `offer` followed by `wait`. +- `redis-operations`: Replaces diagnostic-string recovery with stable semantic engine failures, including indeterminate writes and relationship limits. +- `scheduler-delivery`: Requires invalid scheduler definitions to fail through a typed configuration error. + +## Impact + +This intentionally breaks source compatibility across `Task`, `Scheduler`, `TaskQueue`, `TaskEngine`, and codec APIs. Callers must yield or otherwise handle effectful construction, provide the newly visible schema services, and match the exact published error unions. Storage bytes remain compatible; the change is to failure fidelity rather than wire representation. diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/effect-api-contracts/spec.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/effect-api-contracts/spec.md new file mode 100644 index 0000000..1923289 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/effect-api-contracts/spec.md @@ -0,0 +1,50 @@ +## Purpose + +Defines public Effect APIs whose declared success, failure, and service channels exactly describe every outcome and dependency observable by callers. + +## ADDED Requirements + +### Requirement: Public Effect signatures are complete +Every public Effect-returning API SHALL expose the union of all failures it can return and all services it can request, including failures and services introduced by delegated queue, engine, storage, and schema operations. Public declarations SHALL NOT widen a failure channel to `any`, erase a required service, or claim an infallible channel for a recoverable failure. + +#### Scenario: Completion uses schema services +- **WHEN** a completion handler requires an environment and its payload, success, and failure schemas require encoding or decoding services +- **THEN** the completion API's public type requires the handler environment, engine service, and every schema service it uses +- **AND** its failure type includes every recoverable engine and schema failure it can return + +#### Scenario: One-item completion is inspected by a consumer +- **WHEN** a consumer inspects the generated declaration for the one-item completion API +- **THEN** its failure channel is an exact named union rather than `any` + +### Requirement: Task decoding preserves all schema requirements +Decoding a stored task SHALL expose the decoding services required by the payload, success, and failure schemas and SHALL retain all corresponding schema failures in the typed channel. + +#### Scenario: Payload decoder requires a service +- **WHEN** a task payload schema depends on a decoding service +- **THEN** the stored-task decoder cannot be executed until that service is provided + +### Requirement: Predictably invalid construction is typed +Public construction of tasks, schedulers, and engine configuration SHALL validate caller-supplied values in an Effect and fail with a semantic configuration error. It SHALL NOT throw synchronously or terminate with a defect for a predictably invalid value. + +#### Scenario: Invalid task retry configuration +- **WHEN** a caller constructs a task with an invalid retry or timeout value +- **THEN** construction fails with a task-configuration error identifying the invalid field and constraint + +#### Scenario: Invalid engine limit +- **WHEN** an engine layer is built with an invalid size or batch limit +- **THEN** layer construction fails in its typed error channel before Redis work begins + +### Requirement: Expected failures have semantic identities +Every expected public failure SHALL have a stable tagged identity and structured fields sufficient for programmatic recovery. Human-readable messages and nested causes SHALL provide diagnostics only and SHALL NOT determine control flow. + +#### Scenario: Recovery branches on a failure +- **WHEN** an operation reaches a relationship limit or an indeterminate transport outcome +- **THEN** the caller can distinguish the condition by tag and structured reason without parsing a message or nested cause text + +### Requirement: Public operation inputs are obtainable +Every public operation that accepts an opaque library-owned value SHALL be paired with a public operation that produces that value in the same abstraction. A high-level API SHALL NOT expose only the continuation half of a lower-level lifecycle. + +#### Scenario: Caller manages a task attempt +- **WHEN** a caller needs to acquire, extend, or release a low-level task attempt +- **THEN** those operations are available together through the TaskEngine lifecycle API +- **AND** the high-level typed queue API does not require an attempt value it cannot produce diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/redis-operations/spec.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/redis-operations/spec.md new file mode 100644 index 0000000..239b7c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/redis-operations/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Redis failures are translated semantically +The Redis boundary SHALL translate expected transport, script, protocol, relationship-limit, and indeterminate-commit conditions into stable tagged errors with structured reasons. Queue policy SHALL branch only on those semantic errors and SHALL NOT inspect Redis messages or recursively stringify causes. + +#### Scenario: Relationship limit is reached +- **WHEN** an atomic storage operation rejects a relationship because its configured limit is reached +- **THEN** the queue receives a relationship-limit failure with structured limit context + +#### Scenario: Commit outcome is unknown +- **WHEN** the connection is lost after a mutating command may have reached Redis +- **THEN** the operation fails with an indeterminate-write error carrying the operation and retry identity + +#### Scenario: Redis diagnostic wording changes +- **WHEN** a Redis client changes the human-readable wording or cause nesting of an equivalent failure +- **THEN** the queue's recovery decision remains unchanged diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/scheduler-delivery/spec.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/scheduler-delivery/spec.md new file mode 100644 index 0000000..4b2db24 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/scheduler-delivery/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Invalid schedule definitions fail predictably +Schedule construction SHALL validate missed-tick policy and backfill bounds before materialization. Predictably invalid definitions SHALL fail with a typed scheduler-configuration error and SHALL NOT throw or die. + +#### Scenario: Maximum backfill is invalid +- **WHEN** a schedule declares a maximum backfill outside the supported range +- **THEN** construction fails with a scheduler-configuration error identifying the field and accepted range +- **AND** no due tick is evaluated or offered diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/storage-protocol/spec.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/storage-protocol/spec.md new file mode 100644 index 0000000..c34733b --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/storage-protocol/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Corruption is never normalized into valid empty data +Malformed bytes, invalid field types, structurally invalid collections, serializer exceptions, and invalid byte conversions SHALL fail decoding in the typed error channel. These failures SHALL NOT escape as defects or be normalized into valid-looking data. Only explicitly documented canonical representations may normalize to an equivalent value. + +#### Scenario: Error history decodes to a map +- **WHEN** the stored error-history field contains a non-list value +- **THEN** decoding fails instead of returning an empty history + +#### Scenario: MessagePack input is truncated +- **WHEN** stored MessagePack bytes end before a declared value is complete +- **THEN** decoding fails with a typed storage-decoding error +- **AND** no synchronous serializer exception escapes the Effect + +#### Scenario: Encoded input is not a supported byte representation +- **WHEN** an external value cannot be converted to the required byte representation +- **THEN** conversion fails with a typed storage-decoding error rather than a defect diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/task-events-stream/spec.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/task-events-stream/spec.md new file mode 100644 index 0000000..e4f29ad --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/specs/task-events-stream/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: wait and execute await a task's terminal event +`TaskQueue.wait` SHALL accept a task/result handle containing generation identity and an authoritative cursor. It SHALL check durable terminal state, subscribe from the cursor, then recheck state to close the race. It SHALL resolve typed success or fail with typed task failure, task-not-found, result-expired, cursor-expired, timeout, storage, engine, or schema failures. `TaskQueue.execute` SHALL be behaviorally and type-equivalent to offering a task and awaiting the returned handle through this protocol; its public failure and service channels SHALL contain the full union required by both operations. + +#### Scenario: Task already completed +- **WHEN** `wait` begins after the retained task has already completed +- **THEN** it resolves immediately from durable terminal state + +#### Scenario: Completion races subscription +- **WHEN** completion occurs between the initial state read and stream subscription +- **THEN** the subscription or recheck observes the same terminal generation and `wait` resolves + +#### Scenario: Result expired +- **WHEN** terminal metadata exists but its result retention has expired +- **THEN** `wait` fails with a typed result-expired error + +#### Scenario: Execute round-trips a task +- **WHEN** `execute` offers a task and a managed worker completes it +- **THEN** it resolves with the decoded success for the offered generation + +#### Scenario: Execute observes a terminal task failure +- **WHEN** the offered generation reaches terminal failure +- **THEN** `execute` fails with the same typed task-failure wrapper that `wait` returns + +#### Scenario: Execute requires schema services +- **WHEN** offering or awaiting requires payload, success, or failure schema services +- **THEN** the generated `execute` type requires the complete union of those services diff --git a/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/tasks.md b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/tasks.md new file mode 100644 index 0000000..5d9c645 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-make-effect-contracts-honest/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract Characterization + +- [x] 1.1 Add compile-time assertions for the current `complete`, `completeOne`, `decodeTask`, `wait`, and `execute` success, failure, and service channels. +- [x] 1.2 Add runtime regression tests proving malformed MessagePack and invalid byte inputs fail in the typed channel rather than as defects. +- [x] 1.3 Add regression tests that exercise relationship-limit and indeterminate-write recovery without relying on diagnostic wording. + +## 2. Typed Codec Boundary + +- [x] 2.1 Introduce focused storage encoding and decoding error variants that retain codec stage, schema path, and cause details. +- [x] 2.2 Replace infallible MessagePack transforms with fallible transforms that capture pack and unpack exceptions. +- [x] 2.3 Validate external string/byte representations before conversion and remove the service-erasing MessagePack schema cast. +- [x] 2.4 Correct task codec generics so payload, success, and failure decoding services propagate through stored-task decoding. +- [x] 2.5 Add golden byte, round-trip, truncated-input, invalid-input-type, and corrupt-structure codec tests. + +## 3. Semantic Error Algebra + +- [x] 3.1 Define stable tagged engine reason variants for transport, script, protocol/invalid-reply, relationship-limit, and indeterminate-commit failures. +- [x] 3.2 Translate Redis/client outcomes to the semantic algebra at the TaskEngine boundary while preserving diagnostic causes. +- [x] 3.3 Remove recursive cause stringification, connection-message regexes, and storage-limit sentinel parsing from TaskQueue. +- [x] 3.4 Update offer/recovery branches to switch exhaustively on semantic tags and add wording-independent tests. + +## 4. Typed Construction + +- [x] 4.1 Define tagged configuration errors for task, scheduler, and engine fields with structured constraint details. +- [x] 4.2 Make task construction effectful and remove synchronous RangeError paths for caller-supplied task configuration. +- [x] 4.3 Make scheduler construction validate missed-tick/backfill settings before materialization and remove `Effect.die` for invalid settings. +- [x] 4.4 Move engine limit validation into typed layer/construction failure before Redis acquisition or commands. +- [x] 4.5 Update internal call sites, examples, and tests to yield the newly effectful constructors. + +## 5. Honest Queue Contracts + +- [x] 5.1 Export named exact error and requirement aliases for offer, complete, wait, and execute operations. +- [x] 5.2 Correct `complete` to require TaskEngine, handler environment, payload decoding, and success/failure encoding services and to expose every recoverable failure. +- [x] 5.3 Correct `completeOne` to reuse the completion contracts with no `any` channel. +- [x] 5.4 Correct stored-task decoding to require payload, success, and failure decoding services. +- [x] 5.5 Rebuild `execute` as the exact composition of `offer` and `wait`, retaining `TaskFailed` and all protocol, retention, engine, storage, schema, and timeout failures. +- [x] 5.6 Remove explicit annotations and casts that narrow implementation-inferred Effect channels, then satisfy the public named contracts. +- [x] 5.7 Remove public TaskQueue `extendLock`/`release` operations that require its unobtainable private attempt type, keep Worker ownership internal, and document TaskEngine as the complete low-level lifecycle. + +## 6. Verification and Migration + +- [x] 6.1 Regenerate declarations and verify the compile-time contract assertions fail under deliberate `E`/`R` erasure mutations. +- [x] 6.2 Update API documentation and release notes with the constructor and error-channel breaking changes and migration examples. +- [x] 6.3 Run production/test typechecks, lint, unit tests, Redis integration/fault tests, storage golden fixtures, docs checks, and packed-consumer verification. diff --git a/openspec/changes/archive/2026-08-20-restructure-effect-modules/.openspec.yaml b/openspec/changes/archive/2026-08-20-restructure-effect-modules/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-20-restructure-effect-modules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-20-restructure-effect-modules/design.md b/openspec/changes/archive/2026-08-20-restructure-effect-modules/design.md new file mode 100644 index 0000000..fe38018 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-restructure-effect-modules/design.md @@ -0,0 +1,81 @@ +## Context + +See `proposal.md` for motivation. Graph analysis identifies `Schemas.ts` as the shared owner of unrelated codec, record, and event paths, while `TaskEngine`, `TaskQueue`, and `StorageProtocol` are already recognizable domain seams. The root barrel exports Observability, but the package manifest omits its matching subpath. TaskContext models optional ambient state as a service, and layer names do not reveal whether Redis dependencies remain required. + +## Goals / Non-Goals + +**Goals:** + +- Give every schema, model, service, and layer an obvious conceptual owner. +- Make root exports, subpath exports, and generated declarations agree. +- Standardize service declaration and layer dependency semantics. +- Encode Effect coding conventions in source structure and automated checks. + +**Non-Goals:** + +- Split `TaskQueue` merely to reduce line count; its operations share one queue/handle lifecycle. +- Change Redis keys, Lua behavior, or storage bytes. +- Publish every internal codec and engine record as a supported API. +- Preserve imports from `Schemas.ts`, `utils.ts`, or obsolete package subpaths. + +## Decisions + +### 1. Replace catch-all modules with explicit concept owners + +The target ownership is: + +| Module | Visibility | Responsibility | +|---|---|---| +| `MessagePack.ts` | internal | Configured MessagePack byte encoding and decoding boundary | +| `TaskRecord.ts` | public | Task identity, durable task state, and typed task record models/codecs | +| `EngineRecord.ts` | internal | Redis-facing command/result record schemas | +| `TaskEvent.ts` | public | Versioned lifecycle event models and codecs | +| `RetrySchedule.ts` | internal | Retry schedule evaluation and composition currently hidden in `utils.ts` | + +`StorageProtocol.ts` remains the public owner of storage envelopes, protocol versions, and limits. Task, Scheduler, TaskQueue, TaskEngine, Worker, RedisPool, NodeRedisPool, and Observability remain cohesive modules. Shared source may be physically small; module boundaries follow concepts, not file-size thresholds. + +A renamed `CodecUtils.ts` was rejected because it would preserve mixed ownership. Splitting TaskQueue by individual methods was rejected because it would create shallow modules with heavy shared state. + +### 2. Publish only types that cross supported APIs + +`TaskRecord` and `TaskEvent` become supported root namespaces and matching package subpaths because public engine/stream values reference them. Observability receives the missing subpath. Internal MessagePack, EngineRecord, and RetrySchedule modules are omitted from `package.json` exports and must not appear as inaccessible deep imports in declarations. The package verification fixture imports every supported subpath. + +Exporting every new file was rejected because physical organization is not automatically a compatibility promise. Keeping public types reachable only transitively was rejected because consumers need a stable naming home. + +### 3. Use service classes, with TaskContext as a Reference + +Runtime capabilities such as TaskEngine and RedisPool use `Context.Service` classes with identifiers under `@effectmq/core/`. Service interfaces become `Service.Shape` types or focused exported aliases rather than separate value/interface pairs with generic IDs. `TaskContext` becomes a `Context.Reference` whose default is absent provenance, since reading it outside a handler is valid and needs no layer. Handler execution uses `Effect.locally`/the appropriate reference-local operation to set it. + +Keeping TaskContext as an optional service was rejected because it conflates “service not installed” with the valid “no current task” state. Making it a required service was rejected because it adds boilerplate to unrelated effects. + +### 4. Layer names state whether dependencies remain + +`TaskEngine.layerNoDeps(config)` constructs the engine while requiring `RedisPool`. `TaskEngine.layer(config)` is the standard Node live graph and supplies NodeRedisPool; it intentionally retains the Redis role/health/pool services needed by Worker and observability, and documents that output union. Similar service modules follow the same naming rule. `Layer.provide` is the default composition; `Layer.provideMerge` is used only in the standard live graph where retained Redis services are deliberate API outputs. + +Keeping the current ambiguous `layer` name was rejected because callers cannot tell whether it is live or still has requirements. A generic `Runtime.ts` composition module was rejected because it does not name a domain capability. + +### 5. Narrow imports and standard Effect function definitions are enforced + +Production files import from `effect/Effect`, `effect/Schema`, and other supported subpaths. Reusable generator-backed functions—including dual implementations—use `Effect.fnUntraced`; exported and recursive effects pin exact return types or `Effect.fn.Return`. Small one-off inline effects may remain direct expressions. An architecture check scans production imports and known reusable function patterns so the convention does not rely only on review memory. + +A blanket wrapper around every anonymous effect was rejected as ceremony without architectural value. Root `effect` imports were rejected because they create broad, unstable dependency edges. + +### 6. Repository guidance changes with the code + +`CLAUDE.md` and contributor-facing commands will describe concept modules, stable service IDs, TaskContext Reference semantics, layer naming, narrow imports, exact contracts, and the testing policy from `adopt-effect-native-testing`. Contradictory guidance endorsing ambiguous layer composition or unmanaged test runtimes is removed in the same breaking release. + +## Risks / Trade-offs + +- [File moves create a noisy diff and merge conflicts] → Apply after behavior/error proposals, use mechanical import changes in isolated commits, and preserve user changes in overlapping files. +- [New public subpaths expand long-term support obligations] → Export only TaskRecord, TaskEvent, and Observability because they are already public concepts; keep codec internals private. +- [Stable service ID changes duplicate services across mixed versions] → Treat this as a coordinated breaking release and prohibit mixing old/new library instances in one Effect graph. +- [Live layer retains more services than some callers need] → Keep `layerNoDeps` as the precise composition primitive for custom graphs. + +## Migration Plan + +1. Land contract and boundary changes first so moved modules expose their final types. +2. Create the new concept modules and move definitions without compatibility re-exports. +3. Convert services and TaskContext, then rename and rebuild layer constructors. +4. Update narrow imports and reusable Effect function definitions. +5. Rebuild root exports, package subpaths, generated declarations, docs, and packed-consumer fixtures. +6. Remove `Schemas.ts` and `utils.ts`; run architecture checks, typechecks, tests, docs, and package verification. Rollback is a release revert rather than a compatibility layer. diff --git a/openspec/changes/archive/2026-08-20-restructure-effect-modules/proposal.md b/openspec/changes/archive/2026-08-20-restructure-effect-modules/proposal.md new file mode 100644 index 0000000..334055c --- /dev/null +++ b/openspec/changes/archive/2026-08-20-restructure-effect-modules/proposal.md @@ -0,0 +1,26 @@ +## Why + +The current package hides several distinct concepts in `Schemas.ts` and `utils.ts`, uses inconsistent service declarations and layer wiring, and exposes an incomplete subpath surface. The result is unnecessary coupling and an API whose module boundaries do not explain the domain. + +## What Changes + +- **BREAKING** Replace the catch-all schema module with focused MessagePack, task-record, engine-record, and task-event modules; replace `utils.ts` with a retry-schedule module. +- **BREAKING** Declare runtime services as `Context.Service` classes with stable package-qualified identifiers, model optional ambient task provenance as a `Context.Reference`, and standardize `layerNoDeps` versus fully wired `layer` constructors. +- **BREAKING** Rebuild package exports around supported concept modules, including an explicit Observability subpath, and remove obsolete aggregate/internal exports. +- Replace broad root `effect` imports with stable narrow subpath imports throughout production code. +- Define reusable Effect-returning functions with `Effect.fnUntraced` and pin exact public or recursive return types. +- Update repository conventions to describe the new module, service, layer, import, and function-definition rules. + +## Capabilities + +### New Capabilities + +- `effect-module-architecture`: Defines the package's concept-oriented modules, supported subpaths, service identities, layer ownership, and reusable Effect function conventions. + +### Modified Capabilities + +None. + +## Impact + +This is an intentionally breaking source-layout and import-surface change affecting most files in `src`, generated declarations, package exports, documentation, and consumer fixtures. Redis wire data is not renamed by this proposal; storage compatibility changes, if any, remain governed by the storage protocol. diff --git a/openspec/changes/archive/2026-08-20-restructure-effect-modules/specs/effect-module-architecture/spec.md b/openspec/changes/archive/2026-08-20-restructure-effect-modules/specs/effect-module-architecture/spec.md new file mode 100644 index 0000000..01d6da6 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-restructure-effect-modules/specs/effect-module-architecture/spec.md @@ -0,0 +1,62 @@ +## Purpose + +Defines stable package seams in which each module owns one recognizable domain concept and public Effect services expose predictable identities, layers, and import paths. + +## ADDED Requirements + +### Requirement: Modules are organized by domain concept +Each production module SHALL have one singular, nameable responsibility. Serialization, task records, engine records, task events, and retry schedules SHALL have distinct owners rather than sharing a generic schema or utility module. + +#### Scenario: Maintainer changes event decoding +- **WHEN** a maintainer changes the representation or decoder for task lifecycle events +- **THEN** the change is localized to the task-event concept and its direct consumers +- **AND** MessagePack configuration and unrelated retry scheduling do not share that module + +#### Scenario: Generic module names are checked +- **WHEN** production modules are reviewed statically +- **THEN** catch-all names such as `Schemas` and `utils` are absent + +### Requirement: Public concepts have stable import paths +Every supported public module namespace SHALL be available from the root namespace barrel and from a matching package subpath. Internal storage and adapter helpers SHALL NOT be accidentally reachable through undocumented deep imports or generated public declarations. + +#### Scenario: Consumer imports Observability +- **WHEN** a packed-package consumer imports the Observability namespace through its documented subpath +- **THEN** Node and TypeScript resolve the same supported module exposed by the root barrel + +#### Scenario: Public declaration references a model +- **WHEN** a generated declaration exposes a task-record or task-event model +- **THEN** that model is reachable from a documented public subpath + +### Requirement: Service identities are stable +Project runtime services SHALL use class-based service declarations with stable package-qualified identifiers. Optional ambient task provenance SHALL use a context reference with an explicit default rather than a fabricated always-present service. + +#### Scenario: Two modules request the engine service +- **WHEN** independently imported modules request the task engine +- **THEN** both resolve the same stable service identity + +#### Scenario: Handler runs without provenance +- **WHEN** code reads task provenance outside a managed task handler +- **THEN** it receives the documented absent default without requiring an extra layer + +### Requirement: Service layers communicate dependency ownership +A service module SHALL expose `layerNoDeps` for construction that still requires upstream services and `layer` for the standard live composition with those dependencies supplied. The live layer SHALL retain upstream outputs only when they are intentionally part of its documented public service graph. + +#### Scenario: Application supplies a custom Redis pool +- **WHEN** an application uses the dependency-free engine layer constructor +- **THEN** the type system requires the Redis pool service from the application + +#### Scenario: Application uses the standard live layer +- **WHEN** an application uses the fully wired engine layer +- **THEN** it receives the documented engine and Redis operational services with no unresolved requirements + +### Requirement: Effect implementation style preserves contracts +Reusable Effectful functions SHALL use the project's traceable function wrapper convention and public or recursive functions SHALL declare exact return contracts. Production modules SHALL import Effect APIs through stable narrow subpaths. + +#### Scenario: Reusable queue operation is inspected +- **WHEN** a reusable queue operation that builds an Effect is inspected statically +- **THEN** it uses the standard untraced Effect function wrapper +- **AND** its declaration has no inferred `any` channel + +#### Scenario: Production imports are checked +- **WHEN** production sources are checked by the architecture lint rule +- **THEN** they do not import APIs from the broad `effect` package root diff --git a/openspec/changes/archive/2026-08-20-restructure-effect-modules/tasks.md b/openspec/changes/archive/2026-08-20-restructure-effect-modules/tasks.md new file mode 100644 index 0000000..65ae035 --- /dev/null +++ b/openspec/changes/archive/2026-08-20-restructure-effect-modules/tasks.md @@ -0,0 +1,42 @@ +## 1. Establish Target Contracts + +- [x] 1.1 Record the final public declarations and import graph after the contract and runtime-boundary changes land. +- [x] 1.2 Add architecture checks for forbidden production root `effect` imports, generic `Schemas`/`utils` modules, unsupported deep imports, and missing public subpaths. + +## 2. Split Concept Modules + +- [x] 2.1 Create internal `MessagePack.ts` and move only configured MessagePack encoding/decoding ownership into it. +- [x] 2.2 Create public `TaskRecord.ts` and move task identity, durable task state, and typed task-record models/codecs into it. +- [x] 2.3 Create internal `EngineRecord.ts` and move Redis-facing command/result record schemas into it. +- [x] 2.4 Create public `TaskEvent.ts` and move versioned lifecycle event models/codecs into it. +- [x] 2.5 Create internal `RetrySchedule.ts`, move the retry schedule operations from `utils.ts`, and update their names/documentation. +- [x] 2.6 Update all consumers to import the focused owners and delete `Schemas.ts` and `utils.ts` without compatibility re-exports. + +## 3. Standardize Services and Layers + +- [x] 3.1 Convert TaskEngine, RedisPool, connection-role, and connection-health services to `Context.Service` classes with `@effectmq/core/` identifiers. +- [x] 3.2 Convert optional TaskContext provenance to a `Context.Reference` with an absent default and locally provide it around handlers. +- [x] 3.3 Add `TaskEngine.layerNoDeps` requiring RedisPool and migrate custom Redis compositions to it. +- [x] 3.4 Rebuild `TaskEngine.layer` as the fully wired Node live graph with its retained Redis operational services documented in the output type. +- [x] 3.5 Replace incidental `Layer.provideMerge` uses with `Layer.provide`; retain it only where upstream Redis services are intentional live-layer outputs. +- [x] 3.6 Add compile-time layer tests for unresolved `layerNoDeps` requirements and the zero-requirement live graph. + +## 4. Normalize Effect Source Style + +- [x] 4.1 Convert all production imports from the broad `effect` root to supported narrow subpaths and apply deterministic import ordering. +- [x] 4.2 Convert reusable generator-backed functions in TaskQueue, codec modules, and Worker to `Effect.fnUntraced`, including dual implementations. +- [x] 4.3 Pin exact public and recursive Effect return types or `Effect.fn.Return` and remove any inference-erasing annotations introduced by the move. +- [x] 4.4 Run the architecture check and inspect the dependency graph to confirm the new modules form distinct cohesive seams. + +## 5. Rebuild the Public Surface + +- [x] 5.1 Add root namespace exports and matching package subpaths for TaskRecord, TaskEvent, and Observability. +- [x] 5.2 Ensure public declarations reference only supported subpaths and internal MessagePack, EngineRecord, and RetrySchedule modules do not leak. +- [x] 5.3 Update packed-consumer fixtures to import every supported root namespace and subpath and to reject removed deep imports. +- [x] 5.4 Update API documentation and migration notes for module moves, stable service identifiers, TaskContext Reference semantics, and layer naming. + +## 6. Repository Policy and Verification + +- [x] 6.1 Update `CLAUDE.md` and contributor guidance to match the final module, service, layer, import, exact-contract, and Effect function conventions. +- [x] 6.2 Remove contradictory guidance endorsing ambiguous layer composition or unmanaged test-runtime patterns. +- [x] 6.3 Run production/test typechecks, architecture checks, lint, unit/integration/fault tests, docs checks, declaration inspection, and packed-package verification. diff --git a/openspec/specs/effect-api-contracts/spec.md b/openspec/specs/effect-api-contracts/spec.md new file mode 100644 index 0000000..30605bc --- /dev/null +++ b/openspec/specs/effect-api-contracts/spec.md @@ -0,0 +1,52 @@ +# effect-api-contracts + +## Purpose + +Defines public Effect APIs whose declared success, failure, and service channels exactly describe every outcome and dependency observable by callers. + +## Requirements + +### Requirement: Public Effect signatures are complete +Every public Effect-returning API SHALL expose the union of all failures it can return and all services it can request, including failures and services introduced by delegated queue, engine, storage, and schema operations. Public declarations SHALL NOT widen a failure channel to `any`, erase a required service, or claim an infallible channel for a recoverable failure. + +#### Scenario: Completion uses schema services +- **WHEN** a completion handler requires an environment and its payload, success, and failure schemas require encoding or decoding services +- **THEN** the completion API's public type requires the handler environment, engine service, and every schema service it uses +- **AND** its failure type includes every recoverable engine and schema failure it can return + +#### Scenario: One-item completion is inspected by a consumer +- **WHEN** a consumer inspects the generated declaration for the one-item completion API +- **THEN** its failure channel is an exact named union rather than `any` + +### Requirement: Task decoding preserves all schema requirements +Decoding a stored task SHALL expose the decoding services required by the payload, success, and failure schemas and SHALL retain all corresponding schema failures in the typed channel. + +#### Scenario: Payload decoder requires a service +- **WHEN** a task payload schema depends on a decoding service +- **THEN** the stored-task decoder cannot be executed until that service is provided + +### Requirement: Predictably invalid construction is typed +Public construction of tasks, schedulers, and engine configuration SHALL validate caller-supplied values in an Effect and fail with a semantic configuration error. It SHALL NOT throw synchronously or terminate with a defect for a predictably invalid value. + +#### Scenario: Invalid task retry configuration +- **WHEN** a caller constructs a task with an invalid retry or timeout value +- **THEN** construction fails with a task-configuration error identifying the invalid field and constraint + +#### Scenario: Invalid engine limit +- **WHEN** an engine layer is built with an invalid size or batch limit +- **THEN** layer construction fails in its typed error channel before Redis work begins + +### Requirement: Expected failures have semantic identities +Every expected public failure SHALL have a stable tagged identity and structured fields sufficient for programmatic recovery. Human-readable messages and nested causes SHALL provide diagnostics only and SHALL NOT determine control flow. + +#### Scenario: Recovery branches on a failure +- **WHEN** an operation reaches a relationship limit or an indeterminate transport outcome +- **THEN** the caller can distinguish the condition by tag and structured reason without parsing a message or nested cause text + +### Requirement: Public operation inputs are obtainable +Every public operation that accepts an opaque library-owned value SHALL be paired with a public operation that produces that value in the same abstraction. A high-level API SHALL NOT expose only the continuation half of a lower-level lifecycle. + +#### Scenario: Caller manages a task attempt +- **WHEN** a caller needs to acquire, extend, or release a low-level task attempt +- **THEN** those operations are available together through the TaskEngine lifecycle API +- **AND** the high-level typed queue API does not require an attempt value it cannot produce diff --git a/openspec/specs/effect-module-architecture/spec.md b/openspec/specs/effect-module-architecture/spec.md new file mode 100644 index 0000000..55a4a5e --- /dev/null +++ b/openspec/specs/effect-module-architecture/spec.md @@ -0,0 +1,64 @@ +# effect-module-architecture + +## Purpose + +Defines stable package seams in which each module owns one recognizable domain concept and public Effect services expose predictable identities, layers, and import paths. + +## Requirements + +### Requirement: Modules are organized by domain concept +Each production module SHALL have one singular, nameable responsibility. Serialization, task records, engine records, task events, and retry schedules SHALL have distinct owners rather than sharing a generic schema or utility module. + +#### Scenario: Maintainer changes event decoding +- **WHEN** a maintainer changes the representation or decoder for task lifecycle events +- **THEN** the change is localized to the task-event concept and its direct consumers +- **AND** MessagePack configuration and unrelated retry scheduling do not share that module + +#### Scenario: Generic module names are checked +- **WHEN** production modules are reviewed statically +- **THEN** catch-all names such as `Schemas` and `utils` are absent + +### Requirement: Public concepts have stable import paths +Every supported public module namespace SHALL be available from the root namespace barrel and from a matching package subpath. Internal storage and adapter helpers SHALL NOT be accidentally reachable through undocumented deep imports or generated public declarations. + +#### Scenario: Consumer imports Observability +- **WHEN** a packed-package consumer imports the Observability namespace through its documented subpath +- **THEN** Node and TypeScript resolve the same supported module exposed by the root barrel + +#### Scenario: Public declaration references a model +- **WHEN** a generated declaration exposes a task-record or task-event model +- **THEN** that model is reachable from a documented public subpath + +### Requirement: Service identities are stable +Project runtime services SHALL use class-based service declarations with stable package-qualified identifiers. Optional ambient task provenance SHALL use a context reference with an explicit default rather than a fabricated always-present service. + +#### Scenario: Two modules request the engine service +- **WHEN** independently imported modules request the task engine +- **THEN** both resolve the same stable service identity + +#### Scenario: Handler runs without provenance +- **WHEN** code reads task provenance outside a managed task handler +- **THEN** it receives the documented absent default without requiring an extra layer + +### Requirement: Service layers communicate dependency ownership +A service module SHALL expose `layerNoDeps` for construction that still requires upstream services and `layer` for the standard live composition with those dependencies supplied. The live layer SHALL retain upstream outputs only when they are intentionally part of its documented public service graph. + +#### Scenario: Application supplies a custom Redis pool +- **WHEN** an application uses the dependency-free engine layer constructor +- **THEN** the type system requires the Redis pool service from the application + +#### Scenario: Application uses the standard live layer +- **WHEN** an application uses the fully wired engine layer +- **THEN** it receives the documented engine and Redis operational services with no unresolved requirements + +### Requirement: Effect implementation style preserves contracts +Reusable Effectful functions SHALL use the project's traceable function wrapper convention and public or recursive functions SHALL declare exact return contracts. Production modules SHALL import Effect APIs through stable narrow subpaths. + +#### Scenario: Reusable queue operation is inspected +- **WHEN** a reusable queue operation that builds an Effect is inspected statically +- **THEN** it uses the standard untraced Effect function wrapper +- **AND** its declaration has no inferred `any` channel + +#### Scenario: Production imports are checked +- **WHEN** production sources are checked by the architecture lint rule +- **THEN** they do not import APIs from the broad `effect` package root diff --git a/openspec/specs/effect-runtime-boundaries/spec.md b/openspec/specs/effect-runtime-boundaries/spec.md new file mode 100644 index 0000000..bb65e53 --- /dev/null +++ b/openspec/specs/effect-runtime-boundaries/spec.md @@ -0,0 +1,48 @@ +# effect-runtime-boundaries + +## Purpose + +Defines how effectmq owns ambient capabilities, asynchronous JavaScript integrations, and resource lifetimes so execution remains typed, deterministic, and interruptible. + +## Requirements + +### Requirement: Ambient capabilities are explicit +Operations that observe time, generate task identities, or read process configuration SHALL obtain those capabilities from their Effect environment at execution time. They SHALL NOT capture wall-clock time when an Effect is constructed or read ambient randomness/configuration inside domain logic. + +#### Scenario: Delayed effect observes current execution time +- **WHEN** an Effect is constructed and executed after the clock has advanced +- **THEN** its timestamp is based on execution time rather than construction time + +#### Scenario: Generated task identity is deterministic in a test +- **WHEN** a caller supplies a deterministic cryptographic-randomness service +- **THEN** default task identity generation uses that service + +### Requirement: Asynchronous foreign APIs are adapted honestly +Promise and callback integrations SHALL be wrapped at the module that owns the foreign API. Predictable rejections SHALL become semantic typed errors, cancellation SHALL interrupt cancelable work when supported, and defects or interruption SHALL NOT be caught and reclassified as an expected negative result. + +#### Scenario: Readiness check is interrupted +- **WHEN** a fiber checking Redis readiness is interrupted +- **THEN** interruption propagates instead of being converted to `false` + +#### Scenario: Redis promise rejects +- **WHEN** a Redis client promise rejects with an expected connection failure +- **THEN** the owning adapter returns a semantic typed error containing the original cause + +### Requirement: Resources are scoped +Every acquired Redis client, runtime bridge, listener registration, and test or CLI resource SHALL have a release action attached to the same Effect scope. Release failures SHALL be observed according to the public shutdown policy rather than becoming untracked promise rejections. + +#### Scenario: CLI inspection is interrupted +- **WHEN** the inspection process is interrupted while scanning +- **THEN** the Redis connection is closed by scope finalization before process exit + +#### Scenario: Connection acquisition fails midway +- **WHEN** one resource in a multi-connection pool fails after earlier resources were acquired +- **THEN** every successfully acquired resource is released + +### Requirement: Process entry points stay at the edge +Command-line entry points SHALL compose configuration, layers, logging, and the application as a single Effect and invoke the runtime only once at the outermost process boundary. + +#### Scenario: Redis URL is absent +- **WHEN** the inspection command starts without required Redis configuration +- **THEN** it exits through a typed configuration failure rendered by the process runner +- **AND** no Redis client is constructed diff --git a/openspec/specs/effect-test-harness/spec.md b/openspec/specs/effect-test-harness/spec.md new file mode 100644 index 0000000..245dcc0 --- /dev/null +++ b/openspec/specs/effect-test-harness/spec.md @@ -0,0 +1,63 @@ +# effect-test-harness + +## Purpose + +Defines an Effect-native test harness whose execution, typechecking, timing, and resource lifecycle provide trustworthy evidence about the package's public contracts. + +## Requirements + +### Requirement: Effect tests use the Effect-aware runner +Tests whose body is an Effect SHALL execute through the project's Effect-aware Vitest integration and SHALL receive dependencies through test layers. Test bodies SHALL NOT manually call Effect runtimes or use a `ManagedRuntime` as a general-purpose runner. + +#### Scenario: Queue integration test runs +- **WHEN** a queue integration test needs Redis and package services +- **THEN** the test declares an Effect body under a suite-scoped layer +- **AND** the runner reports Effect failures and defects with their structured causes + +### Requirement: Test resources are scoped and released +Containers, Redis clients, listeners, fibers, and test layers SHALL be acquired and released by Effect scopes. Suite completion SHALL dispose every acquired resource under success, test failure, timeout, and interruption. + +#### Scenario: Test assertion fails after Redis acquisition +- **WHEN** an assertion fails after a suite layer has acquired Redis resources +- **THEN** the suite scope closes all owned clients and containers + +#### Scenario: Test suite completes +- **WHEN** the final test using a shared suite layer finishes +- **THEN** no warmed runtime, client, container, listener, or supervised fiber remains live + +### Requirement: Complete test source is strictly typechecked +Every test and testing-support TypeScript file SHALL compile under strict settings compatible with production. The test runner's transpilation path SHALL NOT substitute for this diagnostic typecheck. + +#### Scenario: Test fixture passes a malformed option +- **WHEN** a test supplies an option shape that is not accepted by the public API +- **THEN** the test typecheck fails before the behavioral suite runs + +### Requirement: Public Effect contracts have compile-time assertions +The test suite SHALL assert the exact success, failure, and service channels of public Effect APIs whose contracts compose other operations. Assertions SHALL cover completion, task decoding, waiting, and execution and SHALL fail if a channel widens to `any`, `unknown`, or omits a required member. + +#### Scenario: Execute loses a service requirement +- **WHEN** an implementation annotation accidentally removes a schema service from `execute` +- **THEN** a compile-time contract test fails + +#### Scenario: CompleteOne widens to any +- **WHEN** the one-item completion failure channel becomes `any` +- **THEN** a compile-time assertion rejects the declaration + +### Requirement: Concurrency and timing tests are deterministic +Unit tests SHALL coordinate fibers with virtual time and explicit synchronization primitives rather than arbitrary sleeps or wall-clock race windows. Integration tests that necessarily exercise Redis server time SHALL be labeled as real-time tests and use bounded polling or event latches with documented timeouts. + +#### Scenario: Retry delay is tested +- **WHEN** a unit test verifies a retry scheduled after a duration +- **THEN** it advances virtual time and observes the transition without waiting for wall-clock time + +#### Scenario: Redis TTL is tested +- **WHEN** an integration test verifies server-owned expiration +- **THEN** it uses a bounded real-time wait classified as integration behavior +- **AND** a timeout produces a diagnostic failure rather than a flaky fixed sleep + +### Requirement: Test foreign APIs use typed adapters +Testing support that calls promise- or callback-based external APIs SHALL wrap them with Effect's fallible asynchronous boundaries and semantic test-infrastructure errors. It SHALL NOT place throwing work in infallible Effect constructors. + +#### Scenario: Container startup rejects +- **WHEN** the test container library rejects during startup +- **THEN** suite acquisition fails with a typed test-infrastructure error retaining the original cause diff --git a/openspec/specs/production-release/spec.md b/openspec/specs/production-release/spec.md index 23a2593..3332391 100644 --- a/openspec/specs/production-release/spec.md +++ b/openspec/specs/production-release/spec.md @@ -80,3 +80,15 @@ long-lived publication token. #### Scenario: Release gate has not passed - **WHEN** the publication workflow runs for a commit without a successful release gate - **THEN** no npm package is published + +### Requirement: The complete test program is type- and lifecycle-checked + +Release CI SHALL strictly typecheck production source, every test, every testing-support module, and public contract assertions before executing behavioral suites. Test completion SHALL also verify that suite-owned runtimes, Redis clients, containers, listeners, and fibers have been released. + +#### Scenario: Test-only type error is introduced +- **WHEN** a release commit contains a strict TypeScript error only in an excluded test file +- **THEN** the release gate fails before publication + +#### Scenario: Shared integration resource leaks +- **WHEN** an integration suite completes while a suite-owned resource remains undisposed +- **THEN** the release gate fails with lifecycle diagnostics diff --git a/openspec/specs/redis-operations/spec.md b/openspec/specs/redis-operations/spec.md index 228dcfc..d4c128d 100644 --- a/openspec/specs/redis-operations/spec.md +++ b/openspec/specs/redis-operations/spec.md @@ -67,3 +67,43 @@ shutdown, and indeterminate-write behavior. #### Scenario: Producer loses connection after sending an offer - **WHEN** the producer cannot determine whether Redis committed the offer - **THEN** the API returns an indeterminate-write error that instructs retry with the same idempotency identity + +### Requirement: Redis failures are translated semantically + +The Redis boundary SHALL translate expected transport, script, protocol, relationship-limit, and indeterminate-commit conditions into stable tagged errors with structured reasons. Queue policy SHALL branch only on those semantic errors and SHALL NOT inspect Redis messages or recursively stringify causes. + +#### Scenario: Relationship limit is reached +- **WHEN** an atomic storage operation rejects a relationship because its configured limit is reached +- **THEN** the queue receives a relationship-limit failure with structured limit context + +#### Scenario: Commit outcome is unknown +- **WHEN** the connection is lost after a mutating command may have reached Redis +- **THEN** the operation fails with an indeterminate-write error carrying the operation and retry identity + +#### Scenario: Redis diagnostic wording changes +- **WHEN** a Redis client changes the human-readable wording or cause nesting of an equivalent failure +- **THEN** the queue's recovery decision remains unchanged + +### Requirement: Redis connection lifecycle is scope-safe + +Every Redis connection used by the library SHALL be acquired and released within an Effect scope. Connection and close failures SHALL retain their semantic typed identity, while defects and interruption SHALL propagate unchanged. + +#### Scenario: Pool scope closes normally +- **WHEN** the Redis pool scope closes after successful use +- **THEN** each owned client is closed exactly once + +#### Scenario: Readiness command defects +- **WHEN** a readiness command terminates with a defect rather than an expected connection failure +- **THEN** the readiness operation preserves the defect instead of reporting the connection as merely unready + +### Requirement: Redis replies are validated before use + +Every Redis reply crossing into queue logic SHALL be validated against its expected scalar, tuple, collection, or stream shape. An unexpected reply SHALL fail with a typed invalid-reply error containing operation context and SHALL NOT be coerced or accepted through a type assertion. + +#### Scenario: Stream reply has an invalid tuple +- **WHEN** a stream read returns an entry with a malformed field/value tuple +- **THEN** decoding fails with an invalid-reply error before an event is constructed + +#### Scenario: Text conversion receives an unsupported value +- **WHEN** a Redis result cannot be converted using the explicitly supported text representations +- **THEN** the adapter fails with an invalid-reply error rather than stringifying the value implicitly diff --git a/openspec/specs/scheduler-delivery/spec.md b/openspec/specs/scheduler-delivery/spec.md index 37c8719..27ac7b8 100644 --- a/openspec/specs/scheduler-delivery/spec.md +++ b/openspec/specs/scheduler-delivery/spec.md @@ -42,3 +42,12 @@ at-least-once handler execution. #### Scenario: Tick task is retried - **WHEN** the worker loses its lease after beginning a scheduled handler - **THEN** the same tick task may execute again under normal retry semantics + +### Requirement: Invalid schedule definitions fail predictably + +Schedule construction SHALL validate missed-tick policy and backfill bounds before materialization. Predictably invalid definitions SHALL fail with a typed scheduler-configuration error and SHALL NOT throw or die. + +#### Scenario: Maximum backfill is invalid +- **WHEN** a schedule declares a maximum backfill outside the supported range +- **THEN** construction fails with a scheduler-configuration error identifying the field and accepted range +- **AND** no due tick is evaluated or offered diff --git a/openspec/specs/storage-protocol/spec.md b/openspec/specs/storage-protocol/spec.md index 168c4fe..76075bb 100644 --- a/openspec/specs/storage-protocol/spec.md +++ b/openspec/specs/storage-protocol/spec.md @@ -29,14 +29,25 @@ with a typed compatibility error rather than mis-decoding them. ### Requirement: Corruption is never normalized into valid empty data -Malformed bytes, invalid field types, and structurally invalid collections -SHALL fail decoding in the typed error channel. Only explicitly documented -canonical representations may normalize to an equivalent value. +Malformed bytes, invalid field types, structurally invalid collections, +serializer exceptions, and invalid byte conversions SHALL fail decoding in the +typed error channel. These failures SHALL NOT escape as defects or be +normalized into valid-looking data. Only explicitly documented canonical +representations may normalize to an equivalent value. #### Scenario: Error history decodes to a map - **WHEN** the stored error-history field contains a non-list value - **THEN** decoding fails instead of returning an empty history +#### Scenario: MessagePack input is truncated +- **WHEN** stored MessagePack bytes end before a declared value is complete +- **THEN** decoding fails with a typed storage-decoding error +- **AND** no synchronous serializer exception escapes the Effect + +#### Scenario: Encoded input is not a supported byte representation +- **WHEN** an external value cannot be converted to the required byte representation +- **THEN** conversion fails with a typed storage-decoding error rather than a defect + ### Requirement: Rolling compatibility is declared Each release SHALL declare which protocol versions it can read and write. A @@ -57,3 +68,12 @@ limits before an unbounded Redis operation occurs. - **WHEN** a handler returns a result larger than the configured maximum - **THEN** acknowledgement fails with a typed size-limit error - **AND** the task follows the configured terminal handling policy + +### Requirement: Untrusted keyed data is prototype safe + +Collections populated from externally controlled keys SHALL use a representation that cannot mutate or inherit JavaScript object prototypes. Key values such as `__proto__`, `constructor`, and `prototype` SHALL be preserved as ordinary data or rejected by an explicit schema rule. + +#### Scenario: External key is __proto__ +- **WHEN** a Redis field or decoded record contains the key `__proto__` +- **THEN** processing does not alter the collection's prototype +- **AND** the key is handled according to the collection's documented data semantics diff --git a/openspec/specs/task-events-stream/spec.md b/openspec/specs/task-events-stream/spec.md index 0ce3c5d..e6ba22a 100644 --- a/openspec/specs/task-events-stream/spec.md +++ b/openspec/specs/task-events-stream/spec.md @@ -73,12 +73,7 @@ and SHALL NOT be normalized into valid-looking events. ### Requirement: wait and execute await a task's terminal event -`TaskQueue.wait` SHALL accept a task/result handle containing generation -identity and an authoritative cursor. It SHALL check durable terminal state, -subscribe from the cursor, then recheck state to close the race. It SHALL -resolve typed success or fail with typed task failure, task-not-found, -result-expired, cursor-expired, or timeout. `TaskQueue.execute` SHALL offer and -await through the same protocol. +`TaskQueue.wait` SHALL accept a task/result handle containing generation identity and an authoritative cursor. It SHALL check durable terminal state, subscribe from the cursor, then recheck state to close the race. It SHALL resolve typed success or fail with typed task failure, task-not-found, result-expired, cursor-expired, timeout, storage, engine, or schema failures. `TaskQueue.execute` SHALL be behaviorally and type-equivalent to offering a task and awaiting the returned handle through this protocol; its public failure and service channels SHALL contain the full union required by both operations. #### Scenario: Task already completed - **WHEN** `wait` begins after the retained task has already completed @@ -95,3 +90,11 @@ await through the same protocol. #### Scenario: Execute round-trips a task - **WHEN** `execute` offers a task and a managed worker completes it - **THEN** it resolves with the decoded success for the offered generation + +#### Scenario: Execute observes a terminal task failure +- **WHEN** the offered generation reaches terminal failure +- **THEN** `execute` fails with the same typed task-failure wrapper that `wait` returns + +#### Scenario: Execute requires schema services +- **WHEN** offering or awaiting requires payload, success, or failure schema services +- **THEN** the generated `execute` type requires the complete union of those services diff --git a/package.json b/package.json index f56d63f..ed10804 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "types": "./dist/NodeRedisPool.d.ts", "default": "./dist/NodeRedisPool.js" }, + "./Observability": { + "types": "./dist/Observability.d.ts", + "default": "./dist/Observability.js" + }, "./RedisPool": { "types": "./dist/RedisPool.d.ts", "default": "./dist/RedisPool.js" @@ -47,10 +51,18 @@ "types": "./dist/TaskEngine.d.ts", "default": "./dist/TaskEngine.js" }, + "./TaskEvent": { + "types": "./dist/TaskEvent.d.ts", + "default": "./dist/TaskEvent.js" + }, "./TaskQueue": { "types": "./dist/TaskQueue.d.ts", "default": "./dist/TaskQueue.js" }, + "./TaskRecord": { + "types": "./dist/TaskRecord.d.ts", + "default": "./dist/TaskRecord.js" + }, "./Worker": { "types": "./dist/Worker.d.ts", "default": "./dist/Worker.js" @@ -68,15 +80,19 @@ "build": "pnpm clean && pnpm gen:lua && tsc -p tsconfig.json", "clean": "rm -rf dist", "test": "pnpm gen:lua && vitest run", + "test:lifecycle": "vitest run src/testing/redisLayer.test.ts --reporter=default --reporter=hanging-process", "test:fault": "EFFECTMQ_TEST_REDIS=local EFFECTMQ_TEST_SENTINEL=local vitest run src/testing/FaultInjection.test.ts src/RedisRestart.test.ts src/RedisSentinel.test.ts", "test:watch": "pnpm gen:lua && vitest", - "typecheck": "tsc --noEmit", + "typecheck": "pnpm typecheck:src && pnpm typecheck:test", + "typecheck:src": "tsc --noEmit", + "typecheck:test": "tsc -p tsconfig.test.json", "format:check": "biome format .", "lint": "biome lint .", "check:lua": "tsx scripts/check-lua.ts", "check:docs": "tsx scripts/check-docs.ts", "check:release": "tsx scripts/check-release.ts", - "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:lua && pnpm check:docs && pnpm check:release", + "check:architecture": "tsx scripts/check-architecture.ts", + "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:architecture && pnpm check:lua && pnpm check:docs && pnpm check:release", "lint:fix": "pnpm biome check --write --unsafe", "changeset": "changeset", "version:packages": "changeset version", @@ -97,7 +113,7 @@ "devDependencies": { "@biomejs/biome": "2.5.1", "@changesets/cli": "^2.31.0", - "@effect/platform-node": "4.0.0-beta.107", + "@effect/vitest": "4.0.0-beta.107", "@testcontainers/redis": "^12.0.3", "@types/node": "^22.0.0", "effect": "4.0.0-beta.107", @@ -108,6 +124,7 @@ "vitest": "^4.1.9" }, "dependencies": { + "@effect/platform-node": "4.0.0-beta.107", "msgpackr": "^2.0.4", "redis": "^6.1.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d20b51..9835b7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) msgpackr: specifier: ^2.0.4 version: 2.0.4 @@ -21,9 +24,9 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@22.20.0) - '@effect/platform-node': + '@effect/vitest': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0))) '@testcontainers/redis': specifier: ^12.0.3 version: 12.0.3 @@ -183,6 +186,12 @@ packages: effect: ^4.0.0-beta.107 ioredis: '>=5.7.0 <6.0.0' + '@effect/vitest@4.0.0-beta.107': + resolution: {integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==} + peerDependencies: + effect: ^4.0.0-beta.107 + vitest: '>=4.1.0 <5.0.0' + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -2102,6 +2111,11 @@ snapshots: - bufferutil - utf-8-validate + '@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)))': + dependencies: + effect: 4.0.0-beta.107 + vitest: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts new file mode 100644 index 0000000..3148f15 --- /dev/null +++ b/scripts/check-architecture.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const root = process.cwd(); +const source = join(root, "src"); +const failures: string[] = []; + +const visit = (directory: string) => { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + if (statSync(path).isDirectory()) { + if (entry !== "testing" && entry !== "scratchpad") visit(path); + continue; + } + if (!entry.endsWith(".ts") || entry.endsWith(".test.ts")) continue; + const text = readFileSync(path, "utf8"); + if (/^import[^;]+from ["']effect["']/m.test(text)) { + failures.push(`${relative(root, path)} imports the broad effect root`); + } + if (/from ["']effect\/internal\//.test(text)) { + failures.push( + `${relative(root, path)} imports unsupported Effect internals`, + ); + } + if (/\bDate\.now\(\)|\bcrypto\.randomUUID\(/.test(text)) { + failures.push(`${relative(root, path)} reads ambient time or randomness`); + } + } +}; + +visit(source); + +for (const obsolete of ["src/Schemas.ts", "src/utils.ts"]) { + if (existsSync(join(root, obsolete))) + failures.push(`${obsolete} still exists`); +} + +const manifest = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), +) as { + exports?: Record; +}; +for (const subpath of ["./Observability", "./TaskEvent", "./TaskRecord"]) { + if (manifest.exports?.[subpath] === undefined) { + failures.push(`package export ${subpath} is missing`); + } +} + +if (failures.length > 0) throw new Error(failures.join("\n")); +console.log("Architecture checks passed"); diff --git a/scripts/verify-package.ts b/scripts/verify-package.ts index 635af9e..e3a9620 100644 --- a/scripts/verify-package.ts +++ b/scripts/verify-package.ts @@ -68,12 +68,15 @@ try { ); const subpaths = [ "NodeRedisPool", + "Observability", "RedisPool", "Scheduler", "StorageProtocol", "Task", "TaskEngine", + "TaskEvent", "TaskQueue", + "TaskRecord", "Worker", ]; const imports = subpaths @@ -86,7 +89,11 @@ try { ) .join("\n"); const consumerSource = `import * as Core from "@effectmq/core";\nimport { NodeRuntime } from "@effect/platform-node";\n${imports}\nif (Object.keys(Core).length < 9) throw new Error("incomplete root export");\nif (NodeRuntime === undefined) throw new Error("platform-node beta is incompatible");\n${assertions}\nconsole.log("packed ESM exports load");\n`; - writeFileSync(join(consumer, "index.mjs"), consumerSource); + const removedSubpathCheck = `\nfor (const path of ["@effectmq/core/MessagePack", "@effectmq/core/EngineRecord", "@effectmq/core/RetrySchedule", "@effectmq/core/Schemas"]) {\n try {\n await import(path);\n throw new Error(\`removed subpath loaded: \${path}\`);\n } catch (error) {\n if (error instanceof Error && error.message.startsWith("removed subpath loaded:")) throw error;\n }\n}\n`; + writeFileSync( + join(consumer, "index.mjs"), + consumerSource + removedSubpathCheck, + ); writeFileSync(join(consumer, "index.ts"), consumerSource); writeFileSync( join(consumer, "tsconfig.json"), diff --git a/src/EngineRecord.ts b/src/EngineRecord.ts new file mode 100644 index 0000000..4de6a32 --- /dev/null +++ b/src/EngineRecord.ts @@ -0,0 +1,155 @@ +/** Redis-facing task record schemas. @internal */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import * as SchemaIssue from "effect/SchemaIssue"; +import { UnknownFromMsgpack } from "./MessagePack.js"; +import { + CompletionPolicySchema, + DateFromNumberSchema, + TaskIdentitySchema, + TaskOutcomeSchema, +} from "./TaskRecord.js"; + +export const TextFromBytes = Schema.Unknown.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transformOrFail((value: unknown, options) => { + if (typeof value === "string") return Effect.succeed(value); + if (!(value instanceof Uint8Array)) { + return Effect.fail( + new SchemaIssue.InvalidValue( + { message: "Expected a string or Uint8Array Redis value" }, + value, + options, + ), + ); + } + return Effect.try({ + try: () => Buffer.from(value).toString("utf8"), + catch: (cause) => + new SchemaIssue.InvalidValue( + { message: `Byte conversion failed: ${String(cause)}` }, + value, + options, + ), + }); + }), + encode: SchemaGetter.transform((value: string): unknown => value), + }), +); + +export const NumberFromBytes = TextFromBytes.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform(Number), + encode: SchemaGetter.transform(String), + }), +); + +export const BooleanFromBytes = TextFromBytes.pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "1"), + encode: SchemaGetter.transform((value) => (value ? "1" : "0")), + }), +); + +const msgpackListFromBytes = (item: S) => + UnknownFromMsgpack.pipe(Schema.decodeTo(Schema.Array(item))); + +export const EngineTaskSchema = Schema.Struct({ + id: TextFromBytes, + protocolVersion: NumberFromBytes, + schemaId: TextFromBytes, + generation: NumberFromBytes, + name: TextFromBytes, + delay: NumberFromBytes, + maxRetries: NumberFromBytes, + maxStalledCount: NumberFromBytes, + maxErrorEntries: NumberFromBytes, + maxRelationships: NumberFromBytes, + maxEventEntries: NumberFromBytes, + taskRecordRetentionMs: NumberFromBytes, + resultRetentionMs: NumberFromBytes, + terminalIndexRetentionMs: NumberFromBytes, + deadLetterRetentionMs: NumberFromBytes, + eventRetentionMs: NumberFromBytes, + attempt: NumberFromBytes, + handlerFailureCount: NumberFromBytes, + stalledAttemptCount: NumberFromBytes, + onSuccessPolicy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), + onFailurePolicy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), + createdAt: NumberFromBytes.pipe(Schema.decodeTo(DateFromNumberSchema)), + updatedAt: NumberFromBytes.pipe(Schema.decodeTo(DateFromNumberSchema)), + payload: UnknownFromMsgpack, + success: UnknownFromMsgpack.pipe(Schema.optional), + errors: msgpackListFromBytes( + Schema.Struct({ + timestamp: Schema.Number, + error: Schema.Unknown, + retryAt: Schema.optional(Schema.Number), + }), + ), + creator: UnknownFromMsgpack.pipe( + Schema.decodeTo(TaskIdentitySchema), + Schema.optional, + ), + outcome: TextFromBytes.pipe( + Schema.decodeTo(TaskOutcomeSchema), + Schema.optional, + ), +}); +export type EngineTask = typeof EngineTaskSchema.Type; + +export const EngineTerminalResultSchema = Schema.Struct({ + protocolVersion: NumberFromBytes, + schemaId: TextFromBytes, + generation: NumberFromBytes, + outcome: TextFromBytes.pipe(Schema.decodeTo(TaskOutcomeSchema)), + settledAt: NumberFromBytes, + success: UnknownFromMsgpack.pipe(Schema.optional), + failure: UnknownFromMsgpack.pipe(Schema.optional), +}); +export type EngineTerminalResult = typeof EngineTerminalResultSchema.Type; + +export const EngineTaskInsertSchema = Schema.Struct({ + prefix: Schema.String, + id: Schema.String, + name: Schema.String, + schemaId: Schema.String.pipe(Schema.optional), + payload: Schema.Unknown, + delay: Schema.Number, + maxRetries: Schema.Number, + maxStalledCount: Schema.Number.pipe(Schema.optional), + maxErrorEntries: Schema.Number.pipe(Schema.optional), + maxRelationships: Schema.Number.pipe(Schema.optional), + maxEventEntries: Schema.Number.pipe(Schema.optional), + taskRecordRetentionMs: Schema.Number.pipe(Schema.optional), + resultRetentionMs: Schema.Number.pipe(Schema.optional), + terminalIndexRetentionMs: Schema.Number.pipe(Schema.optional), + deadLetterRetentionMs: Schema.Number.pipe(Schema.optional), + eventRetentionMs: Schema.Number.pipe(Schema.optional), + onSuccessPolicy: CompletionPolicySchema, + onFailurePolicy: CompletionPolicySchema, + onDuplicate: Schema.Literals(["return-existing", "new-generation"]).pipe( + Schema.optional, + ), + retentionHolder: TaskIdentitySchema.pipe(Schema.optional), + creator: TaskIdentitySchema.pipe(Schema.optional), +}); +export type EngineTaskInsert = typeof EngineTaskInsertSchema.Type; + +export const TaskLists = Schema.Literals([ + "wait", + "scheduled", + "active", + "failed", + "success", +]); + +export const ExecutionStateSchema = Schema.Literals([ + "delayed", + "waiting", + "leased", + "retry-scheduled", + "succeeded", + "failed", +]); diff --git a/src/MessagePack.ts b/src/MessagePack.ts new file mode 100644 index 0000000..778dc80 --- /dev/null +++ b/src/MessagePack.ts @@ -0,0 +1,38 @@ +/** MessagePack schema boundary shared by Redis record codecs. @internal */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import * as SchemaIssue from "effect/SchemaIssue"; +import { Packr } from "msgpackr"; + +const packr = new Packr({ useRecords: false, int64AsType: "number" }); + +/** MessagePack bytes to and from an unknown decoded value. */ +export const UnknownFromMsgpack = Schema.Uint8Array.pipe( + Schema.decodeTo(Schema.Unknown, { + decode: SchemaGetter.transformOrFail( + (bytes: Uint8Array, options): Effect.Effect => + Effect.try({ + try: () => packr.unpack(bytes), + catch: (cause) => + new SchemaIssue.InvalidValue( + { message: `MessagePack decoding failed: ${String(cause)}` }, + bytes, + options, + ), + }), + ), + encode: SchemaGetter.transformOrFail( + (value: unknown, options): Effect.Effect => + Effect.try({ + try: () => packr.pack(value), + catch: (cause) => + new SchemaIssue.InvalidValue( + { message: `MessagePack encoding failed: ${String(cause)}` }, + value, + options, + ), + }), + ), + }), +); diff --git a/src/NodeRedisPool.boundary.test.ts b/src/NodeRedisPool.boundary.test.ts new file mode 100644 index 0000000..6a748a5 --- /dev/null +++ b/src/NodeRedisPool.boundary.test.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from "node:events"; +import { expect, it } from "@effect/vitest"; +import { vi } from "vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const redisMock = vi.hoisted(() => ({ + clients: [] as Array, +})); + +vi.mock("redis", () => ({ + RESP_TYPES: { BLOB_STRING: "blob", MAP: "map" }, + createClientPool: () => redisMock.clients.shift(), + createSentinel: () => redisMock.clients.shift(), +})); + +import * as NodeRedisPool from "./NodeRedisPool.js"; + +class FakeClient extends EventEmitter { + readonly calls = { close: 0, connect: 0, destroy: 0 }; + + constructor( + private readonly options: { + readonly connectFailure?: unknown; + readonly closeFailure?: unknown; + } = {}, + ) { + super(); + } + + connect(): Promise { + this.calls.connect += 1; + return this.options.connectFailure === undefined + ? Promise.resolve() + : Promise.reject(this.options.connectFailure); + } + + close(): Promise { + this.calls.close += 1; + return this.options.closeFailure === undefined + ? Promise.resolve() + : Promise.reject(this.options.closeFailure); + } + + destroy(): void { + this.calls.destroy += 1; + } + + sendCommand(command: ReadonlyArray): Promise { + if (command[0] === "INFO") return Promise.resolve("cluster_enabled:0\r\n"); + if (command[0] === "PING") return Promise.resolve("PONG"); + return Promise.resolve(null); + } +} + +it.effect( + "removes listeners and force-destroys after rejected graceful close", + () => + Effect.gen(function* () { + const clients = [ + new FakeClient({ closeFailure: new Error("close rejected") }), + new FakeClient(), + new FakeClient(), + ]; + redisMock.clients = [...clients]; + + yield* Effect.scoped(Layer.build(NodeRedisPool.layer())); + + for (const client of clients) { + expect(client.listenerCount("error")).toBe(0); + expect(client.listenerCount("reconnecting")).toBe(0); + expect(client.calls.close).toBe(1); + } + expect(clients[0].calls.destroy).toBe(1); + expect(clients[1].calls.destroy).toBe(0); + expect(clients[2].calls.destroy).toBe(0); + }), +); + +it.effect("releases every acquired client after partial pool acquisition", () => + Effect.gen(function* () { + const first = new FakeClient(); + const second = new FakeClient({ + connectFailure: new Error("connect failed"), + }); + const third = new FakeClient(); + redisMock.clients = [first, second, third]; + + const exit = yield* Effect.scoped(Layer.build(NodeRedisPool.layer())).pipe( + Effect.exit, + ); + + expect(exit._tag).toBe("Failure"); + expect(first.calls.close).toBe(1); + expect(second.calls.close).toBe(1); + expect(third.calls.connect).toBe(0); + expect(first.listenerCount("error")).toBe(0); + expect(second.listenerCount("error")).toBe(0); + }), +); diff --git a/src/NodeRedisPool.test.ts b/src/NodeRedisPool.test.ts index 5c53693..4437457 100644 --- a/src/NodeRedisPool.test.ts +++ b/src/NodeRedisPool.test.ts @@ -1,91 +1,98 @@ -import { RedisContainer } from "@testcontainers/redis"; +import { expect, layer } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { expect, test } from "vitest"; import { NodeRedisPool, RedisPool, TaskEngine } from "./index.js"; +import { TestLayer, TestRedisAddress } from "./testing/redisLayer.js"; -test("provides a working RedisPool service backed by a node-redis pool", async () => { - const container = await new RedisContainer("redis:7").start(); - try { - const result = await Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const health = yield* NodeRedisPool.RedisConnectionHealth; - expect(yield* health.readiness).toBe(true); - yield* redis.send("SET", "node-redis-pool-test", "pong"); - const value = yield* redis.send("GET", "node-redis-pool-test"); - const snapshot = yield* health.snapshot; - expect(snapshot.ready).toBe(true); - expect(snapshot.topology).toBe("standalone"); - expect(JSON.stringify(snapshot)).not.toContain("redis://"); - return value; - }).pipe( - Effect.provide( - NodeRedisPool.layer({ url: container.getConnectionUrl() }), - ), - Effect.runPromise, +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "NodeRedisPool (real Redis time)", + (it) => { + it.effect( + "provides a working RedisPool service backed by a node-redis pool", + () => + Effect.gen(function* () { + const address = yield* TestRedisAddress; + const result = yield* Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const health = yield* NodeRedisPool.RedisConnectionHealth; + expect(yield* health.readiness).toBe(true); + yield* redis.send("SET", "node-redis-pool-test", "pong"); + const value = yield* redis.send( + "GET", + "node-redis-pool-test", + ); + const snapshot = yield* health.snapshot; + expect(snapshot.ready).toBe(true); + expect(snapshot.topology).toBe("standalone"); + expect(JSON.stringify(snapshot)).not.toContain("redis://"); + return value; + }).pipe(Effect.provide(NodeRedisPool.layer(address))); + expect(result).toBe("pong"); + }), ); - expect(result).toBe("pong"); - } finally { - await container.stop(); - } -}); -test("fails before connecting when Redis Cluster is configured", async () => { - const error = await Effect.gen(function* () { - yield* RedisPool.RedisPool; - }).pipe( - Effect.provide(NodeRedisPool.layer({ topology: "cluster" })), - Effect.flip, - Effect.runPromise, - ); + it.effect("fails before connecting when Redis Cluster is configured", () => + Effect.gen(function* () { + const error = yield* Effect.gen(function* () { + yield* RedisPool.RedisPool; + }).pipe( + Effect.provide(NodeRedisPool.layer({ topology: "cluster" })), + Effect.flip, + ); - expect(error).toBeInstanceOf(NodeRedisPool.UnsupportedRedisTopology); - expect(error.topology).toBe("cluster"); -}); + expect(error).toBeInstanceOf(NodeRedisPool.UnsupportedRedisTopology); + if (!(error instanceof NodeRedisPool.UnsupportedRedisTopology)) { + throw new Error("Expected UnsupportedRedisTopology"); + } + expect(error.topology).toBe("cluster"); + }), + ); -test("rejects unbounded or inconsistent pool configuration", async () => { - const error = await Effect.gen(function* () { - yield* RedisPool.RedisPool; - }).pipe( - Effect.provide(NodeRedisPool.layer({ pool: { minimum: 5, maximum: 2 } })), - Effect.flip, - Effect.runPromise, - ); + it.effect("rejects unbounded or inconsistent pool configuration", () => + Effect.gen(function* () { + const error = yield* Effect.gen(function* () { + yield* RedisPool.RedisPool; + }).pipe( + Effect.provide( + NodeRedisPool.layer({ pool: { minimum: 5, maximum: 2 } }), + ), + Effect.flip, + ); - expect(error).toBeInstanceOf(NodeRedisPool.InvalidRedisConfiguration); -}); + expect(error).toBeInstanceOf(NodeRedisPool.InvalidRedisConfiguration); + }), + ); -// the integration suite runs the engine through ioredis; this exercises the -// node-redis binary reply path (typeMapping) for msgpack round-trips -test("TaskEngine msgpack round-trip through the node-redis pool", async () => { - const container = await new RedisContainer("redis:7").start(); - try { - const payload = { nested: { value: [1, null, "✓"] }, n: 1.5 }; - const result = await Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const prefix = "node-redis-msgpack"; - yield* engine.createTask({ - id: "nr1", - name: "t", - payload, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - prefix, - }); - return (yield* engine.takeTask(prefix, 30000))?.task; - }).pipe( - Effect.provide( - Layer.provideMerge( - TaskEngine.layer(), - NodeRedisPool.layer({ url: container.getConnectionUrl() }), - ), - ), - Effect.runPromise, + // the integration suite runs the engine through ioredis; this exercises the + // node-redis binary reply path (typeMapping) for msgpack round-trips + it.effect("TaskEngine msgpack round-trip through the node-redis pool", () => + Effect.gen(function* () { + const address = yield* TestRedisAddress; + const payload = { nested: { value: [1, null, "✓"] }, n: 1.5 }; + const result = yield* Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const prefix = "node-redis-msgpack"; + yield* engine.createTask({ + id: "nr1", + name: "t", + payload, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + prefix, + }); + return (yield* engine.takeTask(prefix, 30000))?.task; + }).pipe( + Effect.provide( + Layer.provideMerge( + TaskEngine.layerNoDeps(), + NodeRedisPool.layer(address), + ), + ), + ); + expect(result?.payload).toEqual(payload); + expect(result?.errors).toEqual([]); + }), ); - expect(result?.payload).toEqual(payload); - expect(result?.errors).toEqual([]); - } finally { - await container.stop(); - } -}); + }, +); diff --git a/src/NodeRedisPool.ts b/src/NodeRedisPool.ts index 1cce94d..70ee142 100644 --- a/src/NodeRedisPool.ts +++ b/src/NodeRedisPool.ts @@ -7,7 +7,14 @@ * * @module */ -import { Context, Data, Effect, Layer, Metric, Ref, Scope } from "effect"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Redis from "effect/unstable/persistence/Redis"; import { createClientPool, @@ -18,6 +25,7 @@ import { type RedisSentinelOptions, } from "redis"; import * as Observability from "./Observability.js"; +import * as RedisReadiness from "./RedisReadiness.js"; import { makeConnectionRoles, make as makeRedisPool, @@ -181,15 +189,15 @@ export interface RedisConnectionHealthService { export class RedisConnectionHealth extends Context.Service< RedisConnectionHealth, RedisConnectionHealthService ->()("effectmq/RedisConnectionHealth") {} +>()("@effectmq/core/RedisConnectionHealth") {} const roles: ReadonlyArray = ["producer", "worker", "maintenance"]; -const initialRoleHealth = (): RedisRoleHealth => ({ +const initialRoleHealth = (now: number): RedisRoleHealth => ({ state: "disconnected", commandErrors: 0, reconnects: 0, - lastChangeAt: Date.now(), + lastChangeAt: now, }); const binaryTypeMapping = { @@ -245,66 +253,76 @@ const makeClient = Effect.fnUntraced(function* ( role: RedisRole, health: Ref.Ref, ) { + const clock = yield* Clock.Clock; + const now = () => clock.currentTimeMillisUnsafe(); const topology = config.topology ?? "standalone"; const standalone = topology === "standalone"; - const standaloneClient = - config.topology !== "sentinel" - ? (() => { - const { topology: _topology, pool, ...clientOptions } = config; - return createClientPool( - clientOptions as Omit, - pool as Partial | undefined, - ); - })() - : undefined; - const sentinelClient = - config.topology === "sentinel" - ? createSentinel(config.sentinel) - : undefined; + const { standaloneClient, sentinelClient } = yield* Effect.try({ + try: () => ({ + standaloneClient: + config.topology !== "sentinel" + ? (() => { + const { topology: _topology, pool, ...clientOptions } = config; + return createClientPool( + clientOptions as Omit, + pool as Partial | undefined, + ); + })() + : undefined, + sentinelClient: + config.topology === "sentinel" + ? createSentinel(config.sentinel) + : undefined, + }), + catch: (cause) => new Redis.RedisError({ cause }), + }); const client = standaloneClient ?? sentinelClient; - if (client === undefined) throw new Error("unreachable Redis topology"); + if (client === undefined) return yield* unsupportedCluster(); // EventEmitter treats an unhandled error event as process-fatal. Install // before connect and log no connection config or raw error string. - client.on("error", () => { + const onError = () => { Effect.runFork( Effect.all([ updateHealth(health, role, (current) => ({ ...current, state: "degraded", commandErrors: current.commandErrors + 1, - lastChangeAt: Date.now(), + lastChangeAt: now(), })), Effect.logError("effectmq Redis connection error", { role, topology }), Metric.update(Observability.redisErrors, 1), ]).pipe(Effect.asVoid), ); - }); + }; + client.on("error", onError); + let onTopologyChange: ((event: unknown) => void) | undefined; if (standalone) { - client.on("reconnecting", () => { + onTopologyChange = () => { Effect.runFork( Effect.all([ updateHealth(health, role, (current) => ({ ...current, state: "connecting", reconnects: current.reconnects + 1, - lastChangeAt: Date.now(), + lastChangeAt: now(), })), Effect.logWarning("effectmq Redis connection reconnecting", { role }), Metric.update(Observability.redisReconnects, 1), ]).pipe(Effect.asVoid), ); - }); + }; + client.on("reconnecting", onTopologyChange); } else { - client.on("topology-change", (event) => { + onTopologyChange = (event) => { Effect.runFork( Effect.all([ updateHealth(health, role, (current) => ({ ...current, state: "connecting", reconnects: current.reconnects + 1, - lastChangeAt: Date.now(), + lastChangeAt: now(), })), Effect.logWarning("effectmq Redis Sentinel topology changed", { role, @@ -316,13 +334,49 @@ const makeClient = Effect.fnUntraced(function* ( Metric.update(Observability.redisReconnects, 1), ]).pipe(Effect.asVoid), ); - }); + }; + client.on("topology-change", onTopologyChange); } const scope = yield* Effect.scope; + let closed = false; yield* Scope.addFinalizer( scope, - Effect.promise(() => client.close()), + Effect.sync(() => { + client.off("error", onError); + if (onTopologyChange !== undefined) { + client.off( + standalone ? "reconnecting" : "topology-change", + onTopologyChange, + ); + } + }).pipe( + Effect.andThen( + Effect.suspend(() => { + if (closed) return Effect.void; + closed = true; + return Effect.tryPromise({ + try: () => client.close(), + catch: (cause) => new Redis.RedisError({ cause }), + }).pipe( + Effect.catch((closeError) => + Effect.logWarning( + "effectmq Redis graceful close failed; forcing destroy", + { role, topology, closeError }, + ).pipe( + Effect.andThen( + Effect.try({ + try: () => client.destroy(), + catch: (cause) => new Redis.RedisError({ cause }), + }), + ), + Effect.orDie, + ), + ), + ); + }), + ), + ), ); const rawSend = ( @@ -344,7 +398,7 @@ const makeClient = Effect.fnUntraced(function* ( yield* updateHealth(health, role, (current) => ({ ...current, state: "connecting", - lastChangeAt: Date.now(), + lastChangeAt: now(), })); yield* Effect.tryPromise({ try: async () => await client.connect(), @@ -358,7 +412,7 @@ const makeClient = Effect.fnUntraced(function* ( yield* updateHealth(health, role, (current) => ({ ...current, state: "ready", - lastChangeAt: Date.now(), + lastChangeAt: now(), })); }).pipe(Effect.cached); // Building the scoped Layer is the startup boundary: resolve topology and @@ -381,7 +435,7 @@ const makeClient = Effect.fnUntraced(function* ( ...current, state: "degraded", commandErrors: current.commandErrors + 1, - lastChangeAt: Date.now(), + lastChangeAt: now(), })), ), ); @@ -389,7 +443,7 @@ const makeClient = Effect.fnUntraced(function* ( ...current, state: "ready", lastChangeAt: - current.state === "ready" ? current.lastChangeAt : Date.now(), + current.state === "ready" ? current.lastChangeAt : now(), })); return result; }); @@ -409,10 +463,11 @@ const make = Effect.fnUntraced(function* (config: RedisConfig = {}) { const topology: "standalone" | "sentinel" = config.topology === "sentinel" ? "sentinel" : "standalone"; + const now = yield* Clock.currentTimeMillis; const health = yield* Ref.make({ - producer: initialRoleHealth(), - worker: initialRoleHealth(), - maintenance: initialRoleHealth(), + producer: initialRoleHealth(now), + worker: initialRoleHealth(now), + maintenance: initialRoleHealth(now), }); const supported = config as Exclude; const producer = yield* makeClient(supported, "producer", health); @@ -430,14 +485,11 @@ const make = Effect.fnUntraced(function* (config: RedisConfig = {}) { ); const connectionHealth = RedisConnectionHealth.of({ snapshot, - readiness: Effect.all([ + readiness: RedisReadiness.fromProbes([ producer.redisPool.send("PING"), worker.redisPool.send("PING"), maintenance.redisPool.send("PING"), - ]).pipe( - Effect.as(true), - Effect.catchCause(() => Effect.succeed(false)), - ), + ]), }); return Context.make(RedisPool, producer.redisPool).pipe( diff --git a/src/Observability.ts b/src/Observability.ts index 0be5609..476744e 100644 --- a/src/Observability.ts +++ b/src/Observability.ts @@ -1,5 +1,6 @@ /** Effect metrics emitted by EffectMQ's Redis and queue runtime. @module */ -import { Effect, Metric } from "effect"; +import * as Effect from "effect/Effect"; +import * as Metric from "effect/Metric"; /** * Gauge of runnable, delayed, and leased tasks, attributed by queue. diff --git a/src/PublicContracts.test.ts b/src/PublicContracts.test.ts new file mode 100644 index 0000000..45534e9 --- /dev/null +++ b/src/PublicContracts.test.ts @@ -0,0 +1,173 @@ +import { expect, it } from "vitest"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as RedisPool from "./RedisPool.js"; +import * as TaskEngine from "./TaskEngine.js"; +import * as TaskQueue from "./TaskQueue.js"; +import * as TaskRecord from "./TaskRecord.js"; +import type { + Equal, + Expect, + ExpectFalse, + IsAny, + IsUnknown, +} from "./testing/TypeAssertions.js"; + +class QueueService extends Context.Service()( + "@effectmq/core/test/QueueService", +) {} +class HandlerService extends Context.Service()( + "@effectmq/core/test/HandlerService", +) {} +class IdentityService extends Context.Service()( + "@effectmq/core/test/IdentityService", +) {} + +const Payload = Schema.String; +const Success = Schema.Number; +const Failure = Schema.Boolean; + +const compilePublicContracts = () => { + const queue = undefined as unknown as TaskQueue.TaskQueue< + typeof Payload, + typeof Success, + typeof Failure, + QueueService, + IdentityService + >; + const handler = undefined as unknown as TaskQueue.TaskHandler< + typeof Payload, + typeof Success, + typeof Failure, + HandlerService + >; + const handle = undefined as unknown as TaskQueue.TaskHandle; + const engineTask = undefined as unknown as Parameters< + typeof TaskRecord.decodeTask + >[1]; + + const complete = TaskQueue.complete(queue, handler); + type CompleteSuccess = Expect, string>>; + type CompleteError = Expect< + Equal, TaskQueue.CompleteError> + >; + type CompleteServices = Expect< + Equal< + Effect.Services, + TaskQueue.CompleteRequirements< + typeof Payload, + typeof Success, + typeof Failure, + QueueService, + HandlerService + > + > + >; + + const completeOne = TaskQueue.completeOne(queue, handler); + type CompleteOneSuccess = Expect< + Equal, boolean> + >; + type CompleteOneError = Expect< + Equal, TaskQueue.CompleteError> + >; + type CompleteOneServices = Expect< + Equal, Effect.Services> + >; + + const decoded = TaskRecord.decodeTask( + { + schemaId: "contract", + payloadSchema: Payload, + successSchema: Success, + errorSchema: Failure, + }, + engineTask, + ); + type DecodeSuccess = Expect< + Equal< + Effect.Success, + TaskRecord.Task + > + >; + type DecodeServices = Expect, never>>; + + const waited = TaskQueue.wait(queue, handle); + type WaitSuccess = Expect, number>>; + type WaitError = Expect< + Equal, TaskQueue.WaitError> + >; + type WaitServices = Expect< + Equal< + Effect.Services, + TaskQueue.WaitRequirements + > + >; + + const executed = TaskQueue.execute(queue, "payload"); + type ExecuteSuccess = Expect, number>>; + type ExecuteError = Expect< + Equal, TaskQueue.ExecuteError> + >; + type ExecuteServices = Expect< + Equal< + Effect.Services, + TaskQueue.ExecuteRequirements< + typeof Payload, + typeof Success, + typeof Failure, + IdentityService + > + > + >; + + type RejectErasedError = ExpectFalse< + Equal, never> + >; + type RejectAnyError = ExpectFalse>>; + type RejectUnknownError = ExpectFalse< + IsUnknown> + >; + type RejectErasedServices = ExpectFalse< + Equal, never> + >; + type RejectAnyServices = ExpectFalse>>; + + const layerNoDeps = TaskEngine.layerNoDeps(); + const liveLayer = TaskEngine.layer(); + type LayerNoDepsRequirement = Expect< + Equal, RedisPool.RedisPool> + >; + type LiveLayerRequirement = Expect< + Equal, never> + >; + + return undefined as unknown as + | CompleteSuccess + | CompleteError + | CompleteServices + | CompleteOneSuccess + | CompleteOneError + | CompleteOneServices + | DecodeSuccess + | DecodeServices + | WaitSuccess + | WaitError + | WaitServices + | ExecuteSuccess + | ExecuteError + | ExecuteServices + | RejectErasedError + | RejectAnyError + | RejectUnknownError + | RejectErasedServices + | RejectAnyServices + | LayerNoDepsRequirement + | LiveLayerRequirement; +}; + +it("pins public Effect and Layer channels at compile time", () => { + expect(typeof compilePublicContracts).toBe("function"); +}); diff --git a/src/RedisCompatibility.test.ts b/src/RedisCompatibility.test.ts index 3069eea..9271fb1 100644 --- a/src/RedisCompatibility.test.ts +++ b/src/RedisCompatibility.test.ts @@ -1,36 +1,34 @@ -import { RedisContainer } from "@testcontainers/redis"; +import { expect, layer } from "@effect/vitest"; import { Effect } from "effect"; -import { expect, test } from "vitest"; import { NodeRedisPool, RedisPool } from "./index.js"; +import { redisContainerLayer, TestRedisAddress } from "./testing/redisLayer.js"; const image = process.env.EFFECTMQ_COMPAT_REDIS_IMAGE ?? "redis:7.2-alpine"; const resp = Number(process.env.EFFECTMQ_COMPAT_RESP ?? "3") as 2 | 3; -test(`compatibility: ${image} over RESP${resp}`, async () => { - const container = await new RedisContainer(image).start(); - try { - const result = await Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - yield* redis.send("SET", "effectmq:compat", `RESP${resp}`); - const value = yield* redis.send("GET", "effectmq:compat"); - const binary = new Uint8Array([0, 255, 1, 128]); - yield* redis.sendBinary("SET", "effectmq:compat:binary", binary); - const binaryResult = yield* redis.sendBinary( - "GET", - "effectmq:compat:binary", +layer(redisContainerLayer({ image }), { + excludeTestServices: true, + timeout: "60 seconds", +})(`Redis compatibility (real Redis time)`, (it) => { + it.effect(`compatibility: ${image} over RESP${resp}`, () => + Effect.gen(function* () { + const address = yield* TestRedisAddress; + const result = yield* Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + yield* redis.send("SET", "effectmq:compat", `RESP${resp}`); + const value = yield* redis.send("GET", "effectmq:compat"); + const binary = new Uint8Array([0, 255, 1, 128]); + yield* redis.sendBinary("SET", "effectmq:compat:binary", binary); + const binaryResult = yield* redis.sendBinary( + "GET", + "effectmq:compat:binary", + ); + return { binaryResult, value }; + }).pipe(Effect.provide(NodeRedisPool.layer({ ...address, RESP: resp }))); + expect(result.value).toBe(`RESP${resp}`); + expect(Buffer.from(result.binaryResult)).toEqual( + Buffer.from([0, 255, 1, 128]), ); - return { binaryResult, value }; - }).pipe( - Effect.provide( - NodeRedisPool.layer({ RESP: resp, url: container.getConnectionUrl() }), - ), - Effect.runPromise, - ); - expect(result.value).toBe(`RESP${resp}`); - expect(Buffer.from(result.binaryResult)).toEqual( - Buffer.from([0, 255, 1, 128]), - ); - } finally { - await container.stop(); - } + }), + ); }); diff --git a/src/RedisPool.ts b/src/RedisPool.ts index 2761a60..a83b2e8 100644 --- a/src/RedisPool.ts +++ b/src/RedisPool.ts @@ -5,7 +5,10 @@ * * @module */ -import { Context, Effect, Metric, Ref } from "effect"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Metric from "effect/Metric"; +import * as Ref from "effect/Ref"; import type * as Redis from "effect/unstable/persistence/Redis"; import * as Observability from "./Observability.js"; @@ -81,7 +84,7 @@ export interface RedisPoolService { * @since 0.2.0 */ export class RedisPool extends Context.Service()( - "effectmq/RedisPool", + "@effectmq/core/RedisPool", ) {} /** @@ -108,7 +111,7 @@ export interface RedisConnectionRolesService { export class RedisConnectionRoles extends Context.Service< RedisConnectionRoles, RedisConnectionRolesService ->()("effectmq/RedisConnectionRoles") {} +>()("@effectmq/core/RedisConnectionRoles") {} /** * Creates role routing from three independently managed Redis services. diff --git a/src/RedisReadiness.test.ts b/src/RedisReadiness.test.ts new file mode 100644 index 0000000..180063e --- /dev/null +++ b/src/RedisReadiness.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Redis from "effect/unstable/persistence/Redis"; +import { fromProbes } from "./RedisReadiness.js"; + +it.effect("readiness recovers only expected Redis failures", () => + Effect.gen(function* () { + const ready = yield* fromProbes([Effect.succeed("PONG")]); + expect(ready).toBe(true); + + const unavailable = yield* fromProbes([ + Effect.fail(new Redis.RedisError({ cause: new Error("offline") })), + ]); + expect(unavailable).toBe(false); + + const defect = yield* fromProbes([Effect.die("defect")]).pipe(Effect.exit); + expect(Exit.isFailure(defect)).toBe(true); + if (Exit.isFailure(defect)) expect(Cause.hasDies(defect.cause)).toBe(true); + }), +); + +it.effect("readiness interruption propagates", () => + Effect.gen(function* () { + const fiber = yield* fromProbes([Effect.never]).pipe(Effect.forkChild); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), +); diff --git a/src/RedisReadiness.ts b/src/RedisReadiness.ts new file mode 100644 index 0000000..8b2dd0a --- /dev/null +++ b/src/RedisReadiness.ts @@ -0,0 +1,11 @@ +/** Readiness policy shared by Redis adapters. @internal */ +import * as Effect from "effect/Effect"; +import type * as Redis from "effect/unstable/persistence/Redis"; + +export const fromProbes = ( + probes: ReadonlyArray>, +): Effect.Effect => + Effect.all(probes).pipe( + Effect.as(true), + Effect.catchTag("RedisError", () => Effect.succeed(false)), + ); diff --git a/src/RedisRestart.test.ts b/src/RedisRestart.test.ts index aa3851b..cb347f4 100644 --- a/src/RedisRestart.test.ts +++ b/src/RedisRestart.test.ts @@ -3,136 +3,227 @@ import { once } from "node:events"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Layer, Schedule } from "effect"; +import { expect, it, layer } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import { Redis as IORedis } from "ioredis"; -import { expect, test } from "vitest"; import { NodeRedisPool, RedisPool, TaskEngine } from "./index.js"; import * as FaultInjection from "./testing/FaultInjection.js"; +import { TestInfrastructureError } from "./testing/redisLayer.js"; -const startRedis = async (socket: string, directory: string) => { - const child = spawn( - "redis-server", - [ - "--port", - "0", - "--unixsocket", - socket, - "--dir", - directory, - "--dbfilename", - "restart.rdb", - "--save", - "", - ], - { stdio: "ignore" }, - ); - const probe = new IORedis({ path: socket, lazyConnect: true }); - try { - for (let attempt = 0; attempt < 100; attempt++) { - try { - await probe.ping(); - return child; - } catch { - await new Promise((resolve) => setTimeout(resolve, 20)); - } - } - throw new Error("Redis restart probe timed out"); - } finally { - probe.disconnect(); - } -}; +const stopRedis = (child: ChildProcess) => + Effect.tryPromise({ + try: async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGTERM"); + await exited; + }, + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "redis-server-stop", + }), + }); -const stopRedis = async (child: ChildProcess | undefined) => { - if (!child || child.exitCode !== null) return; - child.kill("SIGTERM"); - await once(child, "exit"); -}; +const startRedis = Effect.fnUntraced(function* ( + socket: string, + directory: string, +) { + const child = yield* Effect.try({ + try: () => + spawn( + "redis-server", + [ + "--port", + "0", + "--unixsocket", + socket, + "--dir", + directory, + "--dbfilename", + "restart.rdb", + "--save", + "", + ], + { stdio: "ignore" }, + ), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "redis-server-start", + }), + }); -test.skipIf(process.env.EFFECTMQ_TEST_REDIS !== "local")( - "a current attempt survives Redis restart and reloads its script", - async () => { - const directory = mkdtempSync(join(tmpdir(), "effectmq-restart-")); - const socket = join(directory, "redis.sock"); - let server: ChildProcess | undefined; - try { - server = await startRedis(socket, directory); - const program = Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const fault = yield* FaultInjection.make({ reconnect: 1, restart: 1 }); - const prefix = "redis-restart"; - yield* engine.createTask({ - prefix, - id: "restart", - name: "restart", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - }); - const attempt = yield* engine.takeTask(prefix, 30_000); - if (attempt === null) return yield* Effect.die("Expected an attempt"); - yield* redis.send("SAVE"); + yield* Effect.scoped( + Effect.gen(function* () { + const probe = yield* Effect.acquireRelease( + Effect.try({ + try: () => new IORedis({ path: socket, lazyConnect: true }), + catch: (cause) => + new TestInfrastructureError({ cause, operation: "client-create" }), + }), + (client) => + Effect.try({ + try: () => client.disconnect(), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "client-disconnect", + }), + }).pipe(Effect.orDie), + ); + yield* Effect.tryPromise({ + try: () => probe.ping(), + catch: (cause) => + new TestInfrastructureError({ cause, operation: "redis-ready" }), + }).pipe( + Effect.retry({ + times: 100, + schedule: Schedule.spaced("20 millis"), + }), + ); + }), + ).pipe(Effect.tapError(() => stopRedis(child).pipe(Effect.orDie))); - const restartFault = yield* fault - .after( - "restart", - Effect.promise(async () => { - await stopRedis(server); - server = await startRedis(socket, directory); + return child; +}); + +class RestartFixture extends Context.Service< + RestartFixture, + { + readonly socket: string; + readonly restart: Effect.Effect; + } +>()("effectmq/testing/RestartFixture") {} + +const restartFixtureLayer = Layer.effect( + RestartFixture, + Effect.gen(function* () { + const directory = yield* Effect.acquireRelease( + Effect.try({ + try: () => mkdtempSync(join(tmpdir(), "effectmq-restart-")), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "directory-create", + }), + }), + (path) => + Effect.try({ + try: () => rmSync(path, { recursive: true, force: true }), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "directory-remove", }), - ) - .pipe(Effect.flip); - expect(restartFault).toMatchObject({ point: "restart" }); + }).pipe(Effect.orDie), + ); + const socket = join(directory, "redis.sock"); + let server = yield* startRedis(socket, directory); + yield* Effect.addFinalizer(() => stopRedis(server).pipe(Effect.orDie)); - const reconnectFault = yield* fault - .before( - "reconnect", - engine.writeSuccess( + return RestartFixture.of({ + socket, + restart: Effect.gen(function* () { + yield* stopRedis(server); + server = yield* startRedis(socket, directory); + }), + }); + }), +); + +if (process.env.EFFECTMQ_TEST_REDIS === "local") { + layer(restartFixtureLayer, { + excludeTestServices: true, + timeout: "20 seconds", + })("Redis restart (real Redis time)", (it) => { + it.effect( + "a current attempt survives Redis restart and reloads its script", + () => + Effect.gen(function* () { + const fixture = yield* RestartFixture; + const task = yield* Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const fault = yield* FaultInjection.make({ + reconnect: 1, + restart: 1, + }); + const prefix = "redis-restart"; + yield* engine.createTask({ prefix, - attempt.task.id, - attempt.leaseToken, - "after-restart", - ), - ) - .pipe(Effect.flip); - expect(reconnectFault).toMatchObject({ point: "reconnect" }); + id: "restart", + name: "restart", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + const attempt = yield* engine.takeTask(prefix, 30_000); + if (attempt === null) + return yield* Effect.die("Expected an attempt"); + yield* redis.send("SAVE"); - // The process restart clears Redis' script cache. The cached digest - // therefore takes the NOSCRIPT reload path before acknowledging. - yield* engine - .writeSuccess( - prefix, - attempt.task.id, - attempt.leaseToken, - "after-restart", - ) - .pipe( - Effect.retry({ - times: 50, - schedule: Schedule.spaced("20 millis"), - }), + const restartFault = yield* fault + .after("restart", fixture.restart) + .pipe(Effect.flip); + expect(restartFault).toMatchObject({ point: "restart" }); + + const reconnectFault = yield* fault + .before( + "reconnect", + engine.writeSuccess( + prefix, + attempt.task.id, + attempt.leaseToken, + "after-restart", + ), + ) + .pipe(Effect.flip); + expect(reconnectFault).toMatchObject({ point: "reconnect" }); + + yield* engine + .writeSuccess( + prefix, + attempt.task.id, + attempt.leaseToken, + "after-restart", + ) + .pipe( + Effect.retry({ + times: 50, + schedule: Schedule.spaced("20 millis"), + }), + ); + return yield* engine.getTask(prefix, "restart"); + }).pipe( + Effect.provide( + Layer.merge( + Layer.provideMerge( + TaskEngine.layerNoDeps(), + NodeRedisPool.layer({ + socket: { path: fixture.socket, tls: false }, + }), + ), + NodeCrypto.layer, + ), + ), ); - return yield* engine.getTask(prefix, "restart"); - }).pipe( - Effect.provide( - Layer.provideMerge( - TaskEngine.layer(), - NodeRedisPool.layer({ socket: { path: socket } }), - ), - ), - ); - const task = await Effect.runPromise(program); - expect(task).toMatchObject({ - outcome: "success", - success: "after-restart", - }); - } finally { - await stopRedis(server); - rmSync(directory, { recursive: true, force: true }); - } - }, - 20_000, -); + expect(task).toMatchObject({ + outcome: "success", + success: "after-restart", + }); + }), + 20_000, + ); + }); +} else { + it.skip("a current attempt survives Redis restart and reloads its script", () => + Effect.void); +} diff --git a/src/RedisSentinel.test.ts b/src/RedisSentinel.test.ts index a1f9d19..ac0ac3c 100644 --- a/src/RedisSentinel.test.ts +++ b/src/RedisSentinel.test.ts @@ -1,16 +1,18 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Schedule } from "effect"; +import { expect, it, layer } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import { Redis as IORedis } from "ioredis"; -import { afterAll, beforeAll, expect, test } from "vitest"; import { NodeRedisPool, RedisPool } from "./index.js"; import * as FaultInjection from "./testing/FaultInjection.js"; - -const enabled = process.env.EFFECTMQ_TEST_SENTINEL === "local"; -const sentinelTest = enabled ? test : test.skip; +import { TestInfrastructureError } from "./testing/redisLayer.js"; const availablePort = () => new Promise((resolve, reject) => { @@ -27,10 +29,16 @@ const availablePort = () => }); }); -const waitFor = async (check: () => Promise, timeoutMs = 15_000) => { +const waitFor = async ( + description: string, + check: () => Promise, + timeoutMs = 15_000, +) => { const deadline = Date.now() + timeoutMs; + let attempts = 0; let lastError: unknown; while (Date.now() < deadline) { + attempts += 1; try { if (await check()) return; } catch (error) { @@ -38,20 +46,38 @@ const waitFor = async (check: () => Promise, timeoutMs = 15_000) => { } await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error("timed out waiting for Redis Sentinel", { cause: lastError }); + throw new Error( + `timed out waiting for ${description} after ${attempts} attempts`, + { + cause: lastError, + }, + ); +}; + +const stopChild = async (child: ChildProcess) => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; }; -let directory = ""; -let masterPort = 0; -let replicaPort = 0; -let sentinelPorts: ReadonlyArray = []; -let master: ChildProcess | undefined; -let children: ReadonlyArray = []; +interface StartedSentinel { + readonly directory: string; + readonly master: ChildProcess; + readonly masterPort: number; + readonly replicaPort: number; + readonly sentinelPorts: ReadonlyArray; + readonly children: ReadonlyArray; +} + +const stopSentinel = async (fixture: StartedSentinel) => { + await Promise.all(fixture.children.map(stopChild)); + rmSync(fixture.directory, { force: true, recursive: true }); +}; -beforeAll(async () => { - if (!enabled) return; - directory = mkdtempSync(join(tmpdir(), "effectmq-sentinel-")); - [masterPort, replicaPort, ...sentinelPorts] = await Promise.all( +const startSentinel = async (): Promise => { + const directory = mkdtempSync(join(tmpdir(), "effectmq-sentinel-")); + const [masterPort, replicaPort, ...sentinelPorts] = await Promise.all( Array.from({ length: 5 }, availablePort), ); const spawnRedis = (args: ReadonlyArray) => @@ -60,7 +86,7 @@ beforeAll(async () => { const replicaDir = join(directory, "replica"); mkdirSync(masterDir); mkdirSync(replicaDir); - master = spawnRedis([ + const master = spawnRedis([ "--port", String(masterPort), "--bind", @@ -110,7 +136,14 @@ beforeAll(async () => { ); return spawnRedis([config, "--sentinel"]); }); - children = [master, replica, ...sentinels]; + const fixture = { + children: [master, replica, ...sentinels], + directory, + master, + masterPort, + replicaPort, + sentinelPorts, + } satisfies StartedSentinel; const masterClient = new IORedis(masterPort, "127.0.0.1", { lazyConnect: true, @@ -128,8 +161,11 @@ beforeAll(async () => { }), ); try { - await waitFor(async () => (await masterClient.ping()) === "PONG"); - await waitFor(async () => { + await waitFor( + "Redis master readiness", + async () => (await masterClient.ping()) === "PONG", + ); + await waitFor("Redis replica synchronization", async () => { const replication = await replicaClient.info("replication"); return ( replication.includes("role:slave") && @@ -137,7 +173,7 @@ beforeAll(async () => { ); }); for (const client of sentinelClients) { - await waitFor(async () => { + await waitFor("Sentinel quorum", async () => { const address = (await client.call( "SENTINEL", "get-master-addr-by-name", @@ -158,96 +194,174 @@ beforeAll(async () => { ); }); } + return fixture; + } catch (error) { + await stopSentinel(fixture); + throw error; } finally { masterClient.disconnect(); replicaClient.disconnect(); for (const client of sentinelClients) client.disconnect(); } -}, 30_000); +}; -afterAll(() => { - for (const child of children) child.kill("SIGKILL"); - if (directory !== "") rmSync(directory, { force: true, recursive: true }); -}); +class SentinelFixture extends Context.Service< + SentinelFixture, + StartedSentinel +>()("effectmq/testing/SentinelFixture") {} -sentinelTest( - "discovers a promoted primary and reloads scripts after failover", - async () => { - const script = "return ARGV[1]"; - const result = await Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const health = yield* NodeRedisPool.RedisConnectionHealth; - const fault = yield* FaultInjection.make({ "sentinel-failover": 1 }); - expect(yield* redis.evalScript(script, {}, "before")).toBe( - "before", - ); - // Let all three role pools finish their initial Sentinel discovery before - // inducing the outage; otherwise pool startup and failover discovery race - // and can make a healthy promoted replica look unavailable. - yield* Effect.sleep("1 second"); +const sentinelFixtureLayer = Layer.effect( + SentinelFixture, + Effect.acquireRelease( + Effect.tryPromise({ + try: startSentinel, + catch: (cause) => + new TestInfrastructureError({ cause, operation: "sentinel-setup" }), + }), + (fixture) => + Effect.tryPromise({ + try: () => stopSentinel(fixture), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "sentinel-teardown", + }), + }).pipe(Effect.orDie), + ), +); - const failoverFault = yield* fault - .after( - "sentinel-failover", - Effect.sync(() => master?.kill("SIGKILL")), - ) - .pipe(Effect.flip); - expect(failoverFault).toMatchObject({ point: "sentinel-failover" }); - const sentinelClient = new IORedis(sentinelPorts[0], "127.0.0.1", { - lazyConnect: true, - maxRetriesPerRequest: 0, - }); - yield* Effect.tryPromise({ - try: () => - waitFor(async () => { - const address = (await sentinelClient.call( - "SENTINEL", - "get-master-addr-by-name", - "effectmq", - )) as [string, string] | null; - return address?.[1] === String(replicaPort); - }, 30_000).finally(() => sentinelClient.disconnect()), - catch: (cause) => cause, - }); +if (process.env.EFFECTMQ_TEST_SENTINEL === "local") { + layer(sentinelFixtureLayer, { + excludeTestServices: true, + timeout: "30 seconds", + })("Redis Sentinel failover (real Redis time)", (it) => { + it.effect( + "discovers a promoted primary and reloads scripts after failover", + () => + Effect.gen(function* () { + const fixture = yield* SentinelFixture; + const result = yield* Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const health = yield* NodeRedisPool.RedisConnectionHealth; + const fault = yield* FaultInjection.make({ + "sentinel-failover": 1, + }); + const script = "return ARGV[1]"; + expect(yield* redis.evalScript(script, {}, "before")).toBe( + "before", + ); - const after = yield* redis.evalScript(script, {}, "after").pipe( - Effect.retry({ - schedule: Schedule.spaced("100 millis"), - times: 200, - }), - ); - const snapshot = yield* health.snapshot; - return { after, snapshot }; - }).pipe( - Effect.provide( - NodeRedisPool.layer({ - topology: "sentinel", - sentinel: { - name: "effectmq", - sentinelRootNodes: sentinelPorts.map((port) => ({ - host: "127.0.0.1", - port, - })), - masterPoolSize: 4, - maxCommandRediscovers: 20, - passthroughClientErrorEvents: true, - scanInterval: 100, - commandOptions: { timeout: 1_000 }, - nodeClientOptions: { - socket: { connectTimeout: 500 }, - }, - sentinelClientOptions: { - socket: { connectTimeout: 500 }, - }, - }, + yield* health.readiness.pipe( + Effect.repeat({ + schedule: Schedule.spaced("100 millis"), + until: (ready) => ready, + }), + ); + + const failoverFault = yield* fault + .after( + "sentinel-failover", + Effect.try({ + try: () => fixture.master.kill("SIGKILL"), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "sentinel-teardown", + }), + }), + ) + .pipe(Effect.flip); + expect(failoverFault).toMatchObject({ point: "sentinel-failover" }); + + const sentinelClient = yield* Effect.acquireRelease( + Effect.try({ + try: () => + new IORedis(fixture.sentinelPorts[0], "127.0.0.1", { + lazyConnect: true, + maxRetriesPerRequest: 0, + }), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "client-create", + }), + }), + (client) => + Effect.try({ + try: () => client.disconnect(), + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "client-disconnect", + }), + }).pipe(Effect.orDie), + ); + yield* Effect.tryPromise({ + try: async () => { + const address = (await sentinelClient.call( + "SENTINEL", + "get-master-addr-by-name", + "effectmq", + )) as [string, string] | null; + return address?.[1] === String(fixture.replicaPort); + }, + catch: (cause) => + new TestInfrastructureError({ + cause, + operation: "redis-ready", + }), + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("100 millis"), + until: (promoted) => promoted, + }), + Effect.timeout("30 seconds"), + ); + + const after = yield* redis + .evalScript(script, {}, "after") + .pipe( + Effect.retry({ + schedule: Schedule.spaced("100 millis"), + times: 200, + }), + ); + const snapshot = yield* health.snapshot; + return { after, snapshot }; + }).pipe( + Effect.provide( + NodeRedisPool.layer({ + topology: "sentinel", + sentinel: { + name: "effectmq", + sentinelRootNodes: fixture.sentinelPorts.map((port) => ({ + host: "127.0.0.1", + port, + })), + masterPoolSize: 4, + maxCommandRediscovers: 20, + passthroughClientErrorEvents: true, + scanInterval: 100, + commandOptions: { timeout: 1_000 }, + nodeClientOptions: { + socket: { connectTimeout: 500 }, + }, + sentinelClientOptions: { + socket: { connectTimeout: 500 }, + }, + }, + }), + ), + ); + + expect(result.after).toBe("after"); + expect(result.snapshot.topology).toBe("sentinel"); + expect(result.snapshot.roles.producer.reconnects).toBeGreaterThan(0); }), - ), - Effect.runPromise, + 60_000, ); - - expect(result.after).toBe("after"); - expect(result.snapshot.topology).toBe("sentinel"); - expect(result.snapshot.roles.producer.reconnects).toBeGreaterThan(0); - }, - 60_000, -); + }); +} else { + it.skip("discovers a promoted primary and reloads scripts after failover", () => + Effect.void); +} diff --git a/src/utils.ts b/src/RetrySchedule.ts similarity index 63% rename from src/utils.ts rename to src/RetrySchedule.ts index d0bc6f6..09c9f64 100644 --- a/src/utils.ts +++ b/src/RetrySchedule.ts @@ -1,14 +1,14 @@ -import { Duration, Effect, Pull, Schedule } from "effect"; +/** Retry schedule construction and stepping helpers. @internal */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Pull from "effect/Pull"; +import * as Schedule from "effect/Schedule"; export const buildFromOptions = (options: { - schedule?: Schedule.Schedule | undefined; - while?: - | ((input: Input) => boolean | Effect.Effect) - | undefined; - until?: - | ((input: Input) => boolean | Effect.Effect) - | undefined; - times?: number | undefined; + schedule?: Schedule.Schedule; + while?: (input: Input) => boolean | Effect.Effect; + until?: (input: Input) => boolean | Effect.Effect; + times?: number; }) => { const { while: whileFn, until: untilFn, times } = options; let schedule: Schedule.Schedule = options.schedule @@ -24,7 +24,7 @@ export const buildFromOptions = (options: { schedule = Schedule.while(schedule, ({ input }) => { const applied = untilFn(input); return Effect.isEffect(applied) - ? Effect.map(applied, (b) => !b) + ? Effect.map(applied, (value) => !value) : Effect.succeed(!applied); }); } @@ -39,20 +39,16 @@ export const buildFromOptions = (options: { export const nextRunAt = Effect.fnUntraced(function* ( schedule: Schedule.Schedule, createdAt: Date, - errors: { timestamp: Date; error: unknown }[], + errors: readonly { readonly timestamp: Date; readonly error: unknown }[], ) { const step = yield* Schedule.toStep(schedule); let time = createdAt.getTime(); for (const error of errors) { - const [_, delay] = yield* Pull.catchDone( + const [, delay] = yield* Pull.catchDone( step(error.timestamp.getTime(), error.error), - (v) => { - return Effect.succeed([v, -1] as const); - }, + (value) => Effect.succeed([value, -1] as const), ); - if (delay === -1) { - return undefined; - } + if (delay === -1) return undefined; time = error.timestamp.getTime() + Duration.toMillis(delay); } return time; diff --git a/src/Scheduler.test.ts b/src/Scheduler.test.ts index 5b298fa..0bb9407 100644 --- a/src/Scheduler.test.ts +++ b/src/Scheduler.test.ts @@ -1,5 +1,6 @@ import { Cron, Effect, Schedule, Schema } from "effect"; -import { describe, expect, test } from "vitest"; +import * as Clock from "effect/Clock"; +import { expect, it, layer } from "@effect/vitest"; import { Scheduler, StorageProtocol, @@ -7,10 +8,10 @@ import { TaskEngine, TaskQueue, } from "./index.js"; -import { TestRuntime } from "./testing/redisLayer.js"; +import { TestLayer } from "./testing/redisLayer.js"; const makeQueue = (name: string, retry = false) => { - const task = Task.make({ + const options = { name, payload: { scheduledAt: Schema.String, @@ -19,15 +20,28 @@ const makeQueue = (name: string, retry = false) => { }, success: Schema.String, error: Schema.Struct({ reason: Schema.String }), - idempotencyKey: (payload) => payload.scheduledAt, - ...(retry ? { retry: Schedule.spaced("10 millis"), maxRetries: 1 } : {}), - }); - return TaskQueue.make(name, task); + idempotencyKey: (payload: { + readonly scheduledAt: string; + readonly missedFrom: string; + readonly missedTo: string; + }) => payload.scheduledAt, + }; + const task = retry + ? Task.make({ + ...options, + idempotencyKey: options.idempotencyKey, + retry: Schedule.spaced("10 millis"), + maxRetries: 1, + }) + : Task.make({ ...options, idempotencyKey: options.idempotencyKey }); + return task.pipe( + Effect.map((definition) => TaskQueue.make(name, definition)), + ); }; const config = ( name: string, - queue: ReturnType, + queue: Effect.Success>, missed: Scheduler.MissedTickPolicy = { _tag: "coalesce" }, ) => ({ name, @@ -41,196 +55,266 @@ const config = ( }), }); -describe("durable Scheduler", () => { - test("competing schedulers materialize one deterministic tick task", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("scheduled-race-queue"); - const definition = config("scheduled-race", queue); - const now = new Date("2026-01-01T00:01:30.000Z"); - const tick = new Date("2026-01-01T00:01:00.000Z"); - yield* TaskEngine.setMockTime(now.getTime()); - yield* engine.setSchedule(definition.name, tick); - - yield* Effect.all( - [ - Scheduler.materializeDue(definition, now), - Scheduler.materializeDue(definition, now), - ], - { concurrency: "unbounded" }, - ); +it.effect("invalid backfill configuration fails in the typed channel", () => + Effect.gen(function* () { + const queue = yield* makeQueue("invalid-backfill"); + const error = yield* Scheduler.make( + config("invalid-backfill", queue, { + _tag: "backfill", + maxBackfill: 0, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "SchedulerConfigurationError", + field: "maxBackfill", + actual: 0, + }); + }), +); - const waiting = yield* engine.listTasks(queue.name, "wait"); - expect(waiting.items).toEqual([`scheduled-race/${tick.toISOString()}`]); - expect( - (yield* engine.getTask(queue.name, waiting.items[0]))?.generation, - ).toBe(1); - }).pipe(TestRuntime.runPromise)); - - test("an offer committed before a scheduler crash is replay-safe", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("scheduled-crash-queue"); - const definition = config("scheduled-crash", queue); - const tick = new Date("2026-01-01T00:02:00.000Z"); - const now = new Date("2026-01-01T00:02:30.000Z"); - yield* TaskEngine.setMockTime(now.getTime()); - yield* engine.setSchedule(definition.name, tick); - - // This is the state left by a process that offered and died before it - // advanced the schedule cursor. - yield* TaskQueue.offer( - queue, - { - scheduledAt: tick.toISOString(), - missedFrom: tick.toISOString(), - missedTo: tick.toISOString(), - }, - { taskId: `scheduled-crash/${tick.toISOString()}` }, - ); - yield* Scheduler.materializeDue(definition, now); - - const waiting = yield* engine.listTasks(queue.name, "wait"); - expect(waiting.items).toEqual([`scheduled-crash/${tick.toISOString()}`]); - expect( - (yield* engine.getTask(queue.name, waiting.items[0]))?.generation, - ).toBe(1); - }).pipe(TestRuntime.runPromise)); - - test("a crash before offer leaves the due cursor available", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("scheduled-before-offer-queue"); - const definition = config("scheduled-before-offer", queue); - const tick = new Date("2026-01-01T00:03:00.000Z"); - const now = new Date("2026-01-01T00:03:30.000Z"); - yield* TaskEngine.setMockTime(now.getTime()); - yield* engine.setSchedule(definition.name, tick); - - // No operation occurs before the simulated crash. A replacement - // scheduler sees the same cursor and materializes the task normally. - yield* Scheduler.materializeDue(definition, now); - expect((yield* engine.listTasks(queue.name, "wait")).items).toEqual([ - `scheduled-before-offer/${tick.toISOString()}`, - ]); - }).pipe(TestRuntime.runPromise)); - - test("skip, coalesce, and bounded backfill have explicit downtime behavior", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const now = new Date("2026-01-01T00:05:00.000Z"); - const firstMissed = new Date("2026-01-01T00:00:00.000Z"); - yield* TaskEngine.setMockTime(now.getTime()); - - const skipQueue = makeQueue("scheduled-skip-queue"); - const skip = config("scheduled-skip", skipQueue, { _tag: "skip" }); - yield* engine.setSchedule(skip.name, firstMissed); - yield* Scheduler.materializeDue(skip, now); - expect((yield* engine.listTasks(skipQueue.name, "wait")).items).toEqual( - [], - ); +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "durable Scheduler (real Redis time)", + (it) => { + it.effect("reads Clock when a materialization Effect executes", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const liveClock = yield* Clock.Clock; + const queue = yield* makeQueue("execution-clock-queue"); + const definition = config("execution-clock", queue); + const tick = new Date("2026-01-01T00:01:00.000Z"); + const now = new Date("2026-01-01T00:01:30.000Z"); + yield* engine.setSchedule(definition.name, tick); + yield* TaskEngine.setMockTime(now.getTime()); - const coalesceQueue = makeQueue("scheduled-coalesce-queue"); - const coalesce = config("scheduled-coalesce", coalesceQueue); - yield* engine.setSchedule(coalesce.name, firstMissed); - yield* Scheduler.materializeDue(coalesce, now); - const coalesced = (yield* engine.listTasks(coalesceQueue.name, "wait")) - .items; - expect(coalesced).toEqual([`scheduled-coalesce/${now.toISOString()}`]); - const coalescedTask = yield* engine.getTask( - coalesceQueue.name, - coalesced[0], - ); - const coalescedPayload = yield* StorageProtocol.decodeValue( - coalescedTask?.payload, - coalesceQueue.task.schemaId, - "payload", - ); - expect(coalescedPayload).toMatchObject({ - missedFrom: firstMissed.toISOString(), - }); + const operation = Scheduler.materializeDue(definition); + const fixedClock = Clock.Clock.of({ + ...liveClock, + currentTimeMillisUnsafe: () => now.getTime(), + currentTimeMillis: Effect.succeed(now.getTime()), + }); + yield* operation.pipe(Effect.provideService(Clock.Clock, fixedClock)); - const backfillQueue = makeQueue("scheduled-backfill-queue"); - const backfill = config("scheduled-backfill", backfillQueue, { - _tag: "backfill", - maxBackfill: 2, - }); - yield* engine.setSchedule(backfill.name, firstMissed); - yield* Scheduler.materializeDue(backfill, now); - expect( - (yield* engine.listTasks(backfillQueue.name, "wait")).items, - ).toEqual([ - `scheduled-backfill/2026-01-01T00:04:00.000Z`, - `scheduled-backfill/2026-01-01T00:05:00.000Z`, - ]); - }).pipe(TestRuntime.runPromise)); - - test("scheduled work executes and retries through normal queue semantics", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("scheduled-retry-queue", true); - const definition = config("scheduled-retry", queue); - const now = new Date(Math.floor(Date.now() / 60_000) * 60_000 + 30_000); - const tick = new Date(now.getTime() - 30_000); - yield* TaskEngine.setMockTime(now.getTime()); - yield* engine.setSchedule(definition.name, tick); - yield* Scheduler.materializeDue(definition, now); - - let attempts = 0; - yield* TaskQueue.complete(queue, () => { - attempts++; - return Effect.fail({ reason: "retry" }); - }); - const retryTask = yield* engine.getTask( - queue.name, - `scheduled-retry/${tick.toISOString()}`, - ); - const retryAt = retryTask?.errors.at(-1)?.retryAt; - expect(retryAt).toBeDefined(); - if (retryAt === undefined) return yield* Effect.die("Expected retryAt"); - // Retry schedules are evaluated from the recorded handler-failure time. - // Advance the mocked Redis clock to that durable deadline instead of - // assuming it is within 20 ms of this process's wall clock. - yield* TaskEngine.setMockTime(retryAt + 1); - yield* engine.maintain(queue.name); - yield* TaskQueue.complete(queue, () => { - attempts++; - return Effect.succeed("done"); - }); - - expect(attempts).toBe(2); - }).pipe(TestRuntime.runPromise)); - - test("a lost scheduled-task lease can execute the handler again", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("scheduled-at-least-once-queue"); - const definition = config("scheduled-at-least-once", queue); - const now = new Date("2026-01-01T00:08:30.000Z"); - const tick = new Date("2026-01-01T00:08:00.000Z"); - yield* TaskEngine.setMockTime(now.getTime()); - yield* engine.setSchedule(definition.name, tick); - yield* Scheduler.materializeDue(definition, now); - - let executions = 0; - const abandoned = yield* engine.takeTask(queue.name, 100); - if (abandoned === null) - return yield* Effect.die("Expected scheduled task"); - executions++; - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(queue.name); - - yield* TaskQueue.complete(queue, () => { + expect((yield* engine.listTasks(queue.name, "wait")).items).toContain( + `execution-clock/${tick.toISOString()}`, + ); + }), + ); + + it.effect( + "competing schedulers materialize one deterministic tick task", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("scheduled-race-queue"); + const definition = config("scheduled-race", queue); + const now = new Date("2026-01-01T00:01:30.000Z"); + const tick = new Date("2026-01-01T00:01:00.000Z"); + yield* TaskEngine.setMockTime(now.getTime()); + yield* engine.setSchedule(definition.name, tick); + + yield* Effect.all( + [ + Scheduler.materializeDue(definition, now), + Scheduler.materializeDue(definition, now), + ], + { concurrency: "unbounded" }, + ); + + const waiting = yield* engine.listTasks(queue.name, "wait"); + expect(waiting.items).toEqual([ + `scheduled-race/${tick.toISOString()}`, + ]); + expect( + (yield* engine.getTask(queue.name, waiting.items[0]))?.generation, + ).toBe(1); + }), + ); + + it.effect( + "an offer committed before a scheduler crash is replay-safe", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("scheduled-crash-queue"); + const definition = config("scheduled-crash", queue); + const tick = new Date("2026-01-01T00:02:00.000Z"); + const now = new Date("2026-01-01T00:02:30.000Z"); + yield* TaskEngine.setMockTime(now.getTime()); + yield* engine.setSchedule(definition.name, tick); + + // This is the state left by a process that offered and died before it + // advanced the schedule cursor. + yield* TaskQueue.offer( + queue, + { + scheduledAt: tick.toISOString(), + missedFrom: tick.toISOString(), + missedTo: tick.toISOString(), + }, + { taskId: `scheduled-crash/${tick.toISOString()}` }, + ); + yield* Scheduler.materializeDue(definition, now); + + const waiting = yield* engine.listTasks(queue.name, "wait"); + expect(waiting.items).toEqual([ + `scheduled-crash/${tick.toISOString()}`, + ]); + expect( + (yield* engine.getTask(queue.name, waiting.items[0]))?.generation, + ).toBe(1); + }), + ); + + it.effect("a crash before offer leaves the due cursor available", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("scheduled-before-offer-queue"); + const definition = config("scheduled-before-offer", queue); + const tick = new Date("2026-01-01T00:03:00.000Z"); + const now = new Date("2026-01-01T00:03:30.000Z"); + yield* TaskEngine.setMockTime(now.getTime()); + yield* engine.setSchedule(definition.name, tick); + + // No operation occurs before the simulated crash. A replacement + // scheduler sees the same cursor and materializes the task normally. + yield* Scheduler.materializeDue(definition, now); + expect((yield* engine.listTasks(queue.name, "wait")).items).toEqual([ + `scheduled-before-offer/${tick.toISOString()}`, + ]); + }), + ); + + it.effect( + "skip, coalesce, and bounded backfill have explicit downtime behavior", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const now = new Date("2026-01-01T00:05:00.000Z"); + const firstMissed = new Date("2026-01-01T00:00:00.000Z"); + yield* TaskEngine.setMockTime(now.getTime()); + + const skipQueue = yield* makeQueue("scheduled-skip-queue"); + const skip = config("scheduled-skip", skipQueue, { _tag: "skip" }); + yield* engine.setSchedule(skip.name, firstMissed); + yield* Scheduler.materializeDue(skip, now); + expect( + (yield* engine.listTasks(skipQueue.name, "wait")).items, + ).toEqual([]); + + const coalesceQueue = yield* makeQueue("scheduled-coalesce-queue"); + const coalesce = config("scheduled-coalesce", coalesceQueue); + yield* engine.setSchedule(coalesce.name, firstMissed); + yield* Scheduler.materializeDue(coalesce, now); + const coalesced = (yield* engine.listTasks( + coalesceQueue.name, + "wait", + )).items; + expect(coalesced).toEqual([ + `scheduled-coalesce/${now.toISOString()}`, + ]); + const coalescedTask = yield* engine.getTask( + coalesceQueue.name, + coalesced[0], + ); + const coalescedPayload = yield* StorageProtocol.decodeValue( + coalescedTask?.payload, + coalesceQueue.task.schemaId, + "payload", + ); + expect(coalescedPayload).toMatchObject({ + missedFrom: firstMissed.toISOString(), + }); + + const backfillQueue = yield* makeQueue("scheduled-backfill-queue"); + const backfill = config("scheduled-backfill", backfillQueue, { + _tag: "backfill", + maxBackfill: 2, + }); + yield* engine.setSchedule(backfill.name, firstMissed); + yield* Scheduler.materializeDue(backfill, now); + expect( + (yield* engine.listTasks(backfillQueue.name, "wait")).items, + ).toEqual([ + `scheduled-backfill/2026-01-01T00:04:00.000Z`, + `scheduled-backfill/2026-01-01T00:05:00.000Z`, + ]); + }), + ); + + it.effect( + "scheduled work executes and retries through normal queue semantics", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("scheduled-retry-queue", true); + const definition = config("scheduled-retry", queue); + const now = new Date( + Math.floor(Date.now() / 60_000) * 60_000 + 30_000, + ); + const tick = new Date(now.getTime() - 30_000); + yield* TaskEngine.setMockTime(now.getTime()); + yield* engine.setSchedule(definition.name, tick); + yield* Scheduler.materializeDue(definition, now); + + let attempts = 0; + yield* TaskQueue.complete(queue, () => { + attempts++; + return Effect.fail({ reason: "retry" }); + }); + const retryTask = yield* engine.getTask( + queue.name, + `scheduled-retry/${tick.toISOString()}`, + ); + const retryAt = retryTask?.errors.at(-1)?.retryAt; + expect(retryAt).toBeDefined(); + if (retryAt === undefined) + return yield* Effect.die("Expected retryAt"); + // Retry schedules are evaluated from the recorded handler-failure time. + // Advance the mocked Redis clock to that durable deadline instead of + // assuming it is within 20 ms of this process's wall clock. + yield* TaskEngine.setMockTime(retryAt + 1); + yield* engine.maintain(queue.name); + yield* TaskQueue.complete(queue, () => { + attempts++; + return Effect.succeed("done"); + }); + + expect(attempts).toBe(2); + }), + ); + + it.effect("a lost scheduled-task lease can execute the handler again", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("scheduled-at-least-once-queue"); + const definition = config("scheduled-at-least-once", queue); + const now = new Date("2026-01-01T00:08:30.000Z"); + const tick = new Date("2026-01-01T00:08:00.000Z"); + yield* TaskEngine.setMockTime(now.getTime()); + yield* engine.setSchedule(definition.name, tick); + yield* Scheduler.materializeDue(definition, now); + + let executions = 0; + const abandoned = yield* engine.takeTask(queue.name, 100); + if (abandoned === null) + return yield* Effect.die("Expected scheduled task"); executions++; - return Effect.succeed("done"); - }); - expect(executions).toBe(2); - }).pipe(TestRuntime.runPromise)); - - test("the configured cron timezone determines the nominal tick", () => { - const cron = Cron.parseUnsafe("0 9 * * *", "America/New_York"); - expect(Cron.next(cron, new Date("2026-03-08T12:00:00.000Z"))).toEqual( - new Date("2026-03-08T13:00:00.000Z"), + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(queue.name); + + yield* TaskQueue.complete(queue, () => { + executions++; + return Effect.succeed("done"); + }); + expect(executions).toBe(2); + }), ); - }); -}); + + it("the configured cron timezone determines the nominal tick", () => { + const cron = Cron.parseUnsafe("0 9 * * *", "America/New_York"); + expect(Cron.next(cron, new Date("2026-03-08T12:00:00.000Z"))).toEqual( + new Date("2026-03-08T13:00:00.000Z"), + ); + }); + }, +); diff --git a/src/Scheduler.ts b/src/Scheduler.ts index c6bfe16..1280913 100644 --- a/src/Scheduler.ts +++ b/src/Scheduler.ts @@ -1,6 +1,13 @@ /** Durable cron tick materialization through ordinary EffectMQ tasks. @module */ -import { Duration, Effect, Effectable, Schedule, type Schema } from "effect"; +import * as Clock from "effect/Clock"; import * as Cron from "effect/Cron"; +import type * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Effectable from "effect/Effectable"; +import * as Schedule from "effect/Schedule"; +import type * as Schema from "effect/Schema"; import type * as StorageProtocol from "./StorageProtocol.js"; import * as TaskEngine from "./TaskEngine.js"; import * as TaskQueue from "./TaskQueue.js"; @@ -38,6 +45,40 @@ export type MissedTickPolicy = | { readonly _tag: "coalesce" } | { readonly _tag: "backfill"; readonly maxBackfill: number }; +/** Predictable validation failure for a scheduler definition. */ +export class SchedulerConfigurationError extends Data.TaggedError( + "SchedulerConfigurationError", +)<{ + readonly field: "maxBackfill"; + readonly constraint: string; + readonly actual: unknown; +}> {} + +const validateConfig = < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + QueueR, + QueueIdentityR, +>( + config: SchedulerConfig, +): Effect.Effect => { + if ( + config.missed._tag === "backfill" && + (!Number.isSafeInteger(config.missed.maxBackfill) || + config.missed.maxBackfill < 1) + ) { + return Effect.fail( + new SchedulerConfigurationError({ + field: "maxBackfill", + constraint: "a positive safe integer", + actual: config.missed.maxBackfill, + }), + ); + } + return Effect.void; +}; + /** * Configures durable cron tick materialization into a task queue. * @@ -52,11 +93,18 @@ export interface SchedulerConfig< Success extends Schema.Top, Error extends Schema.Top, QueueR = never, + QueueIdentityR = Crypto.Crypto, > { readonly name: string; /** Cron rule including its optional IANA time zone. */ readonly cron: Cron.Cron; - readonly queue: TaskQueue.TaskQueue; + readonly queue: TaskQueue.TaskQueue< + Payload, + Success, + Error, + QueueR, + QueueIdentityR + >; readonly payload: (tick: Tick) => Payload["Type"]; readonly missed: MissedTickPolicy; /** Initial cursor for a brand-new schedule; defaults to the next future tick. */ @@ -68,6 +116,8 @@ export interface SchedulerConfig< } type SchedulerFailure = + | SchedulerConfigurationError + | TaskQueue.OfferError | StorageProtocol.StorageProtocolError | TaskEngine.TaskEngineError | TaskQueue.IndeterminateWriteError @@ -89,11 +139,15 @@ type SchedulerFailure = * @category Models * @since 0.1.0 */ -export interface Scheduler - extends Effect.Effect< +export interface Scheduler< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + QueueIdentityR, +> extends Effect.Effect< void, SchedulerFailure, - TaskEngine.TaskEngine | Payload["DecodingServices"] + TaskQueue.OfferRequirements > { readonly [TypeId]: typeof TypeId; readonly name: string; @@ -137,25 +191,22 @@ export const materializeDue = Effect.fnUntraced(function* < Success extends Schema.Top, Error extends Schema.Top, QueueR, ->(config: SchedulerConfig, now = new Date()) { - if ( - config.missed._tag === "backfill" && - (!Number.isSafeInteger(config.missed.maxBackfill) || - config.missed.maxBackfill < 1) - ) { - return yield* Effect.die( - new RangeError("maxBackfill must be a positive safe integer"), - ); - } + QueueIdentityR, +>( + config: SchedulerConfig, + now?: Date, +) { + yield* validateConfig(config); + const observedAt = now ?? new Date(yield* Clock.currentTimeMillis); const engine = yield* TaskEngine.TaskEngine; - const initial = config.startAt ?? Cron.next(config.cron, now); + const initial = config.startAt ?? Cron.next(config.cron, observedAt); const firstDue = yield* engine.setSchedule(config.name, initial); - if (firstDue.getTime() > now.getTime()) return firstDue; + if (firstDue.getTime() > observedAt.getTime()) return firstDue; const following = Cron.next(config.cron, firstDue); - const hasMissedBacklog = following.getTime() <= now.getTime(); - const nextFuture = Cron.next(config.cron, now); + const hasMissedBacklog = following.getTime() <= observedAt.getTime(); + const nextFuture = Cron.next(config.cron, observedAt); let ticks: Date[]; if (!hasMissedBacklog) { @@ -168,7 +219,7 @@ export const materializeDue = Effect.fnUntraced(function* < ticks = recentBackfill( config.cron, firstDue, - now, + observedAt, config.missed.maxBackfill, ); } @@ -207,23 +258,24 @@ export const materializeDue = Effect.fnUntraced(function* < * **Example: Materialize a coalesced daily task** * * ```ts - * import { Cron, Schema } from "effect" + * import { Cron, Effect, Schema } from "effect" * import { Scheduler, Task, TaskQueue } from "@effectmq/core" * - * const report = Task.make({ - * name: "report", - * payload: { scheduledAt: Schema.String }, - * success: Schema.Void, - * error: Schema.String - * }) - * const reports = TaskQueue.make("reports", report) - * - * const daily = Scheduler.make({ - * name: "daily-report", - * cron: Cron.parseUnsafe("0 2 * * *", "UTC"), - * queue: reports, - * payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }), - * missed: { _tag: "coalesce" } + * const daily = Effect.gen(function* () { + * const report = yield* Task.make({ + * name: "report", + * payload: { scheduledAt: Schema.String }, + * success: Schema.Void, + * error: Schema.String + * }) + * const reports = TaskQueue.make("reports", report) + * return yield* Scheduler.make({ + * name: "daily-report", + * cron: Cron.parseUnsafe("0 2 * * *", "UTC"), + * queue: reports, + * payload: (tick) => ({ scheduledAt: tick.scheduledAt.toISOString() }), + * missed: { _tag: "coalesce" } + * }) * }) * ``` * @@ -235,27 +287,36 @@ export const make = < Success extends Schema.Top, Error extends Schema.Top, QueueR = never, + QueueIdentityR = Crypto.Crypto, >( - config: SchedulerConfig, -): Scheduler => { - const execute = Effect.gen(function* () { - let next = yield* materializeDue(config); - yield* Effect.gen(function* () { - const sleepFor = Math.max(100, next.getTime() - Date.now()); - yield* Effect.sleep(Duration.millis(sleepFor)); - next = yield* materializeDue(config); - }).pipe(Effect.repeat(Schedule.forever)); + config: SchedulerConfig, +): Effect.Effect< + Scheduler, + SchedulerConfigurationError +> => + Effect.gen(function* () { + yield* validateConfig(config); + const execute = Effect.gen(function* () { + let next = yield* materializeDue(config); + yield* Effect.gen(function* () { + const sleepFor = Math.max( + 100, + next.getTime() - (yield* Clock.currentTimeMillis), + ); + yield* Effect.sleep(Duration.millis(sleepFor)); + next = yield* materializeDue(config); + }).pipe(Effect.repeat(Schedule.forever)); + }); + return { + ...Effectable.Prototype({ + label: "effectmq/Scheduler", + evaluate() { + return execute; + }, + }), + [TypeId]: TypeId, + name: config.name, + cron: config.cron, + timeZone: config.cron.tz, + } as Scheduler; }); - return { - ...Effectable.Prototype({ - label: "effectmq/Scheduler", - evaluate() { - return execute; - }, - }), - [TypeId]: TypeId, - name: config.name, - cron: config.cron, - timeZone: config.cron.tz, - } as Scheduler; -}; diff --git a/src/Schemas.test.ts b/src/Schemas.test.ts new file mode 100644 index 0000000..9bb7fb7 --- /dev/null +++ b/src/Schemas.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { UnknownFromMsgpack } from "./MessagePack.js"; + +it.effect("MessagePack round-trips supported values", () => + Effect.gen(function* () { + const value = { text: "effectmq", values: [1, true, null] }; + const bytes = yield* Schema.encodeEffect(UnknownFromMsgpack)(value); + expect(yield* Schema.decodeEffect(UnknownFromMsgpack)(bytes)).toEqual( + value, + ); + }), +); + +it.effect("truncated MessagePack fails as a SchemaError, not a defect", () => + Effect.gen(function* () { + const error = yield* Schema.decodeEffect(UnknownFromMsgpack)( + new Uint8Array([0xd9]), + ).pipe(Effect.flip); + expect(error._tag).toBe("SchemaError"); + }), +); + +it.effect("non-byte MessagePack input fails as a SchemaError", () => + Effect.gen(function* () { + const error = yield* Schema.decodeUnknownEffect(UnknownFromMsgpack)({ + not: "bytes", + }).pipe(Effect.flip); + expect(error._tag).toBe("SchemaError"); + }), +); diff --git a/src/Schemas.ts b/src/Schemas.ts deleted file mode 100644 index 57fec44..0000000 --- a/src/Schemas.ts +++ /dev/null @@ -1,513 +0,0 @@ -/** - * Shared schemas for the task model: completion policies, the built-in task - * error types, and {@link makeTaskSchema} which assembles a fully-typed task - * schema from payload/success/error schemas. - * - * @module - */ -import { Effect, SchemaGetter } from "effect"; -import * as Schema from "effect/Schema"; -import { Packr } from "msgpackr"; -import * as StorageProtocol from "./StorageProtocol.js"; - -// standard msgpack only (no msgpackr record extension — Redis' cmsgpack -// can't read it) and 64-bit ints as JS numbers (timestamps would otherwise -// decode as BigInt) -const packr = new Packr({ useRecords: false, int64AsType: "number" }); - -/** msgpack bytes ⇄ decoded unknown value. */ -export const UnknownFromMsgpack = Schema.Uint8Array.pipe( - Schema.decodeTo(Schema.Unknown, { - decode: SchemaGetter.transform((bytes: Uint8Array): unknown => - packr.unpack(bytes), - ), - encode: SchemaGetter.transform( - (value: unknown): Uint8Array => packr.pack(value), - ), - }), -); - -/** - * Policy applied to a task once it completes (on success or failure): - * `delete` removes it, `keep` clears it from all lists, and - * `mark-as-success`/`mark-as-failure` move it to the corresponding list. - */ -const CompletionPolicySchema = Schema.Literals([ - "delete", - "keep", - "mark-as-success", - "mark-as-failure", -]); - -/** Stable identity of one task generation at the public queue boundary. */ -export const TaskIdentitySchema = Schema.Struct({ - queue: Schema.String, - id: Schema.String, - generation: Schema.Number, -}); -export type TaskIdentity = typeof TaskIdentitySchema.Type; - -/** Terminal outcome recorded when a task generation settles. */ -const TaskOutcomeSchema = Schema.Literals(["success", "failure"]); -/** Terminal outcome of a settled task (`success` | `failure`). */ -export type TaskOutcome = typeof TaskOutcomeSchema.Type; - -export const errorEntrySchema = (error: Error) => - Schema.Struct({ - error: error, - timestamp: DateFromNumberSchema, - retryAt: Schema.optional(DateFromNumberSchema), - }); - -export type ErrorEntry = { - error: Error; - timestamp: Date; - retryAt?: Date; -}; -/** A completion policy value (`delete` | `keep` | `mark-as-success` | `mark-as-failure`). */ -export type CompletionPolicy = typeof CompletionPolicySchema.Type; -export class StalledErrorSchema extends Schema.TaggedError()( - "~effectmq/Error/Stalled", - { - timestamp: Schema.Number, - }, -) { - static of(timestamp: number) { - return new StalledErrorSchema({ timestamp }); - } -} -export class CanceledErrorSchema extends Schema.TaggedError()( - "~effectmq/Error/Canceled", - { - timestamp: Schema.Number, - }, -) { - static of(timestamp: number) { - return new CanceledErrorSchema({ timestamp }); - } -} -/** Union of the engine's built-in task errors (`Stalled`, `Canceled`). */ -export const TaskErrorSchema = Schema.Union([ - StalledErrorSchema, - CanceledErrorSchema, -]); - -export type TaskErrorSchema = typeof TaskErrorSchema.Type; - -const DateFromNumberSchema = Schema.Number.pipe( - Schema.decodeTo(Schema.Date, { - decode: SchemaGetter.transform((value) => { - return new Date(value); - }), - encode: SchemaGetter.transform((value) => { - return value.getTime(); - }), - }), -); - -/** - * A decoded task as seen by a handler: the typed payload/success/error fields - * plus the engine-assigned `id` and `name`. - */ -export interface Task< - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, -> { - readonly _tag: "Task"; - readonly id: string; - readonly generation: number; - readonly name: string; - readonly payload: Payload["Type"]; - readonly success?: Success["Type"] | undefined; - readonly errors: readonly ErrorEntry< - Error["Type"] | StalledErrorSchema | CanceledErrorSchema - >[]; - readonly createdAt: Date; - readonly updatedAt: Date; - readonly delay: number; - readonly maxRetries: number; - readonly maxStalledCount: number; - readonly maxErrorEntries: number; - readonly maxRelationships: number; - readonly maxEventEntries: number; - readonly taskRecordRetentionMs: number; - readonly resultRetentionMs: number; - readonly terminalIndexRetentionMs: number; - readonly deadLetterRetentionMs: number; - readonly eventRetentionMs: number; - readonly attempt: number; - readonly handlerFailureCount: number; - readonly stalledAttemptCount: number; - readonly onSuccessPolicy: CompletionPolicy; - readonly onFailurePolicy: CompletionPolicy; -} -/** - * Build a fully-typed task schema from a task's `payload`, `success`, and - * `error` schemas. The resulting struct decodes the stored task hash: the - * payload/success fields are decoded from the v1 storage envelope, and - * `errors` accepts both the built-in {@link TaskErrorSchema} and the task's - * own error type. - */ -export const makeTaskSchema = < - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, ->(config: { - payloadSchema: Payload; - successSchema: Success; - errorSchema: Error; -}) => { - const payloadDecoder = Schema.decodeTo(config.payloadSchema)( - Schema.Unknown, - ) as unknown as Schema.Union<[Schema.decodeTo]>; - const schema = Schema.Struct({ - _tag: Schema.tagDefaultOmit("Task"), - id: Schema.String, - generation: Schema.Number, - name: Schema.String, - delay: Schema.Number, - maxRetries: Schema.Number, - maxStalledCount: Schema.Number, - maxErrorEntries: Schema.Number, - maxRelationships: Schema.Number, - maxEventEntries: Schema.Number, - taskRecordRetentionMs: Schema.Number, - resultRetentionMs: Schema.Number, - terminalIndexRetentionMs: Schema.Number, - deadLetterRetentionMs: Schema.Number, - eventRetentionMs: Schema.Number, - attempt: Schema.Number, - handlerFailureCount: Schema.Number, - stalledAttemptCount: Schema.Number, - onSuccessPolicy: CompletionPolicySchema, - onFailurePolicy: CompletionPolicySchema, - createdAt: Schema.Date, - updatedAt: Schema.Date, - - payload: payloadDecoder, - errors: errorEntrySchema( - Schema.Unknown.pipe( - Schema.decodeTo(Schema.Union([TaskErrorSchema, config.errorSchema])), - ), - ).pipe(Schema.Array), - - success: Schema.Unknown.pipe( - Schema.decodeTo(config.successSchema), - Schema.optional, - ), - }); - - return schema satisfies Schema.Schema>; -}; - -export const decodeTask = < - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, ->( - config: { - schemaId: string; - payloadSchema: Payload; - successSchema: Success; - errorSchema: Error; - }, - task: EngineTask, -): Effect.Effect< - Task, - Schema.SchemaError | StorageProtocol.StorageProtocolError, - Error["DecodingServices"] -> => { - const decode = Schema.decodeEffect( - makeTaskSchema(config), - ); - return Effect.gen(function* () { - if ( - !StorageProtocol.readableProtocolVersions.includes( - task.protocolVersion as 1, - ) - ) { - return yield* new StorageProtocol.UnsupportedProtocolVersion({ - encountered: task.protocolVersion, - supported: StorageProtocol.readableProtocolVersions, - }); - } - if (task.schemaId !== config.schemaId) { - return yield* new StorageProtocol.SchemaIdentityMismatch({ - expected: config.schemaId, - encountered: task.schemaId, - }); - } - const errors = yield* Effect.forEach(task.errors, (entry) => { - const value = entry.error; - const isBuiltIn = - typeof value === "object" && - value !== null && - "_tag" in value && - Object.values(StorageProtocol.builtInErrorTags).includes( - value._tag as never, - ); - return isBuiltIn - ? Effect.succeed(entry) - : StorageProtocol.decodeValue(value, config.schemaId, "failure").pipe( - Effect.map((error) => ({ ...entry, error })), - ); - }); - return yield* decode({ - ...task, - payload: yield* StorageProtocol.decodeValue( - task.payload, - config.schemaId, - "payload", - ), - success: - task.success === undefined - ? undefined - : yield* StorageProtocol.decodeValue( - task.success, - config.schemaId, - "success", - ), - errors, - }); - }); -}; - -export const encodeTask = < - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, ->( - config: { - payloadSchema: Payload; - successSchema: Success; - errorSchema: Error; - }, - task: Task, -) => Schema.encodeEffect(makeTaskSchema(config))(task); - -export type TaskSchema< - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, -> = ReturnType>; -/** - * Redis replies carrying msgpack fields are read in binary mode, so scalar - * values arrive as `Buffer`s (or strings when injected on the TS side) — - * decode either to a utf8 string. - */ -const TextFromBytes = Schema.Unknown.pipe( - Schema.decodeTo(Schema.String, { - decode: SchemaGetter.transform((value: unknown): string => - typeof value === "string" - ? value - : Buffer.from(value as Uint8Array).toString("utf8"), - ), - encode: SchemaGetter.transform((value: string): unknown => value), - }), -); - -const NumberFromBytes = TextFromBytes.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform(Number), - encode: SchemaGetter.transform(String), - }), -); - -const BooleanFromBytes = TextFromBytes.pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "1"), - encode: SchemaGetter.transform((value) => (value ? "1" : "0")), - }), -); - -/** A list stored as a MessagePack blob. Wrong shapes are corruption errors. */ -const msgpackListFromBytes = (item: S) => - UnknownFromMsgpack.pipe(Schema.decodeTo(Schema.Array(item))); - -/** - * The engine task as returned by the Lua library's `getTask`: a record of raw - * hash values — scalars as utf8 bytes, structured fields (`payload`, - * `success`, `errors`, and `creator`) as msgpack blobs decoded here. - * Lua never unpacks the payload, so it round-trips byte-exact. - */ -export const EngineTaskSchema = Schema.Struct({ - id: TextFromBytes, - protocolVersion: NumberFromBytes, - schemaId: TextFromBytes, - generation: NumberFromBytes, - name: TextFromBytes, - delay: NumberFromBytes, - maxRetries: NumberFromBytes, - maxStalledCount: NumberFromBytes, - maxErrorEntries: NumberFromBytes, - maxRelationships: NumberFromBytes, - maxEventEntries: NumberFromBytes, - taskRecordRetentionMs: NumberFromBytes, - resultRetentionMs: NumberFromBytes, - terminalIndexRetentionMs: NumberFromBytes, - deadLetterRetentionMs: NumberFromBytes, - eventRetentionMs: NumberFromBytes, - attempt: NumberFromBytes, - handlerFailureCount: NumberFromBytes, - stalledAttemptCount: NumberFromBytes, - onSuccessPolicy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), - onFailurePolicy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), - createdAt: NumberFromBytes.pipe(Schema.decodeTo(DateFromNumberSchema)), - updatedAt: NumberFromBytes.pipe(Schema.decodeTo(DateFromNumberSchema)), - payload: UnknownFromMsgpack, - success: UnknownFromMsgpack.pipe(Schema.optional), - errors: msgpackListFromBytes( - Schema.Struct({ - timestamp: Schema.Number, - error: Schema.Unknown, - retryAt: Schema.optional(Schema.Number), - }), - ), - creator: UnknownFromMsgpack.pipe( - Schema.decodeTo(TaskIdentitySchema), - Schema.optional, - ), - outcome: TextFromBytes.pipe( - Schema.decodeTo(TaskOutcomeSchema), - Schema.optional, - ), -}); - -export type EngineTask = typeof EngineTaskSchema.Type; - -/** Generation-specific terminal outcome retained independently of task data. */ -export const EngineTerminalResultSchema = Schema.Struct({ - protocolVersion: NumberFromBytes, - schemaId: TextFromBytes, - generation: NumberFromBytes, - outcome: TextFromBytes.pipe(Schema.decodeTo(TaskOutcomeSchema)), - settledAt: NumberFromBytes, - success: UnknownFromMsgpack.pipe(Schema.optional), - failure: UnknownFromMsgpack.pipe(Schema.optional), -}); -export type EngineTerminalResult = typeof EngineTerminalResultSchema.Type; - -export const EngineTaskInsertSchema = Schema.Struct({ - prefix: Schema.String, - id: Schema.String, - name: Schema.String, - schemaId: Schema.String.pipe(Schema.optional), - payload: Schema.Unknown, - delay: Schema.Number, - maxRetries: Schema.Number, - maxStalledCount: Schema.Number.pipe(Schema.optional), - maxErrorEntries: Schema.Number.pipe(Schema.optional), - maxRelationships: Schema.Number.pipe(Schema.optional), - maxEventEntries: Schema.Number.pipe(Schema.optional), - taskRecordRetentionMs: Schema.Number.pipe(Schema.optional), - resultRetentionMs: Schema.Number.pipe(Schema.optional), - terminalIndexRetentionMs: Schema.Number.pipe(Schema.optional), - deadLetterRetentionMs: Schema.Number.pipe(Schema.optional), - eventRetentionMs: Schema.Number.pipe(Schema.optional), - onSuccessPolicy: CompletionPolicySchema, - - onFailurePolicy: CompletionPolicySchema, - onDuplicate: Schema.Literals(["return-existing", "new-generation"]).pipe( - Schema.optional, - ), - retentionHolder: TaskIdentitySchema.pipe(Schema.optional), - creator: TaskIdentitySchema.pipe(Schema.optional), -}); -export type EngineTaskInsert = typeof EngineTaskInsertSchema.Type; - -export const TaskLists = Schema.Literals([ - "wait", - "scheduled", - "active", - "failed", - "success", -]); - -export const ExecutionStateSchema = Schema.Literals([ - "delayed", - "waiting", - "leased", - "retry-scheduled", - "succeeded", - "failed", -]); - -const eventBase = { - id: Schema.String, - taskId: Schema.String, - generation: NumberFromBytes, - protocolVersion: NumberFromBytes, - schemaId: TextFromBytes, -}; - -/** - * A queue lifecycle event as read from the Redis Stream. Events are published - * as flat stream fields (raw msgpack values stay binary-safe bulk strings); - * `TaskEngine.stream` reassembles them into `{id, taskId, _tag, payload}` - * records — task snapshots arrive as raw-entry records decoded by - * {@link EngineTaskSchema}. - */ -export const EventSchema = Schema.Union([ - Schema.TaggedStruct("task.created", { - ...eventBase, - payload: Schema.Struct({ - newTask: EngineTaskSchema, - state: TextFromBytes.pipe(Schema.decodeTo(ExecutionStateSchema)), - }), - }), - Schema.TaggedStruct("task.updated", { - ...eventBase, - payload: Schema.Struct({ - existingTask: EngineTaskSchema, - newTask: EngineTaskSchema, - state: TextFromBytes.pipe(Schema.decodeTo(ExecutionStateSchema)), - }), - }), - Schema.TaggedStruct("task.failed", { - ...eventBase, - payload: Schema.Struct({ - policy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), - error: UnknownFromMsgpack, - retryAt: NumberFromBytes.pipe(Schema.optional), - failureKind: TextFromBytes.pipe( - Schema.decodeTo(Schema.Literals(["handler", "stall"])), - ), - attempt: NumberFromBytes, - terminal: BooleanFromBytes, - }), - }), - Schema.TaggedStruct("task.completed", { - ...eventBase, - payload: Schema.Struct({ - // a void success is msgpack-encoded undefined; decodes back to undefined - success: UnknownFromMsgpack.pipe(Schema.optional), - policy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), - }), - }), - Schema.TaggedStruct("task.moved", { - ...eventBase, - payload: Schema.Struct({ - from: TextFromBytes.pipe(Schema.decodeTo(TaskLists), Schema.optional), - to: TextFromBytes.pipe(Schema.decodeTo(TaskLists), Schema.optional), - previousState: TextFromBytes.pipe( - Schema.decodeTo(ExecutionStateSchema), - Schema.optional, - ), - newState: TextFromBytes.pipe( - Schema.decodeTo(ExecutionStateSchema), - Schema.optional, - ), - attempt: NumberFromBytes, - handlerFailureCount: NumberFromBytes, - stalledAttemptCount: NumberFromBytes, - }), - }), -]); - -export type Event = typeof EventSchema.Type; - -const EventTypeSchema = EventSchema.mapMembers((member) => - member.map((member) => member.fields._tag), -); -export type EventType = typeof EventTypeSchema.Type; diff --git a/src/StorageProtocol.test.ts b/src/StorageProtocol.test.ts index 99a8523..d943601 100644 --- a/src/StorageProtocol.test.ts +++ b/src/StorageProtocol.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { Packr } from "msgpackr"; -import { describe, expect, test } from "vitest"; import * as StorageProtocol from "./StorageProtocol.js"; const fixture = JSON.parse( @@ -21,96 +21,141 @@ const value = { }; describe("StorageProtocol v1", () => { - test("the committed golden envelope is byte-stable and lossless", async () => { - const encoded = await Effect.runPromise( - StorageProtocol.encodeValue(fixture.schemaId, fixture.kind, value), - ); - expect(encoded).toBe(fixture.encoded); - await expect( - Effect.runPromise( - StorageProtocol.decodeValue( + it.effect("the committed golden envelope is byte-stable and lossless", () => + Effect.gen(function* () { + const encoded = yield* StorageProtocol.encodeValue( + fixture.schemaId, + fixture.kind, + value, + ); + expect(encoded).toBe(fixture.encoded); + expect( + yield* StorageProtocol.decodeValue( fixture.encoded, fixture.schemaId, fixture.kind, ), - ), - ).resolves.toEqual(value); - }); + ).toEqual(value); + }), + ); - test("unsupported JavaScript values fail instead of coercing", async () => { - const cyclic: { self?: unknown } = {}; - cyclic.self = cyclic; - for (const unsupported of [ - undefined, - BigInt(1), - Number.POSITIVE_INFINITY, - Number.MAX_SAFE_INTEGER + 1, - new Date(), - cyclic, - ]) { - const error = await Effect.runPromise( - StorageProtocol.encodeValue("schema", "payload", unsupported).pipe( - Effect.flip, - ), - ); - expect(error._tag).toBe("UnsupportedStorageValue"); - } - }); + it.effect("unsupported JavaScript values fail instead of coercing", () => + Effect.gen(function* () { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + for (const unsupported of [ + undefined, + BigInt(1), + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + new Date(), + cyclic, + ]) { + const error = yield* StorageProtocol.encodeValue( + "schema", + "payload", + unsupported, + ).pipe(Effect.flip); + expect(error._tag).toBe("UnsupportedStorageValue"); + } + }), + ); - test("size limits are checked on the encoded representation", async () => { - const error = await Effect.runPromise( - StorageProtocol.encodeValue("schema", "success", "too large", { - maxValueBytes: 4, - }).pipe(Effect.flip), - ); - expect(error).toMatchObject({ - _tag: "StorageLimitExceeded", - kind: "success", - maxBytes: 4, - }); - }); + it.effect("size limits are checked on the encoded representation", () => + Effect.gen(function* () { + const error = yield* StorageProtocol.encodeValue( + "schema", + "success", + "too large", + { + maxValueBytes: 4, + }, + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "StorageLimitExceeded", + kind: "success", + maxBytes: 4, + }); + }), + ); - test("schema, version, kind, and malformed envelopes have distinct errors", async () => { - const schemaError = await Effect.runPromise( - StorageProtocol.decodeValue( - fixture.encoded, - "another/schema", - "payload", - ).pipe(Effect.flip), - ); - expect(schemaError._tag).toBe("SchemaIdentityMismatch"); + it.effect( + "schema, version, kind, and malformed envelopes have distinct errors", + () => + Effect.gen(function* () { + const schemaError = yield* StorageProtocol.decodeValue( + fixture.encoded, + "another/schema", + "payload", + ).pipe(Effect.flip); + expect(schemaError._tag).toBe("SchemaIdentityMismatch"); - const packr = new Packr({ useRecords: false }); - const v2 = `effectmq:v1:${Buffer.from( - packr.pack([2, fixture.schemaId, "payload", null]), - ).toString("base64")}`; - const versionError = await Effect.runPromise( - StorageProtocol.decodeValue(v2, fixture.schemaId, "payload").pipe( - Effect.flip, - ), - ); - expect(versionError).toMatchObject({ - _tag: "UnsupportedProtocolVersion", - encountered: 2, - supported: [1], - }); + const packr = new Packr({ useRecords: false }); + const v2 = `effectmq:v1:${Buffer.from( + packr.pack([2, fixture.schemaId, "payload", null]), + ).toString("base64")}`; + const versionError = yield* StorageProtocol.decodeValue( + v2, + fixture.schemaId, + "payload", + ).pipe(Effect.flip); + expect(versionError).toMatchObject({ + _tag: "UnsupportedProtocolVersion", + encountered: 2, + supported: [1], + }); - const kindError = await Effect.runPromise( - StorageProtocol.decodeValue( - fixture.encoded, - fixture.schemaId, - "failure", - ).pipe(Effect.flip), - ); - expect(kindError._tag).toBe("CorruptStorageValue"); + const kindError = yield* StorageProtocol.decodeValue( + fixture.encoded, + fixture.schemaId, + "failure", + ).pipe(Effect.flip); + expect(kindError._tag).toBe("CorruptStorageValue"); - const corrupt = await Effect.runPromise( - StorageProtocol.decodeValue( - "effectmq:v1:not-messagepack", - fixture.schemaId, + const corrupt = yield* StorageProtocol.decodeValue( + "effectmq:v1:not-messagepack", + fixture.schemaId, + "payload", + ).pipe(Effect.flip); + expect(corrupt).toMatchObject({ + _tag: "StorageDecodingError", + stage: "base64", + path: "$", + }); + + const truncated = yield* StorageProtocol.decodeValue( + `effectmq:v1:${Buffer.from([0xd9]).toString("base64")}`, + fixture.schemaId, + "payload", + ).pipe(Effect.flip); + expect(truncated).toMatchObject({ + _tag: "StorageDecodingError", + stage: "messagepack", + path: "$", + }); + }), + ); + + it.effect("serializer-adjacent exceptions remain typed", () => + Effect.gen(function* () { + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error("hostile ownKeys"); + }, + }, + ); + const error = yield* StorageProtocol.encodeValue( + "schema", "payload", - ).pipe(Effect.flip), - ); - expect(corrupt._tag).toBe("CorruptStorageValue"); - }); + hostile, + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "StorageEncodingError", + stage: "messagepack", + path: "$", + }); + }), + ); }); diff --git a/src/StorageProtocol.ts b/src/StorageProtocol.ts index cbce9f4..017bf30 100644 --- a/src/StorageProtocol.ts +++ b/src/StorageProtocol.ts @@ -1,5 +1,6 @@ /** Versioned storage envelopes for opaque user values. @module */ -import { Data, Effect } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; import { Packr } from "msgpackr"; /** @@ -121,6 +122,24 @@ export class CorruptStorageValue extends Data.TaggedError( "CorruptStorageValue", )<{ readonly message: string; readonly cause?: unknown }> {} +/** A supported value could not be serialized for durable storage. */ +export class StorageEncodingError extends Data.TaggedError( + "StorageEncodingError", +)<{ + readonly stage: "messagepack" | "base64"; + readonly path: string; + readonly cause: unknown; +}> {} + +/** External bytes could not be converted or deserialized safely. */ +export class StorageDecodingError extends Data.TaggedError( + "StorageDecodingError", +)<{ + readonly stage: "bytes" | "base64" | "messagepack"; + readonly path: string; + readonly cause: unknown; +}> {} + /** * Indicates that an envelope uses a protocol version this release cannot read. * @@ -152,6 +171,8 @@ export type StorageProtocolError = | StorageLimitExceeded | StorageCountLimitExceeded | CorruptStorageValue + | StorageEncodingError + | StorageDecodingError | UnsupportedProtocolVersion | SchemaIdentityMismatch; @@ -257,11 +278,30 @@ export const encodeValue = ( kind: ValueKind, value: unknown, limits: Partial = defaultStorageLimits, -): Effect.Effect => +): Effect.Effect< + string, + UnsupportedStorageValue | StorageLimitExceeded | StorageEncodingError +> => Effect.gen(function* () { - const unsupported = validate(value, "$", new Set()); + const unsupported = yield* Effect.try({ + try: () => validate(value, "$", new Set()), + catch: (cause) => + new StorageEncodingError({ + stage: "messagepack", + path: "$", + cause, + }), + }); if (unsupported) return yield* unsupported; - const bytes = packr.pack([protocolVersion, schemaId, kind, value]); + const bytes = yield* Effect.try({ + try: () => packr.pack([protocolVersion, schemaId, kind, value]), + catch: (cause) => + new StorageEncodingError({ + stage: "messagepack", + path: "$", + cause, + }), + }); const maxValueBytes = limits.maxValueBytes ?? defaultStorageLimits.maxValueBytes; if (bytes.byteLength > maxValueBytes) { @@ -289,7 +329,10 @@ export const decodeValue = ( expectedKind: ValueKind, ): Effect.Effect< unknown, - CorruptStorageValue | UnsupportedProtocolVersion | SchemaIdentityMismatch + | CorruptStorageValue + | StorageDecodingError + | UnsupportedProtocolVersion + | SchemaIdentityMismatch > => Effect.gen(function* () { if (typeof encoded !== "string" || !encoded.startsWith(prefix)) { @@ -297,12 +340,27 @@ export const decodeValue = ( message: "Stored value is not an effectmq v1 envelope", }); } + const base64 = encoded.slice(prefix.length); + if ( + base64.length === 0 || + base64.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + base64, + ) + ) { + return yield* new StorageDecodingError({ + stage: "base64", + path: "$", + cause: new TypeError("Storage envelope contains invalid base64"), + }); + } + const bytes = Buffer.from(base64, "base64"); const envelope = yield* Effect.try({ - try: () => - packr.unpack(Buffer.from(encoded.slice(prefix.length), "base64")), + try: () => packr.unpack(bytes), catch: (cause) => - new CorruptStorageValue({ - message: "Invalid MessagePack envelope", + new StorageDecodingError({ + stage: "messagepack", + path: "$", cause, }), }); diff --git a/src/Task.test.ts b/src/Task.test.ts new file mode 100644 index 0000000..3a69e59 --- /dev/null +++ b/src/Task.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Task from "./Task.js"; + +const config = { + name: "identity-test", + payload: { value: Schema.String }, + success: Schema.Void, + error: Schema.String, +}; + +it.effect("invalid task configuration fails in the typed channel", () => + Effect.gen(function* () { + const error = yield* Task.make({ ...config, maxRetries: -1 }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "TaskConfigurationError", + field: "maxRetries", + actual: -1, + }); + }), +); + +it.effect("default identities come from the provided Crypto service", () => + Effect.gen(function* () { + const task = yield* Task.make(config); + const deterministicCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, bytes) => Effect.succeed(bytes), + }); + const first = yield* task + .idempotencyKey({ value: "first" }) + .pipe(Effect.provideService(Crypto.Crypto, deterministicCrypto)); + const second = yield* task + .idempotencyKey({ value: "second" }) + .pipe(Effect.provideService(Crypto.Crypto, deterministicCrypto)); + expect(first).toBe(second); + expect(first).toMatch(/^identity-test\/[0-9a-f-]{36}$/); + }), +); + +it.effect("custom identity callback exceptions remain typed", () => + Effect.gen(function* () { + const task = yield* Task.make({ + ...config, + idempotencyKey: () => { + throw new Error("identity failed"); + }, + }); + const error = yield* task + .idempotencyKey({ value: "value" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TaskIdentityGenerationError", + taskName: "identity-test", + }); + }), +); diff --git a/src/Task.ts b/src/Task.ts index 68205e4..a7e7560 100644 --- a/src/Task.ts +++ b/src/Task.ts @@ -4,10 +4,14 @@ * * @module */ -import { type Effect, Schedule, Schema } from "effect"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import type { AnyStructSchema } from "effect/unstable/workflow/Workflow"; +import { buildFromOptions } from "./RetrySchedule.js"; import { defaultStorageLimits, type StorageLimits } from "./StorageProtocol.js"; -import { buildFromOptions } from "./utils.js"; const TypeId = "~effectmq/Task" as const; @@ -50,28 +54,42 @@ export const defaultRetentionPolicy: RetentionPolicy = { const resolveRetention = ( retention: Partial | undefined, -): RetentionPolicy => { - const resolved = { ...defaultRetentionPolicy, ...retention }; - for (const [name, value] of Object.entries(resolved)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`); - } - } - return resolved; -}; +): RetentionPolicy => ({ ...defaultRetentionPolicy, ...retention }); const resolveStorageLimits = ( limits: Partial | undefined, -): StorageLimits => { - const resolved = { ...defaultStorageLimits, ...limits }; - for (const [name, value] of Object.entries(resolved)) { - const minimum = name === "maxEventEntries" ? 1 : 0; - if (!Number.isSafeInteger(value) || value < minimum) { - throw new RangeError(`${name} must be a non-negative safe integer`); +): StorageLimits => ({ ...defaultStorageLimits, ...limits }); + +/** Predictable validation failure for a task definition. */ +export class TaskConfigurationError extends Data.TaggedError( + "TaskConfigurationError", +)<{ + readonly field: string; + readonly constraint: string; + readonly actual: unknown; +}> {} + +/** Failure while deriving a task identity from a callback or Crypto service. */ +export class TaskIdentityGenerationError extends Data.TaggedError( + "TaskIdentityGenerationError", +)<{ readonly taskName: string; readonly cause: unknown }> {} + +const validateIntegerFields = ( + values: object, + minimumFor: (field: string) => number, +): Effect.Effect => + Effect.gen(function* () { + for (const [field, actual] of Object.entries(values)) { + const minimum = minimumFor(field); + if (!Number.isSafeInteger(actual) || actual < minimum) { + return yield* new TaskConfigurationError({ + field, + constraint: `a safe integer greater than or equal to ${minimum}`, + actual, + }); + } } - } - return resolved; -}; + }); /** * A decoded task generation as seen by a handler. @@ -82,7 +100,7 @@ const resolveStorageLimits = ( * @category Models * @since 0.1.0 */ -export type { Task } from "./Schemas.js"; +export type { Task } from "./TaskRecord.js"; /** * The schema-bearing definition of one task family. @@ -99,6 +117,7 @@ export interface TaskDefinition< Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, + IdentityR = Crypto.Crypto, > { readonly [TypeId]: typeof TypeId; @@ -118,7 +137,9 @@ export interface TaskDefinition< readonly storageLimits: StorageLimits; readonly retention: RetentionPolicy; - readonly idempotencyKey: (payload: Payload["Type"]) => string; + readonly idempotencyKey: ( + payload: Payload["Type"], + ) => Effect.Effect; } /** * Resolves either a struct schema or bare struct fields to a struct schema. @@ -131,6 +152,8 @@ export type ResolvePayload = ? T : Schema.Struct; +export type IdempotencyKey = (payload: Payload) => string; + /** * Normalizes a task payload declaration to a struct schema. * @@ -155,6 +178,7 @@ const makeInternal = < Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, + IdentityR = Crypto.Crypto, >(config: { name: string; schemaId?: string; @@ -164,34 +188,63 @@ const makeInternal = < maxRetries?: number | null; storageLimits?: Partial; retention?: Partial; - idempotencyKey?: (payload: ResolvePayload["Type"]) => string; + idempotencyKey: ( + payload: ResolvePayload["Type"], + ) => Effect.Effect; retrySchedule?: Schedule.Schedule, any, R>; -}): TaskDefinition, Success, Error, R> => { - const payloadSchema = resolvePayloadSchema(config.payload); - const successSchema = (config.success ?? Schema.Void) as Success; - const errorSchema = (config.error ?? Schema.Never) as Error; +}): Effect.Effect< + TaskDefinition, Success, Error, R, IdentityR>, + TaskConfigurationError +> => + Effect.gen(function* () { + const payloadSchema = resolvePayloadSchema(config.payload); + const successSchema = (config.success ?? Schema.Void) as Success; + const errorSchema = (config.error ?? Schema.Never) as Error; - const self: TaskDefinition, Success, Error, R> = { - [TypeId]: TypeId, - name: config.name, - schemaId: config.schemaId ?? config.name, - payloadSchema: payloadSchema, - successSchema: successSchema, - errorSchema: errorSchema, - retrySchedule: config.retrySchedule, - // unset → default cap of 5; null → unbounded; a number → that number - maxRetries: + const maxRetries = config.maxRetries === undefined ? DEFAULT_MAX_RETRIES - : (config.maxRetries ?? Infinity), - storageLimits: resolveStorageLimits(config.storageLimits), - retention: resolveRetention(config.retention), - idempotencyKey: - config.idempotencyKey ?? (() => `${config.name}/${crypto.randomUUID()}`), - }; + : (config.maxRetries ?? Infinity); + if ( + maxRetries !== Infinity && + (!Number.isSafeInteger(maxRetries) || maxRetries < 0) + ) { + return yield* new TaskConfigurationError({ + field: "maxRetries", + constraint: "a non-negative safe integer or null", + actual: config.maxRetries, + }); + } + const storageLimits = resolveStorageLimits(config.storageLimits); + yield* validateIntegerFields(storageLimits, (field) => + field === "maxEventEntries" ? 1 : 0, + ); + const retention = resolveRetention(config.retention); + yield* validateIntegerFields(retention, () => 0); - return self; -}; + const self: TaskDefinition< + ResolvePayload, + Success, + Error, + R, + IdentityR + > = { + [TypeId]: TypeId, + name: config.name, + schemaId: config.schemaId ?? config.name, + payloadSchema: payloadSchema, + successSchema: successSchema, + errorSchema: errorSchema, + retrySchedule: config.retrySchedule, + // unset → default cap of 5; null → unbounded; a number → that number + maxRetries, + storageLimits, + retention, + idempotencyKey: config.idempotencyKey, + }; + + return self; + }); /** * Defines a typed task family. @@ -211,17 +264,19 @@ const makeInternal = < * **Example: Define an idempotent task with bounded retries** * * ```ts - * import { Schema } from "effect" + * import { Effect, Schema } from "effect" * import { Task } from "@effectmq/core" * - * const sendInvoice = Task.make({ - * name: "send-invoice", - * schemaId: "send-invoice/v1", - * payload: { invoiceId: Schema.String }, - * success: Schema.Void, - * error: Schema.Struct({ reason: Schema.String }), - * idempotencyKey: ({ invoiceId }) => invoiceId, - * maxRetries: 3 + * const sendInvoice = Effect.gen(function* () { + * return yield* Task.make({ + * name: "send-invoice", + * schemaId: "send-invoice/v1", + * payload: { invoiceId: Schema.String }, + * success: Schema.Void, + * error: Schema.Struct({ reason: Schema.String }), + * idempotencyKey: ({ invoiceId }) => invoiceId, + * maxRetries: 3 + * }) * }) * ``` * @@ -245,7 +300,7 @@ export const make: { maxRetries?: number | null; storageLimits?: Partial; retention?: Partial; - idempotencyKey?: (payload: ResolvePayload["Type"]) => string; + idempotencyKey: IdempotencyKey["Type"]>; retry?: { while?: | (( @@ -262,7 +317,60 @@ export const make: { | Schedule.Schedule, unknown, R3> | undefined; }; - }): TaskDefinition, Success, Error, R1 | R2 | R3>; + }): Effect.Effect< + TaskDefinition< + ResolvePayload, + Success, + Error, + R1 | R2 | R3, + never + >, + TaskConfigurationError + >; + + < + Payload extends AnyStructSchema | Schema.Struct.Fields, + Success extends Schema.Top = Schema.Void, + Error extends Schema.Top = Schema.Never, + R1 = never, + R2 = never, + R3 = never, + >(config: { + name: string; + schemaId?: string; + success: Success; + error: Error; + payload: Payload; + maxRetries?: number | null; + storageLimits?: Partial; + retention?: Partial; + idempotencyKey?: undefined; + retry?: { + while?: + | (( + error: NoInfer, + ) => boolean | Effect.Effect, R1>) + | undefined; + until?: + | (( + error: NoInfer, + ) => boolean | Effect.Effect, R2>) + | undefined; + times?: number | undefined; + schedule?: + | Schedule.Schedule, unknown, R3> + | undefined; + }; + }): Effect.Effect< + TaskDefinition< + ResolvePayload, + Success, + Error, + R1 | R2 | R3, + Crypto.Crypto + >, + TaskConfigurationError + >; < Payload extends AnyStructSchema | Schema.Struct.Fields, @@ -278,14 +386,43 @@ export const make: { maxRetries?: number | null; storageLimits?: Partial; retention?: Partial; - idempotencyKey?: (payload: ResolvePayload["Type"]) => string; + idempotencyKey: IdempotencyKey["Type"]>; retry: Schedule.Schedule< any, NoInfer, NoInfer, Env >; - }): TaskDefinition, Success, Error, Env>; + }): Effect.Effect< + TaskDefinition, Success, Error, Env, never>, + TaskConfigurationError + >; + + < + Payload extends AnyStructSchema | Schema.Struct.Fields, + Success extends Schema.Top = Schema.Void, + Error extends Schema.Top = Schema.Never, + Env = never, + >(config: { + name: string; + schemaId?: string; + payload: Payload; + success: Success; + error: Error; + maxRetries?: number | null; + storageLimits?: Partial; + retention?: Partial; + idempotencyKey?: undefined; + retry: Schedule.Schedule< + any, + NoInfer, + NoInfer, + Env + >; + }): Effect.Effect< + TaskDefinition, Success, Error, Env, Crypto.Crypto>, + TaskConfigurationError + >; } = (({ name, schemaId, @@ -318,6 +455,24 @@ export const make: { ? retry : buildFromOptions(retry) : undefined; + const identity = idempotencyKey + ? (payload: unknown) => + Effect.try({ + try: () => idempotencyKey(payload), + catch: (cause) => + new TaskIdentityGenerationError({ taskName: name, cause }), + }) + : () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new TaskIdentityGenerationError({ taskName: name, cause }), + ), + Effect.map((uuid) => `${name}/${uuid}`), + ); + }); return makeInternal({ name, schemaId, @@ -327,7 +482,7 @@ export const make: { maxRetries, storageLimits, retention, - idempotencyKey, + idempotencyKey: identity, retrySchedule: schedule, }); }) as never; diff --git a/src/TaskContext.ts b/src/TaskContext.ts index 0aede56..0344f49 100644 --- a/src/TaskContext.ts +++ b/src/TaskContext.ts @@ -6,22 +6,11 @@ * * @module */ -import { Context, Effect, Layer, Option } from "effect"; -import type { TaskIdentity } from "./Schemas.js"; - -export interface TaskContext { - readonly currentTask?: TaskIdentity; -} -export const TaskContext = Context.Service( - "~effectmq/TaskContext", -); - -/** Provide a task context. Used by `TaskQueue.complete` around handler runs. */ -export const layer = (options: TaskContext = {}) => - Layer.succeed(TaskContext, options); +import * as Context from "effect/Context"; +import type { TaskIdentity } from "./TaskRecord.js"; /** The identity of the task generation whose handler is running, if any. */ -export const currentTask: Effect.Effect = Effect.map( - Effect.serviceOption(TaskContext), - (context) => (Option.isSome(context) ? context.value.currentTask : undefined), +export const currentTask = Context.Reference( + "@effectmq/core/TaskContext/currentTask", + { defaultValue: () => undefined }, ); diff --git a/src/TaskEngine.locks.test.ts b/src/TaskEngine.locks.test.ts index 06e20e1..8443901 100644 --- a/src/TaskEngine.locks.test.ts +++ b/src/TaskEngine.locks.test.ts @@ -1,7 +1,7 @@ -import { Effect, Schema } from "effect"; -import { describe, expect, test } from "vitest"; +import { Effect, Schedule, Schema } from "effect"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, Task, TaskEngine, TaskQueue } from "./index.js"; -import { getLists, TestRuntime } from "./testing/redisLayer.js"; +import { getLists, TestLayer } from "./testing/redisLayer.js"; import { extendLock, removeLock, @@ -30,336 +30,383 @@ const createTask = ( prefix, }); -describe("TaskEngine locking", () => { - test("one maintenance pass recovers at most the configured lease batch", () => - Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const engine = yield* TaskEngine.make({ - debugMode: true, - maintenanceBatchSize: 2, - }); - const prefix = "bounded-expired-leases"; - yield* TaskEngine.setMockTime(2_000_000); - for (const id of ["one", "two", "three"]) { - yield* engine.createTask({ +const waitForLockExpiry = ( + redis: RedisPool.RedisPoolService, + lockKey: string, +) => + redis.send("PTTL", lockKey).pipe( + Effect.repeat({ + schedule: Schedule.spaced("10 millis"), + until: (ttl) => ttl <= 0, + }), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => Effect.die(`Timed out waiting for Redis lock ${lockKey}`), + }), + ); + +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "TaskEngine locking (real Redis time)", + (it) => { + it.effect( + "one maintenance pass recovers at most the configured lease batch", + () => + Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const engine = yield* TaskEngine.make({ + debugMode: true, + maintenanceBatchSize: 2, + }); + const prefix = "bounded-expired-leases"; + yield* TaskEngine.setMockTime(2_000_000); + for (const id of ["one", "two", "three"]) { + yield* engine.createTask({ + prefix, + id, + name: "bounded lease", + payload: null, + delay: 0, + maxRetries: 0, + maxStalledCount: 1, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + yield* engine.takeTask(prefix, 100); + yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:${id}`); + } + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(prefix); + + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:active`), + ).toBe(1); + expect(yield* redis.send("LLEN", `~effectmq:v1:${prefix}:wait`)).toBe( + 2, + ); + }), + ); + + it.effect("takeTask returns null on an empty queue", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const taken = yield* takeTask(engine, "locks-empty", 30_000); + expect(taken).toBeNull(); + }), + ); + + it.effect("writeSuccess on a task that does not exist fails", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const error = yield* engine + .writeSuccess("locks-missing", "ghost", "lease/missing", "ok") + .pipe(Effect.flip); + expect(error._tag).toBe("TaskEngineError"); + }), + ); + + it.effect("a stale token cannot complete, fail, or extend an attempt", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "locks-foreign"; + yield* createTask(engine, prefix, "f1"); + const attempt = requireAttempt(yield* engine.takeTask(prefix, 30_000)); + + const successError = yield* engine + .writeSuccess(prefix, "f1", "lease/stale", "ok") + .pipe(Effect.flip); + expect(successError._tag).toBe("LeaseLost"); + + const failError = yield* engine + .writeError(prefix, "f1", "lease/stale", { reason: "nope" }) + .pipe(Effect.flip); + expect(failError._tag).toBe("LeaseLost"); + + const extendError = yield* engine + .extendLock(prefix, "f1", "lease/stale", 60_000) + .pipe(Effect.flip); + expect(extendError._tag).toBe("LeaseLost"); + + // the actual holder can still complete it + yield* engine.writeSuccess(prefix, "f1", attempt.leaseToken, "ok"); + }), + ); + + it.effect("extendLock by the holder refreshes the lock TTL", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "locks-extend"; + yield* createTask(engine, prefix, "e1"); + yield* takeTask(engine, prefix, 30_000); + + const lockKey = `~effectmq:v1:${prefix}:lock:e1`; + const initialTtl = yield* redis.send("PTTL", lockKey); + expect(initialTtl).toBeGreaterThan(0); + expect(initialTtl).toBeLessThanOrEqual(30_000); + + yield* extendLock(engine, prefix, "e1", 120_000); + const extendedTtl = yield* redis.send("PTTL", lockKey); + expect(extendedTtl).toBeGreaterThan(30_000); + }), + ); + + it.effect( + "an expired lock stalls the task back to wait with a typed Stalled error", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "locks-stall"; + yield* createTask(engine, prefix, "s1"); + // a 250ms lock, then wait for it to expire for real (lock TTLs are + // real-time Redis key expiry, not mock time) + yield* takeTask(engine, prefix, 250); + expect((yield* getLists(prefix)).active).toEqual(["s1"]); + + yield* waitForLockExpiry(redis, `~effectmq:v1:${prefix}:lock:s1`); + yield* TaskEngine.stepMockTime(400); + + // any engine call runs syncLocks; the unlocked active task is stalled + // and requeued for an immediate retry (stalls bypass the failure policy) + const lists = yield* getLists(prefix); + expect(lists.wait).toEqual(["s1"]); + expect(lists.active).toEqual([]); + expect(lists.failed).toEqual([]); + + const task = yield* engine.getTask(prefix, "s1"); + expect(task?.errors).toHaveLength(1); + expect((task?.errors[0].error as { _tag: string })._tag).toBe( + "~effectmq/Error/Stalled", + ); + }), + ); + + it.effect( + "a stalled task can be re-taken and decoded by a typed queue", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const def = yield* Task.make({ + name: "locks-recover", + payload: { userId: Schema.String }, + success: Schema.String, + error: Schema.Struct({ reason: Schema.String }), + idempotencyKey: (p) => p.userId, + }); + const queue = TaskQueue.make("locks-recover", def); + yield* TaskQueue.offer(queue, { userId: "r1" }); + + // simulate a worker that took the task and died: lock expires + yield* takeTask(engine, queue.name, 250); + yield* waitForLockExpiry(redis, `~effectmq:v1:${queue.name}:lock:r1`); + yield* TaskEngine.stepMockTime(400); + + // the next complete() must decode the task, stalled-error entry included + let seenErrors = 0; + const done = yield* TaskQueue.complete(queue, (task) => { + seenErrors = task.errors.length; + return Effect.succeed("recovered"); + }); + expect(done).toBe("r1"); + expect(seenErrors).toBe(1); + }), + ); + + it.effect("removeLock voluntarily requeues without recording a stall", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "locks-release"; + yield* createTask(engine, prefix, "u1"); + yield* takeTask(engine, prefix, 30_000); + yield* removeLock(engine, prefix, "u1"); + + const lists = yield* getLists(prefix); + expect(lists.wait).toEqual(["u1"]); + expect(lists.active).toEqual([]); + const task = yield* engine.getTask(prefix, "u1"); + expect(task?.stalledAttemptCount).toBe(0); + expect(task?.errors).toEqual([]); + }), + ); + + it.effect("a previous attempt token cannot mutate a re-acquired task", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "locks-fenced-reacquire"; + yield* createTask(engine, prefix, "fenced"); + + const first = requireAttempt(yield* engine.takeTask(prefix, 100)); + expect(first.task.attempt).toBe(1); + + // Model a crashed process: its lock disappears and maintenance observes + // the server-time deadline before another worker acquires the retry. + yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:fenced`); + yield* TaskEngine.stepMockTime(101); + expect((yield* getLists(prefix)).wait).toEqual(["fenced"]); + + const second = requireAttempt(yield* engine.takeTask(prefix, 30_000)); + expect(second.task.attempt).toBe(2); + expect(second.leaseToken).not.toBe(first.leaseToken); + + const staleEffects = [ + engine.writeSuccess(prefix, "fenced", first.leaseToken, "late"), + engine.writeError(prefix, "fenced", first.leaseToken, { + reason: "late", + }), + engine.extendLock(prefix, "fenced", first.leaseToken, 30_000), + engine.removeLock(prefix, "fenced", first.leaseToken), + ]; + for (const effect of staleEffects) { + const error = yield* effect.pipe(Effect.flip); + expect(error._tag).toBe("LeaseLost"); + } + + expect(yield* getLists(prefix)).toMatchObject({ + active: ["fenced"], + wait: [], + }); + expect((yield* engine.getTask(prefix, "fenced"))?.errors).toHaveLength( + 1, + ); + yield* engine.writeSuccess( prefix, - id, - name: "bounded lease", + "fenced", + second.leaseToken, + "current", + ); + }), + ); + + it.effect( + "attempt, handler failure, and stalled counts are independent and stalls terminate", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const now = 1000000000000; + yield* TaskEngine.setMockTime(now); + const prefix = "locks-counts"; + yield* engine.createTask({ + id: "counts", + name: "counts", + payload: null, + delay: 0, + maxRetries: 5, + maxStalledCount: 1, + onSuccessPolicy: "keep", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + const first = requireAttempt(yield* engine.takeTask(prefix, 100)); + yield* engine.writeError( + prefix, + "counts", + first.leaseToken, + { reason: "handler" }, + now, + ); + const second = yield* engine.takeTask(prefix, 100); + expect(second?.task).toMatchObject({ + attempt: 2, + handlerFailureCount: 1, + stalledAttemptCount: 0, + }); + + yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:counts`); + yield* TaskEngine.stepMockTime(101); + yield* getLists(prefix); + const third = yield* engine.takeTask(prefix, 100); + expect(third?.task).toMatchObject({ + attempt: 3, + handlerFailureCount: 1, + stalledAttemptCount: 1, + }); + + yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:counts`); + yield* TaskEngine.stepMockTime(101); + expect(yield* getLists(prefix)).toMatchObject({ + active: [], + failed: ["counts"], + wait: [], + }); + const terminal = yield* engine.getTask(prefix, "counts"); + expect(terminal).toMatchObject({ + attempt: 3, + handlerFailureCount: 1, + stalledAttemptCount: 2, + outcome: "failure", + }); + expect(terminal?.errors).toHaveLength(3); + }), + ); + + it.effect("error history retains only the configured newest entries", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const prefix = "locks-error-limit"; + yield* engine.createTask({ + id: "limited", + name: "limited", payload: null, delay: 0, - maxRetries: 0, - maxStalledCount: 1, + maxRetries: 5, + maxErrorEntries: 2, onSuccessPolicy: "keep", onFailurePolicy: "keep", - }); - yield* engine.takeTask(prefix, 100); - yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:${id}`); - } - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(prefix); - - expect(yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:active`)).toBe( - 1, - ); - expect(yield* redis.send("LLEN", `~effectmq:v1:${prefix}:wait`)).toBe(2); - }).pipe(TestRuntime.runPromise)); - - test("takeTask returns null on an empty queue", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const taken = yield* takeTask(engine, "locks-empty", 30_000); - expect(taken).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("writeSuccess on a task that does not exist fails", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const error = yield* engine - .writeSuccess("locks-missing", "ghost", "lease/missing", "ok") - .pipe(Effect.flip); - expect(error._tag).toBe("TaskEngineError"); - }).pipe(TestRuntime.runPromise)); - - test("a stale token cannot complete, fail, or extend an attempt", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "locks-foreign"; - yield* createTask(engine, prefix, "f1"); - const attempt = requireAttempt(yield* engine.takeTask(prefix, 30_000)); - - const successError = yield* engine - .writeSuccess(prefix, "f1", "lease/stale", "ok") - .pipe(Effect.flip); - expect(successError._tag).toBe("LeaseLost"); - - const failError = yield* engine - .writeError(prefix, "f1", "lease/stale", { reason: "nope" }) - .pipe(Effect.flip); - expect(failError._tag).toBe("LeaseLost"); - - const extendError = yield* engine - .extendLock(prefix, "f1", "lease/stale", 60_000) - .pipe(Effect.flip); - expect(extendError._tag).toBe("LeaseLost"); - - // the actual holder can still complete it - yield* engine.writeSuccess(prefix, "f1", attempt.leaseToken, "ok"); - }).pipe(TestRuntime.runPromise)); - - test("extendLock by the holder refreshes the lock TTL", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "locks-extend"; - yield* createTask(engine, prefix, "e1"); - yield* takeTask(engine, prefix, 30_000); - - const lockKey = `~effectmq:v1:${prefix}:lock:e1`; - const initialTtl = yield* redis.send("PTTL", lockKey); - expect(initialTtl).toBeGreaterThan(0); - expect(initialTtl).toBeLessThanOrEqual(30_000); - - yield* extendLock(engine, prefix, "e1", 120_000); - const extendedTtl = yield* redis.send("PTTL", lockKey); - expect(extendedTtl).toBeGreaterThan(30_000); - }).pipe(TestRuntime.runPromise)); - - test("an expired lock stalls the task back to wait with a typed Stalled error", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "locks-stall"; - yield* createTask(engine, prefix, "s1"); - // a 250ms lock, then wait for it to expire for real (lock TTLs are - // real-time Redis key expiry, not mock time) - yield* takeTask(engine, prefix, 250); - expect((yield* getLists(prefix)).active).toEqual(["s1"]); - - yield* Effect.sleep("400 millis"); - yield* TaskEngine.stepMockTime(400); - - // any engine call runs syncLocks; the unlocked active task is stalled - // and requeued for an immediate retry (stalls bypass the failure policy) - const lists = yield* getLists(prefix); - expect(lists.wait).toEqual(["s1"]); - expect(lists.active).toEqual([]); - expect(lists.failed).toEqual([]); - - const task = yield* engine.getTask(prefix, "s1"); - expect(task?.errors).toHaveLength(1); - expect((task?.errors[0].error as { _tag: string })._tag).toBe( - "~effectmq/Error/Stalled", - ); - }).pipe(TestRuntime.runPromise)); - - test("a stalled task can be re-taken and decoded by a typed queue", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const def = Task.make({ - name: "locks-recover", - payload: { userId: Schema.String }, - success: Schema.String, - error: Schema.Struct({ reason: Schema.String }), - idempotencyKey: (p) => p.userId, - }); - const queue = TaskQueue.make("locks-recover", def); - yield* TaskQueue.offer(queue, { userId: "r1" }); - - // simulate a worker that took the task and died: lock expires - yield* takeTask(engine, queue.name, 250); - yield* Effect.sleep("400 millis"); - yield* TaskEngine.stepMockTime(400); - - // the next complete() must decode the task, stalled-error entry included - let seenErrors = 0; - const done = yield* TaskQueue.complete(queue, (task) => { - seenErrors = task.errors.length; - return Effect.succeed("recovered"); - }); - expect(done).toBe("r1"); - expect(seenErrors).toBe(1); - }).pipe(TestRuntime.runPromise)); - - test("removeLock voluntarily requeues without recording a stall", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "locks-release"; - yield* createTask(engine, prefix, "u1"); - yield* takeTask(engine, prefix, 30_000); - yield* removeLock(engine, prefix, "u1"); - - const lists = yield* getLists(prefix); - expect(lists.wait).toEqual(["u1"]); - expect(lists.active).toEqual([]); - const task = yield* engine.getTask(prefix, "u1"); - expect(task?.stalledAttemptCount).toBe(0); - expect(task?.errors).toEqual([]); - }).pipe(TestRuntime.runPromise)); - - test("a previous attempt token cannot mutate a re-acquired task", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "locks-fenced-reacquire"; - yield* createTask(engine, prefix, "fenced"); - - const first = requireAttempt(yield* engine.takeTask(prefix, 100)); - expect(first.task.attempt).toBe(1); - - // Model a crashed process: its lock disappears and maintenance observes - // the server-time deadline before another worker acquires the retry. - yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:fenced`); - yield* TaskEngine.stepMockTime(101); - expect((yield* getLists(prefix)).wait).toEqual(["fenced"]); - - const second = requireAttempt(yield* engine.takeTask(prefix, 30_000)); - expect(second.task.attempt).toBe(2); - expect(second.leaseToken).not.toBe(first.leaseToken); - - const staleEffects = [ - engine.writeSuccess(prefix, "fenced", first.leaseToken, "late"), - engine.writeError(prefix, "fenced", first.leaseToken, { - reason: "late", - }), - engine.extendLock(prefix, "fenced", first.leaseToken, 30_000), - engine.removeLock(prefix, "fenced", first.leaseToken), - ]; - for (const effect of staleEffects) { - const error = yield* effect.pipe(Effect.flip); - expect(error._tag).toBe("LeaseLost"); - } - - expect(yield* getLists(prefix)).toMatchObject({ - active: ["fenced"], - wait: [], - }); - expect((yield* engine.getTask(prefix, "fenced"))?.errors).toHaveLength(1); - yield* engine.writeSuccess( - prefix, - "fenced", - second.leaseToken, - "current", - ); - }).pipe(TestRuntime.runPromise)); - - test("attempt, handler failure, and stalled counts are independent and stalls terminate", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const now = 1000000000000; - yield* TaskEngine.setMockTime(now); - const prefix = "locks-counts"; - yield* engine.createTask({ - id: "counts", - name: "counts", - payload: null, - delay: 0, - maxRetries: 5, - maxStalledCount: 1, - onSuccessPolicy: "keep", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - const first = requireAttempt(yield* engine.takeTask(prefix, 100)); - yield* engine.writeError( - prefix, - "counts", - first.leaseToken, - { reason: "handler" }, - now, - ); - const second = yield* engine.takeTask(prefix, 100); - expect(second?.task).toMatchObject({ - attempt: 2, - handlerFailureCount: 1, - stalledAttemptCount: 0, - }); - - yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:counts`); - yield* TaskEngine.stepMockTime(101); - yield* getLists(prefix); - const third = yield* engine.takeTask(prefix, 100); - expect(third?.task).toMatchObject({ - attempt: 3, - handlerFailureCount: 1, - stalledAttemptCount: 1, - }); - - yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:counts`); - yield* TaskEngine.stepMockTime(101); - expect(yield* getLists(prefix)).toMatchObject({ - active: [], - failed: ["counts"], - wait: [], - }); - const terminal = yield* engine.getTask(prefix, "counts"); - expect(terminal).toMatchObject({ - attempt: 3, - handlerFailureCount: 1, - stalledAttemptCount: 2, - outcome: "failure", - }); - expect(terminal?.errors).toHaveLength(3); - }).pipe(TestRuntime.runPromise)); - - test("error history retains only the configured newest entries", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const prefix = "locks-error-limit"; - yield* engine.createTask({ - id: "limited", - name: "limited", - payload: null, - delay: 0, - maxRetries: 5, - maxErrorEntries: 2, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - prefix, - }); - - for (const sequence of [1, 2, 3]) { - const attempt = requireAttempt(yield* engine.takeTask(prefix, 30_000)); - yield* engine.writeError( prefix, - "limited", - attempt.leaseToken, - { sequence }, - 1, + }); + + for (const sequence of [1, 2, 3]) { + const attempt = requireAttempt( + yield* engine.takeTask(prefix, 30_000), + ); + yield* engine.writeError( + prefix, + "limited", + attempt.leaseToken, + { sequence }, + 1, + ); + } + + const task = yield* engine.getTask(prefix, "limited"); + expect(task?.errors.map((entry) => entry.error)).toEqual([ + { sequence: 2 }, + { sequence: 3 }, + ]); + }), + ); + + it.effect("non-debug lease deadlines come from Redis server time", () => + Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const engine = yield* TaskEngine.make({ debugMode: false }); + const prefix = "locks-server-time"; + yield* createTask(engine, prefix, "clock"); + + const redisTime = yield* redis.send<[string, string]>("TIME"); + const before = + Number(redisTime[0]) * 1000 + Number(redisTime[1]) / 1000; + yield* engine.takeTask(prefix, 30_000); + const score = Number( + yield* redis.send( + "ZSCORE", + `~effectmq:v1:${prefix}:active`, + "clock", + ), ); - } - - const task = yield* engine.getTask(prefix, "limited"); - expect(task?.errors.map((entry) => entry.error)).toEqual([ - { sequence: 2 }, - { sequence: 3 }, - ]); - }).pipe(TestRuntime.runPromise)); - - test("non-debug lease deadlines come from Redis server time", () => - Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const engine = yield* TaskEngine.make({ debugMode: false }); - const prefix = "locks-server-time"; - yield* createTask(engine, prefix, "clock"); - - const redisTime = yield* redis.send<[string, string]>("TIME"); - const before = Number(redisTime[0]) * 1000 + Number(redisTime[1]) / 1000; - yield* engine.takeTask(prefix, 30_000); - const score = Number( - yield* redis.send( - "ZSCORE", - `~effectmq:v1:${prefix}:active`, - "clock", - ), - ); - - expect(score).toBeGreaterThanOrEqual(before + 29_000); - expect(score).toBeLessThanOrEqual(before + 31_000); - }).pipe(TestRuntime.runPromise)); -}); + + expect(score).toBeGreaterThanOrEqual(before + 29_000); + expect(score).toBeLessThanOrEqual(before + 31_000); + }), + ); + }, +); diff --git a/src/TaskEngine.pinning.test.ts b/src/TaskEngine.pinning.test.ts index 217cc22..8746b63 100644 --- a/src/TaskEngine.pinning.test.ts +++ b/src/TaskEngine.pinning.test.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, TaskEngine } from "./index.js"; -import { getLists, TestRuntime } from "./testing/redisLayer.js"; +import { getLists, TestLayer } from "./testing/redisLayer.js"; const baseTask = ( prefix: string, @@ -60,231 +60,264 @@ const relationKeys = ( retains: `~effectmq:v1:${holderQueue}:task:${holderId}:1:retains`, }); -describe("TaskEngine result-retention relationships", () => { - test("replaying the same holder/retained generation creates one hold", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const holderQueue = "retention-replay-holder"; - const retainedQueue = "retention-replay-task"; - yield* engine.createTask(baseTask(holderQueue, "holder")); - const insert = { - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity(holderQueue, "holder"), - }; - - yield* engine.offerTask(insert); - const replay = yield* engine.offerTask(insert); - expect(replay.status).toBe("existing"); - - const keys = relationKeys( - holderQueue, - "holder", - retainedQueue, - "retained", - ); - expect(yield* redis.send("SCARD", keys.holders)).toBe(1); - expect(yield* redis.send("SCARD", keys.retains)).toBe(1); - }).pipe(TestRuntime.runPromise)); - - test("independent live holders can retain the same existing generation", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const retainedQueue = "retention-multiple-task"; - yield* engine.createTask(baseTask("retention-holder-a", "a")); - yield* engine.createTask(baseTask("retention-holder-b", "b")); - yield* engine.offerTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity("retention-holder-a", "a"), - }); - const existing = yield* engine.offerTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity("retention-holder-b", "b"), - }); - - expect(existing.status).toBe("existing"); - const holdersKey = `~effectmq:v1:${retainedQueue}:task:retained:1:retained-by`; - expect(yield* redis.send("SCARD", holdersKey)).toBe(2); - - yield* succeedNext(engine, retainedQueue, "result"); - expect((yield* engine.getTask(retainedQueue, "retained"))?.outcome).toBe( - "success", - ); - - yield* succeedNext(engine, "retention-holder-a"); - expect(yield* engine.getTask(retainedQueue, "retained")).not.toBeNull(); - expect(yield* redis.send("SCARD", holdersKey)).toBe(1); - - yield* succeedNext(engine, "retention-holder-b"); - expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("a settled holder is rejected without creating the retained task", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const holderQueue = "retention-settled-holder"; - const retainedQueue = "retention-settled-task"; - yield* engine.createTask( - baseTask(holderQueue, "holder", { onSuccessPolicy: "keep" }), - ); - yield* succeedNext(engine, holderQueue); - - const error = yield* engine - .offerTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity(holderQueue, "holder"), - }) - .pipe(Effect.flip); - - expect(String((error.cause as { cause: unknown }).cause)).toContain( - "retention holder is settled", - ); - expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("holder removal releases its hold without affecting runnable work", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const holderQueue = "retention-remove-holder"; - const retainedQueue = "retention-remove-task"; - yield* engine.createTask(baseTask(holderQueue, "holder")); - yield* engine.createTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity(holderQueue, "holder"), - }); - - yield* engine.removeTask(holderQueue, "holder"); - const holdersKey = `~effectmq:v1:${retainedQueue}:task:retained:1:retained-by`; - expect(yield* redis.send("SCARD", holdersKey)).toBe(0); - expect((yield* getLists(retainedQueue)).wait).toEqual(["retained"]); - }).pipe(TestRuntime.runPromise)); - - test("terminal settlement is visible immediately while delete waits for holds", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const holderQueue = "retention-visible-holder"; - const retainedQueue = "retention-visible-task"; - yield* engine.createTask(baseTask(holderQueue, "holder")); - yield* engine.createTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity(holderQueue, "holder"), - }); - - yield* succeedNext(engine, retainedQueue, "visible-result"); - const retained = yield* engine.getTask(retainedQueue, "retained"); - expect(retained).toMatchObject({ - outcome: "success", - success: "visible-result", - }); - expect(yield* getLists(retainedQueue)).toMatchObject({ - active: [], - wait: [], - }); - - yield* succeedNext(engine, holderQueue); - expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("mark policy indexes a held terminal task immediately", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const holderQueue = "retention-mark-holder"; - const retainedQueue = "retention-mark-task"; - yield* engine.createTask(baseTask(holderQueue, "holder")); - yield* engine.createTask({ - ...baseTask(retainedQueue, "retained", { - onSuccessPolicy: "mark-as-success", +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "TaskEngine result-retention relationships (real Redis time)", + (it) => { + it.effect( + "replaying the same holder/retained generation creates one hold", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const holderQueue = "retention-replay-holder"; + const retainedQueue = "retention-replay-task"; + yield* engine.createTask(baseTask(holderQueue, "holder")); + const insert = { + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity(holderQueue, "holder"), + }; + + yield* engine.offerTask(insert); + const replay = yield* engine.offerTask(insert); + expect(replay.status).toBe("existing"); + + const keys = relationKeys( + holderQueue, + "holder", + retainedQueue, + "retained", + ); + expect(yield* redis.send("SCARD", keys.holders)).toBe(1); + expect(yield* redis.send("SCARD", keys.retains)).toBe(1); + }), + ); + + it.effect( + "independent live holders can retain the same existing generation", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const retainedQueue = "retention-multiple-task"; + yield* engine.createTask(baseTask("retention-holder-a", "a")); + yield* engine.createTask(baseTask("retention-holder-b", "b")); + yield* engine.offerTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity("retention-holder-a", "a"), + }); + const existing = yield* engine.offerTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity("retention-holder-b", "b"), + }); + + expect(existing.status).toBe("existing"); + const holdersKey = `~effectmq:v1:${retainedQueue}:task:retained:1:retained-by`; + expect(yield* redis.send("SCARD", holdersKey)).toBe(2); + + yield* succeedNext(engine, retainedQueue, "result"); + expect( + (yield* engine.getTask(retainedQueue, "retained"))?.outcome, + ).toBe("success"); + + yield* succeedNext(engine, "retention-holder-a"); + expect( + yield* engine.getTask(retainedQueue, "retained"), + ).not.toBeNull(); + expect(yield* redis.send("SCARD", holdersKey)).toBe(1); + + yield* succeedNext(engine, "retention-holder-b"); + expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); }), - retentionHolder: identity(holderQueue, "holder"), - }); - - yield* succeedNext(engine, retainedQueue); - expect((yield* getLists(retainedQueue)).success).toEqual(["retained"]); - expect(yield* engine.getTask(retainedQueue, "retained")).not.toBeNull(); - - yield* succeedNext(engine, holderQueue); - expect((yield* getLists(retainedQueue)).success).toEqual(["retained"]); - expect(yield* engine.getTask(retainedQueue, "retained")).not.toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("ordinary removal rejects holds while force removal is explicit", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const holderQueue = "retention-force-holder"; - const retainedQueue = "retention-force-task"; - yield* engine.createTask(baseTask(holderQueue, "holder")); - yield* engine.createTask({ - ...baseTask(retainedQueue, "retained"), - retentionHolder: identity(holderQueue, "holder"), - }); - - const error = yield* engine - .removeTask(retainedQueue, "retained") - .pipe(Effect.flip); - expect(String((error.cause as { cause: unknown }).cause)).toContain( - "active retention holds", - ); - - yield* engine.forceRemoveTask(retainedQueue, "retained"); - expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); - yield* engine.removeTask(holderQueue, "holder"); - }).pipe(TestRuntime.runPromise)); - - test("large holder release leaves and drains a durable bounded continuation", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.make({ - debugMode: true, - maintenanceBatchSize: 64, - }); - const redis = yield* RedisPool.RedisPool; - const prefix = "retention-bounded"; - yield* engine.createTask(baseTask(prefix, "holder")); - for (let index = 0; index < 65; index++) { + ); + + it.effect( + "a settled holder is rejected without creating the retained task", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const holderQueue = "retention-settled-holder"; + const retainedQueue = "retention-settled-task"; + yield* engine.createTask( + baseTask(holderQueue, "holder", { onSuccessPolicy: "keep" }), + ); + yield* succeedNext(engine, holderQueue); + + const error = yield* engine + .offerTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity(holderQueue, "holder"), + }) + .pipe(Effect.flip); + + expect(String((error.cause as { cause: unknown }).cause)).toContain( + "retention holder is settled", + ); + expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); + }), + ); + + it.effect( + "holder removal releases its hold without affecting runnable work", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const holderQueue = "retention-remove-holder"; + const retainedQueue = "retention-remove-task"; + yield* engine.createTask(baseTask(holderQueue, "holder")); + yield* engine.createTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity(holderQueue, "holder"), + }); + + yield* engine.removeTask(holderQueue, "holder"); + const holdersKey = `~effectmq:v1:${retainedQueue}:task:retained:1:retained-by`; + expect(yield* redis.send("SCARD", holdersKey)).toBe(0); + expect((yield* getLists(retainedQueue)).wait).toEqual(["retained"]); + }), + ); + + it.effect( + "terminal settlement is visible immediately while delete waits for holds", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const holderQueue = "retention-visible-holder"; + const retainedQueue = "retention-visible-task"; + yield* engine.createTask(baseTask(holderQueue, "holder")); + yield* engine.createTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity(holderQueue, "holder"), + }); + + yield* succeedNext(engine, retainedQueue, "visible-result"); + const retained = yield* engine.getTask(retainedQueue, "retained"); + expect(retained).toMatchObject({ + outcome: "success", + success: "visible-result", + }); + expect(yield* getLists(retainedQueue)).toMatchObject({ + active: [], + wait: [], + }); + + yield* succeedNext(engine, holderQueue); + expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); + }), + ); + + it.effect("mark policy indexes a held terminal task immediately", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const holderQueue = "retention-mark-holder"; + const retainedQueue = "retention-mark-task"; + yield* engine.createTask(baseTask(holderQueue, "holder")); yield* engine.createTask({ - ...baseTask(prefix, `retained-${index}`), - retentionHolder: identity(prefix, "holder"), + ...baseTask(retainedQueue, "retained", { + onSuccessPolicy: "mark-as-success", + }), + retentionHolder: identity(holderQueue, "holder"), }); - } - - yield* engine.removeTask(prefix, "holder"); - const continuationKey = `~effectmq:v1:${prefix}:retention-release-continuations`; - expect(yield* redis.send("ZCARD", continuationKey)).toBe(1); - - // Any subsequent maintenance-bearing operation drains another bounded - // batch, even though the holder record itself has already been removed. - yield* engine.listTasks(prefix, "wait"); - expect(yield* redis.send("ZCARD", continuationKey)).toBe(0); - expect( - yield* redis.send( - "SCARD", - `~effectmq:v1:${prefix}:task:retained-64:1:retained-by`, - ), - ).toBe(0); - }).pipe(TestRuntime.runPromise)); -}); -describe("TaskEngine creator provenance", () => { - test("creator identity is immutable metadata and survives creator removal", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const prefix = "creator-provenance"; - yield* engine.createTask(baseTask(prefix, "creator")); - const spawned = yield* engine.createTask({ - ...baseTask(prefix, "spawned"), - creator: identity(prefix, "creator"), - }); - - expect(spawned.creator).toEqual({ - queue: `~effectmq:v1:${prefix}`, - id: "creator", - generation: 1, - }); - yield* engine.removeTask(prefix, "creator"); - expect((yield* engine.getTask(prefix, "spawned"))?.creator).toEqual( - spawned.creator, - ); - expect((yield* getLists(prefix)).wait).toEqual(["spawned"]); - }).pipe(TestRuntime.runPromise)); -}); + yield* succeedNext(engine, retainedQueue); + expect((yield* getLists(retainedQueue)).success).toEqual(["retained"]); + expect(yield* engine.getTask(retainedQueue, "retained")).not.toBeNull(); + + yield* succeedNext(engine, holderQueue); + expect((yield* getLists(retainedQueue)).success).toEqual(["retained"]); + expect(yield* engine.getTask(retainedQueue, "retained")).not.toBeNull(); + }), + ); + + it.effect( + "ordinary removal rejects holds while force removal is explicit", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const holderQueue = "retention-force-holder"; + const retainedQueue = "retention-force-task"; + yield* engine.createTask(baseTask(holderQueue, "holder")); + yield* engine.createTask({ + ...baseTask(retainedQueue, "retained"), + retentionHolder: identity(holderQueue, "holder"), + }); + + const error = yield* engine + .removeTask(retainedQueue, "retained") + .pipe(Effect.flip); + expect(String((error.cause as { cause: unknown }).cause)).toContain( + "active retention holds", + ); + + yield* engine.forceRemoveTask(retainedQueue, "retained"); + expect(yield* engine.getTask(retainedQueue, "retained")).toBeNull(); + yield* engine.removeTask(holderQueue, "holder"); + }), + ); + + it.effect( + "large holder release leaves and drains a durable bounded continuation", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.make({ + debugMode: true, + maintenanceBatchSize: 64, + }); + const redis = yield* RedisPool.RedisPool; + const prefix = "retention-bounded"; + yield* engine.createTask(baseTask(prefix, "holder")); + for (let index = 0; index < 65; index++) { + yield* engine.createTask({ + ...baseTask(prefix, `retained-${index}`), + retentionHolder: identity(prefix, "holder"), + }); + } + + yield* engine.removeTask(prefix, "holder"); + const continuationKey = `~effectmq:v1:${prefix}:retention-release-continuations`; + expect(yield* redis.send("ZCARD", continuationKey)).toBe(1); + + // Any subsequent maintenance-bearing operation drains another bounded + // batch, even though the holder record itself has already been removed. + yield* engine.listTasks(prefix, "wait"); + expect(yield* redis.send("ZCARD", continuationKey)).toBe(0); + expect( + yield* redis.send( + "SCARD", + `~effectmq:v1:${prefix}:task:retained-64:1:retained-by`, + ), + ).toBe(0); + }), + ); + }, +); + +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "TaskEngine creator provenance (real Redis time)", + (it) => { + it.effect( + "creator identity is immutable metadata and survives creator removal", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const prefix = "creator-provenance"; + yield* engine.createTask(baseTask(prefix, "creator")); + const spawned = yield* engine.createTask({ + ...baseTask(prefix, "spawned"), + creator: identity(prefix, "creator"), + }); + + expect(spawned.creator).toEqual({ + queue: `~effectmq:v1:${prefix}`, + id: "creator", + generation: 1, + }); + yield* engine.removeTask(prefix, "creator"); + expect((yield* engine.getTask(prefix, "spawned"))?.creator).toEqual( + spawned.creator, + ); + expect((yield* getLists(prefix)).wait).toEqual(["spawned"]); + }), + ); + }, +); diff --git a/src/TaskEngine.replies.test.ts b/src/TaskEngine.replies.test.ts new file mode 100644 index 0000000..052da79 --- /dev/null +++ b/src/TaskEngine.replies.test.ts @@ -0,0 +1,113 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import type * as RedisPool from "./RedisPool.js"; +import * as TaskEngine from "./TaskEngine.js"; + +const service = (options: { + readonly scriptReply?: unknown; + readonly binaryReply?: unknown; +}): RedisPool.RedisPoolService => ({ + send: () => Effect.succeed(options.scriptReply as A), + sendBinary: () => Effect.succeed(options.binaryReply as A), + evalScript: () => Effect.succeed(options.scriptReply as A), +}); + +it.effect("malformed scalar and collection replies fail semantically", () => + Effect.gen(function* () { + const scalarEngine = yield* TaskEngine.makeWithRedis( + service({ scriptReply: { generation: 1 } }), + ); + const scalarError = yield* scalarEngine + .getGeneration("queue", "task") + .pipe(Effect.flip); + expect(scalarError.reason).toMatchObject({ + _tag: "InvalidReply", + operation: "effectmq_getGeneration", + }); + + const collectionEngine = yield* TaskEngine.makeWithRedis( + service({ scriptReply: { cursor: "not-a-tuple" } }), + ); + const collectionError = yield* collectionEngine + .listTasks("queue", "wait") + .pipe(Effect.flip); + expect(collectionError.reason).toMatchObject({ + _tag: "InvalidReply", + operation: "effectmq_listTasks", + }); + }), +); + +const eventFields = [ + "taskId", + "task", + "generation", + "1", + "protocolVersion", + "1", + "schemaId", + "schema", + "_tag", + "task.completed", + "policy", + "keep", + "__proto__", + "polluted", + "constructor", + "constructor-value", + "prototype", + "prototype-value", +] as const; + +const streamReply = (representation: "array" | "map") => { + const entries = [["1-0", eventFields]]; + return representation === "map" + ? new Map([["events", entries]]) + : [["events", entries]]; +}; + +for (const representation of ["array", "map"] as const) { + it.effect( + `validates ${representation} RESP stream replies without prototype mutation`, + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.makeWithRedis( + service({ + scriptReply: ["0-0", "0-0", "0-0"], + binaryReply: streamReply(representation), + }), + ); + const event = yield* engine + .stream("queue", { cursor: "0-0" }) + .pipe(Stream.runHead); + expect(Option.isSome(event)).toBe(true); + if (Option.isSome(event)) + expect(event.value._tag).toBe("task.completed"); + expect( + (Object.prototype as Record).polluted, + ).toBeUndefined(); + }), + ); +} + +it.effect("rejects odd stream field arrays", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.makeWithRedis( + service({ + scriptReply: ["0-0", "0-0", "0-0"], + binaryReply: [["events", [["1-0", ["taskId"]]]]], + }), + ); + const error = yield* engine + .stream("queue", { cursor: "0-0" }) + .pipe(Stream.runHead, Effect.flip); + expect(error._tag).toBe("TaskEngineError"); + if (error._tag !== "TaskEngineError") return; + expect(error.reason).toMatchObject({ + _tag: "InvalidReply", + operation: "xread.fields", + }); + }), +); diff --git a/src/TaskEngine.test.ts b/src/TaskEngine.test.ts index 5530ae5..8697042 100644 --- a/src/TaskEngine.test.ts +++ b/src/TaskEngine.test.ts @@ -1,8 +1,8 @@ import { Effect, Metric } from "effect"; import { Packr } from "msgpackr"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { Observability, RedisPool, TaskEngine } from "./index.js"; -import { getLists, TestRuntime } from "./testing/redisLayer.js"; +import { getLists, TestLayer } from "./testing/redisLayer.js"; import { extendLock, takeTask, @@ -21,419 +21,461 @@ const canceled = (timestamp: number) => ({ timestamp, }); -describe("TaskEngine", () => { - test("rejects maintenance batches above the supported atomic bound", () => - Effect.gen(function* () { - const exit = yield* Effect.exit( - TaskEngine.make({ maintenanceBatchSize: 1_001 }), - ); - expect(exit._tag).toBe("Failure"); - }).pipe(TestRuntime.runPromise)); - - test("task inspection is capped, cursor-paginated, and ordered", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const prefix = "paginated-inspection"; - yield* TaskEngine.setMockTime(500_000); - for (const id of ["first", "second", "third", "fourth", "fifth"]) { - yield* engine.createTask({ - prefix, - id, - name: "inspection", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "TaskEngine (real Redis time)", + (it) => { + it.effect( + "rejects maintenance batches above the supported atomic bound", + () => + Effect.gen(function* () { + const error = yield* TaskEngine.make({ + maintenanceBatchSize: 1_001, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TaskEngineConfigurationError", + field: "maintenanceBatchSize", + actual: 1_001, + }); + }), + ); + + it.effect("task inspection is capped, cursor-paginated, and ordered", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const prefix = "paginated-inspection"; + yield* TaskEngine.setMockTime(500_000); + for (const id of ["first", "second", "third", "fourth", "fifth"]) { + yield* engine.createTask({ + prefix, + id, + name: "inspection", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + } + + const first = yield* engine.listTasks(prefix, "wait", { limit: 2 }); + const second = yield* engine.listTasks(prefix, "wait", { + cursor: first.nextCursor, + limit: 2, }); - } - - const first = yield* engine.listTasks(prefix, "wait", { limit: 2 }); - const second = yield* engine.listTasks(prefix, "wait", { - cursor: first.nextCursor, - limit: 2, - }); - const third = yield* engine.listTasks(prefix, "wait", { - cursor: second.nextCursor, - limit: 2, - }); - expect(first).toEqual({ items: ["first", "second"], nextCursor: "2" }); - expect(second).toEqual({ items: ["third", "fourth"], nextCursor: "4" }); - expect(third).toEqual({ items: ["fifth"], nextCursor: undefined }); - - for (const [id, delay] of [ - ["late", 200], - ["zeta", 100], - ["alpha", 100], - ] as const) { - yield* engine.createTask({ - prefix, - id, - name: "scheduled inspection", - payload: null, - delay, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", + const third = yield* engine.listTasks(prefix, "wait", { + cursor: second.nextCursor, + limit: 2, }); - } - expect( - (yield* engine.listTasks(prefix, "scheduled", { limit: 10 })).items, - ).toEqual(["alpha", "zeta", "late"]); - - const invalid = yield* engine - .listTasks(prefix, "wait", { limit: 1_001 }) - .pipe(Effect.flip); - expect(invalid).toMatchObject({ - _tag: "TaskEngineError", - message: "Invalid task-list page", - }); - }).pipe(TestRuntime.runPromise)); - - test("one maintenance pass promotes at most the configured due batch", () => - Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const engine = yield* TaskEngine.make({ - debugMode: true, - maintenanceBatchSize: 2, - }); - const prefix = "bounded-delayed"; - yield* TaskEngine.setMockTime(1_000_000); - for (const id of ["one", "two", "three"]) { - yield* engine.createTask({ - prefix, - id, - name: "bounded delayed", + expect(first).toEqual({ items: ["first", "second"], nextCursor: "2" }); + expect(second).toEqual({ items: ["third", "fourth"], nextCursor: "4" }); + expect(third).toEqual({ items: ["fifth"], nextCursor: undefined }); + + for (const [id, delay] of [ + ["late", 200], + ["zeta", 100], + ["alpha", 100], + ] as const) { + yield* engine.createTask({ + prefix, + id, + name: "scheduled inspection", + payload: null, + delay, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + } + expect( + (yield* engine.listTasks(prefix, "scheduled", { limit: 10 })).items, + ).toEqual(["alpha", "zeta", "late"]); + + const invalid = yield* engine + .listTasks(prefix, "wait", { limit: 1_001 }) + .pipe(Effect.flip); + expect(invalid).toMatchObject({ + _tag: "TaskEngineError", + reason: { + _tag: "InvalidReply", + operation: "listTasks", + }, + }); + }), + ); + + it.effect( + "one maintenance pass promotes at most the configured due batch", + () => + Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const engine = yield* TaskEngine.make({ + debugMode: true, + maintenanceBatchSize: 2, + }); + const prefix = "bounded-delayed"; + yield* TaskEngine.setMockTime(1_000_000); + for (const id of ["one", "two", "three"]) { + yield* engine.createTask({ + prefix, + id, + name: "bounded delayed", + payload: null, + delay: 1_000, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + } + yield* TaskEngine.stepMockTime(1_050); + const health = yield* engine.maintain(prefix); + + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:scheduled`), + ).toBe(1); + expect(yield* redis.send("LLEN", `~effectmq:v1:${prefix}:wait`)).toBe( + 2, + ); + expect(health).toMatchObject({ + depth: 3, + dueBacklog: 1, + oldestTaskAgeMs: 1_050, + sweepLagMs: 50, + }); + expect( + yield* Metric.value( + Metric.withAttributes(Observability.dueBacklog, { + queue: prefix, + }), + ), + ).toMatchObject({ value: 1 }); + }), + ); + + it.effect( + "terminal records, results, indexes, and dead letters expire independently", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const prefix = "retention-windows"; + yield* TaskEngine.setMockTime(5_000_000); + yield* engine.createTask({ + prefix, + id: "success", + name: "retained success", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "mark-as-success", + onFailurePolicy: "delete", + taskRecordRetentionMs: 300, + resultRetentionMs: 100, + terminalIndexRetentionMs: 200, + deadLetterRetentionMs: 100, + }); + const successAttempt = yield* takeTask(engine, prefix, 30_000); + yield* writeSuccess(engine, prefix, successAttempt?.id ?? "", "ok"); + + expect(yield* engine.getResult(prefix, "success", 1)).toMatchObject({ + generation: 1, + outcome: "success", + success: "ok", + }); + expect((yield* getLists(prefix)).success).toEqual(["success"]); + + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(prefix); + expect(yield* engine.getResult(prefix, "success", 1)).toBeNull(); + expect(yield* engine.getTask(prefix, "success")).not.toBeNull(); + expect((yield* getLists(prefix)).success).toEqual(["success"]); + + yield* TaskEngine.stepMockTime(100); + yield* engine.maintain(prefix); + expect((yield* getLists(prefix)).success).toEqual([]); + expect(yield* engine.getTask(prefix, "success")).not.toBeNull(); + + yield* TaskEngine.stepMockTime(100); + yield* engine.maintain(prefix); + expect(yield* engine.getTask(prefix, "success")).toBeNull(); + + yield* engine.createTask({ + prefix, + id: "failure", + name: "retained failure", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "delete", + onFailurePolicy: "keep", + taskRecordRetentionMs: 1_000, + resultRetentionMs: 1_000, + terminalIndexRetentionMs: 1_000, + deadLetterRetentionMs: 100, + }); + yield* takeTask(engine, prefix, 30_000); + yield* writeError(engine, prefix, "failure", stalled(5_000_301)); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:dead-letter`), + ).toBe(1); + expect(yield* engine.getResult(prefix, "failure", 1)).toMatchObject({ + outcome: "failure", + failure: stalled(5_000_301), + }); + + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(prefix); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:dead-letter`), + ).toBe(0); + expect(yield* engine.getResult(prefix, "failure", 1)).not.toBeNull(); + }), + ); + + it.effect( + "retention cleanup leaves bounded due work for later sweeps", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.make({ + debugMode: true, + maintenanceBatchSize: 2, + }); + const redis = yield* RedisPool.RedisPool; + const prefix = "bounded-retention-cleanup"; + yield* TaskEngine.setMockTime(6_000_000); + for (const id of ["one", "two", "three"]) { + yield* engine.createTask({ + prefix, + id, + name: "bounded retention", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "mark-as-success", + onFailurePolicy: "delete", + taskRecordRetentionMs: 100, + resultRetentionMs: 100, + terminalIndexRetentionMs: 100, + deadLetterRetentionMs: 100, + }); + yield* takeTask(engine, prefix, 30_000); + yield* writeSuccess(engine, prefix, id, id); + } + + yield* TaskEngine.stepMockTime(101); + let sawRetentionContinuation = false; + for (let sweep = 0; sweep < 24; sweep++) { + const health = yield* engine.maintain(prefix); + expect(health.processed).toBeLessThanOrEqual(2); + if (health.retentionBacklog > 0) sawRetentionContinuation = true; + } + expect(sawRetentionContinuation).toBe(true); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:expiry:tasks`), + ).toBe(0); + expect( + yield* redis.send( + "ZCARD", + `~effectmq:v1:${prefix}:expiry:terminal-indexes`, + ), + ).toBe(0); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:success`), + ).toBe(0); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:expiry:results`), + ).toBe(0); + + const deadPrefix = "bounded-dead-letter-cleanup"; + for (const id of ["one", "two", "three"]) { + yield* engine.createTask({ + prefix: deadPrefix, + id, + name: "bounded dead letter", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "delete", + onFailurePolicy: "keep", + taskRecordRetentionMs: 1_000, + resultRetentionMs: 1_000, + terminalIndexRetentionMs: 1_000, + deadLetterRetentionMs: 100, + }); + yield* takeTask(engine, deadPrefix, 30_000); + yield* writeError(engine, deadPrefix, id, stalled(6_000_301)); + } + yield* TaskEngine.stepMockTime(101); + let sawDeadLetterContinuation = false; + for (let sweep = 0; sweep < 15; sweep++) { + const health = yield* engine.maintain(deadPrefix); + expect(health.processed).toBeLessThanOrEqual(2); + if (health.retentionBacklog > 0) sawDeadLetterContinuation = true; + } + expect(sawDeadLetterContinuation).toBe(true); + expect( + yield* redis.send( + "ZCARD", + `~effectmq:v1:${deadPrefix}:dead-letter`, + ), + ).toBe(0); + expect( + yield* redis.send( + "ZCARD", + `~effectmq:v1:${deadPrefix}:expiry:dead-letter`, + ), + ).toBe(0); + }), + ); + + it.effect( + "corrupt non-list error history fails instead of normalizing to empty", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const prefix = "corrupt-error-history"; + yield* engine.createTask({ + prefix, + id: "corrupt", + name: "corrupt task", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + }); + const packr = new Packr({ useRecords: false }); + yield* redis.send( + "HSET", + `~effectmq:v1:${prefix}:task:corrupt`, + "errors", + packr.pack({ not: "a list" }), + ); + + const error = yield* engine + .getTask(prefix, "corrupt") + .pipe(Effect.flip); + expect(error._tag).toBe("TaskEngineError"); + expect(error.reason).toMatchObject({ + _tag: "InvalidReply", + operation: "decodeTask", + }); + }), + ); + it.effect("cached scripts recover independently after SCRIPT FLUSH", () => + Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const reloadsBefore = (yield* Metric.value(Observability.scriptReloads)) + .count; + const versionA = 'return "engine-a:" .. ARGV[1]'; + const versionB = 'return "engine-b:" .. ARGV[1]'; + + expect(yield* redis.evalScript(versionA, {}, "first")).toBe( + "engine-a:first", + ); + expect(yield* redis.evalScript(versionB, {}, "first")).toBe( + "engine-b:first", + ); + + yield* redis.send("SCRIPT", "FLUSH"); + + // Both calls begin with a locally cached digest. Each source must catch + // its own NOSCRIPT, reload its own exact content, and retry once. + expect(yield* redis.evalScript(versionB, {}, "after-flush")).toBe( + "engine-b:after-flush", + ); + expect(yield* redis.evalScript(versionA, {}, "after-flush")).toBe( + "engine-a:after-flush", + ); + expect((yield* Metric.value(Observability.scriptReloads)).count).toBe( + reloadsBefore + 2, + ); + }), + ); + + it.effect( + "re-offering a waiting task preserves exactly one wait membership", + () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const prefix = "same-state-wait"; + const task = { + id: "waiting-1", + name: "waiting task", + payload: "first", + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep" as const, + onFailurePolicy: "keep" as const, + prefix, + }; + + yield* taskEngine.createTask(task); + yield* taskEngine.createTask({ ...task, payload: "replayed" }); + + expect((yield* getLists(prefix)).wait).toEqual([task.id]); + }), + ); + + it.effect("renewing a lease preserves exactly one active membership", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const prefix = "same-state-active"; + yield* taskEngine.createTask({ + id: "active-1", + name: "active task", payload: null, - delay: 1_000, + delay: 0, maxRetries: 0, onSuccessPolicy: "keep", onFailurePolicy: "keep", - }); - } - yield* TaskEngine.stepMockTime(1_050); - const health = yield* engine.maintain(prefix); - - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:scheduled`), - ).toBe(1); - expect(yield* redis.send("LLEN", `~effectmq:v1:${prefix}:wait`)).toBe(2); - expect(health).toMatchObject({ - depth: 3, - dueBacklog: 1, - oldestTaskAgeMs: 1_050, - sweepLagMs: 50, - }); - expect( - yield* Metric.value( - Metric.withAttributes(Observability.dueBacklog, { queue: prefix }), - ), - ).toMatchObject({ value: 1 }); - }).pipe(TestRuntime.runPromise)); - - test("terminal records, results, indexes, and dead letters expire independently", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const prefix = "retention-windows"; - yield* TaskEngine.setMockTime(5_000_000); - yield* engine.createTask({ - prefix, - id: "success", - name: "retained success", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "mark-as-success", - onFailurePolicy: "delete", - taskRecordRetentionMs: 300, - resultRetentionMs: 100, - terminalIndexRetentionMs: 200, - deadLetterRetentionMs: 100, - }); - const successAttempt = yield* takeTask(engine, prefix, 30_000); - yield* writeSuccess(engine, prefix, successAttempt?.id ?? "", "ok"); - - expect(yield* engine.getResult(prefix, "success", 1)).toMatchObject({ - generation: 1, - outcome: "success", - success: "ok", - }); - expect((yield* getLists(prefix)).success).toEqual(["success"]); - - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(prefix); - expect(yield* engine.getResult(prefix, "success", 1)).toBeNull(); - expect(yield* engine.getTask(prefix, "success")).not.toBeNull(); - expect((yield* getLists(prefix)).success).toEqual(["success"]); - - yield* TaskEngine.stepMockTime(100); - yield* engine.maintain(prefix); - expect((yield* getLists(prefix)).success).toEqual([]); - expect(yield* engine.getTask(prefix, "success")).not.toBeNull(); - - yield* TaskEngine.stepMockTime(100); - yield* engine.maintain(prefix); - expect(yield* engine.getTask(prefix, "success")).toBeNull(); - - yield* engine.createTask({ - prefix, - id: "failure", - name: "retained failure", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "delete", - onFailurePolicy: "keep", - taskRecordRetentionMs: 1_000, - resultRetentionMs: 1_000, - terminalIndexRetentionMs: 1_000, - deadLetterRetentionMs: 100, - }); - yield* takeTask(engine, prefix, 30_000); - yield* writeError(engine, prefix, "failure", stalled(5_000_301)); - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:dead-letter`), - ).toBe(1); - expect(yield* engine.getResult(prefix, "failure", 1)).toMatchObject({ - outcome: "failure", - failure: stalled(5_000_301), - }); - - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(prefix); - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:dead-letter`), - ).toBe(0); - expect(yield* engine.getResult(prefix, "failure", 1)).not.toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("retention cleanup leaves bounded due work for later sweeps", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.make({ - debugMode: true, - maintenanceBatchSize: 2, - }); - const redis = yield* RedisPool.RedisPool; - const prefix = "bounded-retention-cleanup"; - yield* TaskEngine.setMockTime(6_000_000); - for (const id of ["one", "two", "three"]) { - yield* engine.createTask({ prefix, - id, - name: "bounded retention", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "mark-as-success", - onFailurePolicy: "delete", - taskRecordRetentionMs: 100, - resultRetentionMs: 100, - terminalIndexRetentionMs: 100, - deadLetterRetentionMs: 100, }); - yield* takeTask(engine, prefix, 30_000); - yield* writeSuccess(engine, prefix, id, id); - } - - yield* TaskEngine.stepMockTime(101); - let sawRetentionContinuation = false; - for (let sweep = 0; sweep < 24; sweep++) { - const health = yield* engine.maintain(prefix); - expect(health.processed).toBeLessThanOrEqual(2); - if (health.retentionBacklog > 0) sawRetentionContinuation = true; - } - expect(sawRetentionContinuation).toBe(true); - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:expiry:tasks`), - ).toBe(0); - expect( + yield* takeTask(taskEngine, prefix, 30_000); + + // Simulate a partially corrupted pre-fix record. Any transition must + // repair cross-state membership rather than preserving extra indexes. + const keyPrefix = `~effectmq:v1:${prefix}`; + yield* redis.send("RPUSH", `${keyPrefix}:wait`, "active-1"); yield* redis.send( - "ZCARD", - `~effectmq:v1:${prefix}:expiry:terminal-indexes`, - ), - ).toBe(0); - expect(yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:success`)).toBe( - 0, - ); - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:expiry:results`), - ).toBe(0); - - const deadPrefix = "bounded-dead-letter-cleanup"; - for (const id of ["one", "two", "three"]) { - yield* engine.createTask({ - prefix: deadPrefix, - id, - name: "bounded dead letter", - payload: null, + "ZADD", + `${keyPrefix}:scheduled`, + String(Number.MAX_SAFE_INTEGER), + "active-1", + ); + yield* redis.send("ZADD", `${keyPrefix}:failed`, "1", "active-1"); + yield* redis.send("ZADD", `${keyPrefix}:success`, "1", "active-1"); + + yield* extendLock(taskEngine, prefix, "active-1", 30_000); + + expect(yield* getLists(prefix)).toEqual({ + active: ["active-1"], + failed: [], + scheduled: [], + success: [], + wait: [], + }); + }), + ); + + it.effect("success happy path with delete on success policy", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "task-engine-test"; + const task = yield* taskEngine.createTask({ + id: "123", + name: "task name", + payload: "task payload", delay: 0, maxRetries: 0, onSuccessPolicy: "delete", - onFailurePolicy: "keep", - taskRecordRetentionMs: 1_000, - resultRetentionMs: 1_000, - terminalIndexRetentionMs: 1_000, - deadLetterRetentionMs: 100, + onFailurePolicy: "delete", + prefix, }); - yield* takeTask(engine, deadPrefix, 30_000); - yield* writeError(engine, deadPrefix, id, stalled(6_000_301)); - } - yield* TaskEngine.stepMockTime(101); - let sawDeadLetterContinuation = false; - for (let sweep = 0; sweep < 15; sweep++) { - const health = yield* engine.maintain(deadPrefix); - expect(health.processed).toBeLessThanOrEqual(2); - if (health.retentionBacklog > 0) sawDeadLetterContinuation = true; - } - expect(sawDeadLetterContinuation).toBe(true); - expect( - yield* redis.send("ZCARD", `~effectmq:v1:${deadPrefix}:dead-letter`), - ).toBe(0); - expect( - yield* redis.send( - "ZCARD", - `~effectmq:v1:${deadPrefix}:expiry:dead-letter`, - ), - ).toBe(0); - }).pipe(TestRuntime.runPromise)); - - test("corrupt non-list error history fails instead of normalizing to empty", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const prefix = "corrupt-error-history"; - yield* engine.createTask({ - prefix, - id: "corrupt", - name: "corrupt task", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - }); - const packr = new Packr({ useRecords: false }); - yield* redis.send( - "HSET", - `~effectmq:v1:${prefix}:task:corrupt`, - "errors", - packr.pack({ not: "a list" }), - ); - - const error = yield* engine.getTask(prefix, "corrupt").pipe(Effect.flip); - expect(error._tag).toBe("TaskEngineError"); - expect(error.message).toBe("Failed to decode task"); - }).pipe(TestRuntime.runPromise)); - test("cached scripts recover independently after SCRIPT FLUSH", () => - Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const reloadsBefore = (yield* Metric.value(Observability.scriptReloads)) - .count; - const versionA = 'return "engine-a:" .. ARGV[1]'; - const versionB = 'return "engine-b:" .. ARGV[1]'; - - expect(yield* redis.evalScript(versionA, {}, "first")).toBe( - "engine-a:first", - ); - expect(yield* redis.evalScript(versionB, {}, "first")).toBe( - "engine-b:first", - ); - - yield* redis.send("SCRIPT", "FLUSH"); - - // Both calls begin with a locally cached digest. Each source must catch - // its own NOSCRIPT, reload its own exact content, and retry once. - expect(yield* redis.evalScript(versionB, {}, "after-flush")).toBe( - "engine-b:after-flush", - ); - expect(yield* redis.evalScript(versionA, {}, "after-flush")).toBe( - "engine-a:after-flush", - ); - expect((yield* Metric.value(Observability.scriptReloads)).count).toBe( - reloadsBefore + 2, - ); - }).pipe(TestRuntime.runPromise)); - - test("re-offering a waiting task preserves exactly one wait membership", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const prefix = "same-state-wait"; - const task = { - id: "waiting-1", - name: "waiting task", - payload: "first", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep" as const, - onFailurePolicy: "keep" as const, - prefix, - }; - - yield* taskEngine.createTask(task); - yield* taskEngine.createTask({ ...task, payload: "replayed" }); - - expect((yield* getLists(prefix)).wait).toEqual([task.id]); - }).pipe(TestRuntime.runPromise)); - - test("renewing a lease preserves exactly one active membership", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const prefix = "same-state-active"; - yield* taskEngine.createTask({ - id: "active-1", - name: "active task", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - prefix, - }); - yield* takeTask(taskEngine, prefix, 30_000); - - // Simulate a partially corrupted pre-fix record. Any transition must - // repair cross-state membership rather than preserving extra indexes. - const keyPrefix = `~effectmq:v1:${prefix}`; - yield* redis.send("RPUSH", `${keyPrefix}:wait`, "active-1"); - yield* redis.send( - "ZADD", - `${keyPrefix}:scheduled`, - String(Number.MAX_SAFE_INTEGER), - "active-1", - ); - yield* redis.send("ZADD", `${keyPrefix}:failed`, "1", "active-1"); - yield* redis.send("ZADD", `${keyPrefix}:success`, "1", "active-1"); - - yield* extendLock(taskEngine, prefix, "active-1", 30_000); - - expect(yield* getLists(prefix)).toEqual({ - active: ["active-1"], - failed: [], - scheduled: [], - success: [], - wait: [], - }); - }).pipe(TestRuntime.runPromise)); - - test("success happy path with delete on success policy", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "task-engine-test"; - const task = yield* taskEngine.createTask({ - id: "123", - name: "task name", - payload: "task payload", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "delete", - onFailurePolicy: "delete", - prefix, - }); - - expect(task).toMatchInlineSnapshot(` + + expect(task).toMatchInlineSnapshot(` { "attempt": 0, "createdAt": 2001-09-09T01:46:40.000Z, @@ -463,7 +505,7 @@ describe("TaskEngine", () => { } `); - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -475,18 +517,18 @@ describe("TaskEngine", () => { } `); - yield* taskEngine.createTask({ - id: "456", - name: "task name 2", - payload: "task payload 2", - delay: 1000, - maxRetries: 0, - onSuccessPolicy: "delete", - onFailurePolicy: "delete", - prefix, - }); - - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + yield* taskEngine.createTask({ + id: "456", + name: "task name 2", + payload: "task payload 2", + delay: 1000, + maxRetries: 0, + onSuccessPolicy: "delete", + onFailurePolicy: "delete", + prefix, + }); + + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -500,8 +542,8 @@ describe("TaskEngine", () => { } `); - yield* TaskEngine.stepMockTime(999); - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + yield* TaskEngine.stepMockTime(999); + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -514,8 +556,8 @@ describe("TaskEngine", () => { ], } `); - yield* TaskEngine.stepMockTime(1); - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + yield* TaskEngine.stepMockTime(1); + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -528,8 +570,8 @@ describe("TaskEngine", () => { } `); - const taken = yield* takeTask(taskEngine, prefix, 30000); - expect(taken).toMatchInlineSnapshot(` + const taken = yield* takeTask(taskEngine, prefix, 30000); + expect(taken).toMatchInlineSnapshot(` { "attempt": 1, "createdAt": 2001-09-09T01:46:40.000Z, @@ -559,7 +601,7 @@ describe("TaskEngine", () => { } `); - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [ "123", @@ -573,8 +615,8 @@ describe("TaskEngine", () => { } `); - yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "success"); - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "success"); + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -585,164 +627,182 @@ describe("TaskEngine", () => { ], } `); - }).pipe(TestRuntime.runPromise)); - - // The engine routes purely on the `retryAt` it is handed: the retry/cap - // decision lives in TaskQueue.fail. These cover the routing contract. - test("writeError with a future retryAt schedules the task", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const now = 1000000000000; - yield* TaskEngine.setMockTime(now); - const prefix = "retry-scheduled"; - yield* taskEngine.createTask({ - id: "r1", - name: "t", - payload: "p", - delay: 0, - maxRetries: 5, - onSuccessPolicy: "delete", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "r1", stalled(1), now + 5000); - - const lists = yield* getLists(prefix); - expect(lists.scheduled).toEqual(["r1"]); - expect(lists.wait).toEqual([]); - expect(lists.failed).toEqual([]); - const task = yield* taskEngine.getTask(prefix, "r1"); - expect(task?.errors).toHaveLength(1); - expect(task?.errors[0].retryAt).toBe(now + 5000); - }).pipe(TestRuntime.runPromise)); - - test("writeError with a past retryAt returns the task to wait", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const now = 1000000000000; - yield* TaskEngine.setMockTime(now); - const prefix = "retry-wait"; - yield* taskEngine.createTask({ - id: "r2", - name: "t", - payload: "p", - delay: 0, - maxRetries: 5, - onSuccessPolicy: "delete", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "r2", stalled(1), now - 1); - - const lists = yield* getLists(prefix); - expect(lists.wait).toEqual(["r2"]); - expect(lists.scheduled).toEqual([]); - expect(lists.failed).toEqual([]); - }).pipe(TestRuntime.runPromise)); - - test("writeError without a retryAt applies the failure policy immediately", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "retry-none"; - yield* taskEngine.createTask({ - id: "r3", - name: "t", - payload: "p", - delay: 0, - maxRetries: 5, - onSuccessPolicy: "delete", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "r3", stalled(1)); - - const lists = yield* getLists(prefix); - expect(lists.failed).toEqual(["r3"]); - expect(lists.wait).toEqual([]); - expect(lists.scheduled).toEqual([]); - const task = yield* taskEngine.getTask(prefix, "r3"); - expect(task?.errors[0].retryAt).toBeUndefined(); - }).pipe(TestRuntime.runPromise)); - - test("Canceled error skips retries and applies onFailurePolicy immediately", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "fail-canceled"; - yield* taskEngine.createTask({ - id: "c1", - name: "cancel task", - payload: "p", - delay: 0, - maxRetries: 5, - onSuccessPolicy: "delete", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "c1", canceled(1000000000000)); - - const lists = yield* getLists(prefix); - expect(lists.wait).toEqual([]); - expect(lists.failed).toEqual(["c1"]); - }).pipe(TestRuntime.runPromise)); - - test("Canceled error with a scheduled retry still skips retrying", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const now = 1000000000000; - yield* TaskEngine.setMockTime(now); - const prefix = "fail-canceled-retry"; - yield* taskEngine.createTask({ - id: "c2", - name: "cancel task", - payload: "p", - delay: 0, - maxRetries: 5, - onSuccessPolicy: "delete", - onFailurePolicy: "mark-as-failure", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - // a retryAt is provided (as TaskQueue.fail would when a retry schedule - // exists), but Canceled must short-circuit it - yield* writeError(taskEngine, prefix, "c2", canceled(now), now + 5000); - - const lists = yield* getLists(prefix); - expect(lists.scheduled).toEqual([]); - expect(lists.wait).toEqual([]); - expect(lists.failed).toEqual(["c2"]); - }).pipe(TestRuntime.runPromise)); - - test("onFailurePolicy: delete removes task entirely", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "fail-delete"; - yield* taskEngine.createTask({ - id: "d1", - name: "t", - payload: "p", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "delete", - onFailurePolicy: "delete", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "d1", stalled(1)); - - expect(yield* getLists(prefix)).toMatchInlineSnapshot(` + }), + ); + + // The engine routes purely on the `retryAt` it is handed: the retry/cap + // decision lives in TaskQueue.fail. These cover the routing contract. + it.effect("writeError with a future retryAt schedules the task", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const now = 1000000000000; + yield* TaskEngine.setMockTime(now); + const prefix = "retry-scheduled"; + yield* taskEngine.createTask({ + id: "r1", + name: "t", + payload: "p", + delay: 0, + maxRetries: 5, + onSuccessPolicy: "delete", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "r1", stalled(1), now + 5000); + + const lists = yield* getLists(prefix); + expect(lists.scheduled).toEqual(["r1"]); + expect(lists.wait).toEqual([]); + expect(lists.failed).toEqual([]); + const task = yield* taskEngine.getTask(prefix, "r1"); + expect(task?.errors).toHaveLength(1); + expect(task?.errors[0].retryAt).toBe(now + 5000); + }), + ); + + it.effect("writeError with a past retryAt returns the task to wait", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const now = 1000000000000; + yield* TaskEngine.setMockTime(now); + const prefix = "retry-wait"; + yield* taskEngine.createTask({ + id: "r2", + name: "t", + payload: "p", + delay: 0, + maxRetries: 5, + onSuccessPolicy: "delete", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "r2", stalled(1), now - 1); + + const lists = yield* getLists(prefix); + expect(lists.wait).toEqual(["r2"]); + expect(lists.scheduled).toEqual([]); + expect(lists.failed).toEqual([]); + }), + ); + + it.effect( + "writeError without a retryAt applies the failure policy immediately", + () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "retry-none"; + yield* taskEngine.createTask({ + id: "r3", + name: "t", + payload: "p", + delay: 0, + maxRetries: 5, + onSuccessPolicy: "delete", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "r3", stalled(1)); + + const lists = yield* getLists(prefix); + expect(lists.failed).toEqual(["r3"]); + expect(lists.wait).toEqual([]); + expect(lists.scheduled).toEqual([]); + const task = yield* taskEngine.getTask(prefix, "r3"); + expect(task?.errors[0].retryAt).toBeUndefined(); + }), + ); + + it.effect( + "Canceled error skips retries and applies onFailurePolicy immediately", + () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "fail-canceled"; + yield* taskEngine.createTask({ + id: "c1", + name: "cancel task", + payload: "p", + delay: 0, + maxRetries: 5, + onSuccessPolicy: "delete", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "c1", canceled(1000000000000)); + + const lists = yield* getLists(prefix); + expect(lists.wait).toEqual([]); + expect(lists.failed).toEqual(["c1"]); + }), + ); + + it.effect( + "Canceled error with a scheduled retry still skips retrying", + () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const now = 1000000000000; + yield* TaskEngine.setMockTime(now); + const prefix = "fail-canceled-retry"; + yield* taskEngine.createTask({ + id: "c2", + name: "cancel task", + payload: "p", + delay: 0, + maxRetries: 5, + onSuccessPolicy: "delete", + onFailurePolicy: "mark-as-failure", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + // a retryAt is provided (as TaskQueue.fail would when a retry schedule + // exists), but Canceled must short-circuit it + yield* writeError( + taskEngine, + prefix, + "c2", + canceled(now), + now + 5000, + ); + + const lists = yield* getLists(prefix); + expect(lists.scheduled).toEqual([]); + expect(lists.wait).toEqual([]); + expect(lists.failed).toEqual(["c2"]); + }), + ); + + it.effect("onFailurePolicy: delete removes task entirely", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "fail-delete"; + yield* taskEngine.createTask({ + id: "d1", + name: "t", + payload: "p", + delay: 0, + maxRetries: 0, + onSuccessPolicy: "delete", + onFailurePolicy: "delete", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "d1", stalled(1)); + + expect(yield* getLists(prefix)).toMatchInlineSnapshot(` { "active": [], "failed": [], @@ -751,118 +811,126 @@ describe("TaskEngine", () => { "wait": [], } `); - expect(yield* taskEngine.getTask(prefix, "d1")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("onFailurePolicy: keep removes from lists but keeps task", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "fail-keep"; - yield* taskEngine.createTask({ - id: "k1", - name: "t", - payload: "p", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "delete", - onFailurePolicy: "keep", - prefix, - }); - - yield* takeTask(taskEngine, prefix, 30000); - yield* writeError(taskEngine, prefix, "k1", stalled(1)); - - const lists = yield* getLists(prefix); - expect(lists.wait).toEqual([]); - expect(lists.active).toEqual([]); - expect(lists.failed).toEqual([]); - const task = yield* taskEngine.getTask(prefix, "k1"); - expect(task?.id).toBe("k1"); - expect(task?.errors).toHaveLength(1); - }).pipe(TestRuntime.runPromise)); - - test("onSuccessPolicy: mark-as-success adds to success list and keeps task", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "success-mark"; - yield* taskEngine.createTask({ - id: "s1", - name: "t", - payload: "p", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "mark-as-success", - onFailurePolicy: "delete", - prefix, - }); - - const taken = yield* takeTask(taskEngine, prefix, 30000); - yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "ok"); - - const lists = yield* getLists(prefix); - expect(lists.success).toEqual(["s1"]); - expect(lists.active).toEqual([]); - const task = yield* taskEngine.getTask(prefix, "s1"); - expect(task?.id).toBe("s1"); - }).pipe(TestRuntime.runPromise)); - - test("onSuccessPolicy: keep removes from lists but keeps task", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(1000000000000); - const prefix = "success-keep"; - yield* taskEngine.createTask({ - id: "sk1", - name: "t", - payload: "p", - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "delete", - prefix, - }); - - const taken = yield* takeTask(taskEngine, prefix, 30000); - yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "ok"); - - const lists = yield* getLists(prefix); - expect(lists.success).toEqual([]); - expect(lists.active).toEqual([]); - expect(lists.wait).toEqual([]); - const task = yield* taskEngine.getTask(prefix, "sk1"); - expect(task?.id).toBe("sk1"); - }).pipe(TestRuntime.runPromise)); - - test("structured payloads round-trip through msgpack unchanged", () => - Effect.gen(function* () { - const taskEngine = yield* TaskEngine.TaskEngine; - const prefix = "task-engine-msgpack-roundtrip"; - // nested objects/arrays, floats, unicode, empty collections - const payload = { - user: { id: "u1", tags: ["a", "b"], scores: [1.5, -2, 3e10] }, - note: "unicode ✓ émoji 🎉", - empty: [], - nested: { deep: { flag: true, none: null } }, - }; - const created = yield* taskEngine.createTask({ - id: "mp1", - name: "t", - payload, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "keep", - onFailurePolicy: "keep", - prefix, - }); - expect(created.payload).toEqual(payload); - expect(created.errors).toEqual([]); - - const fetched = yield* taskEngine.getTask(prefix, "mp1"); - expect(fetched?.payload).toEqual(payload); - - const taken = yield* takeTask(taskEngine, prefix, 30000); - expect(taken?.payload).toEqual(payload); - }).pipe(TestRuntime.runPromise)); -}); + expect(yield* taskEngine.getTask(prefix, "d1")).toBeNull(); + }), + ); + + it.effect("onFailurePolicy: keep removes from lists but keeps task", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "fail-keep"; + yield* taskEngine.createTask({ + id: "k1", + name: "t", + payload: "p", + delay: 0, + maxRetries: 0, + onSuccessPolicy: "delete", + onFailurePolicy: "keep", + prefix, + }); + + yield* takeTask(taskEngine, prefix, 30000); + yield* writeError(taskEngine, prefix, "k1", stalled(1)); + + const lists = yield* getLists(prefix); + expect(lists.wait).toEqual([]); + expect(lists.active).toEqual([]); + expect(lists.failed).toEqual([]); + const task = yield* taskEngine.getTask(prefix, "k1"); + expect(task?.id).toBe("k1"); + expect(task?.errors).toHaveLength(1); + }), + ); + + it.effect( + "onSuccessPolicy: mark-as-success adds to success list and keeps task", + () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "success-mark"; + yield* taskEngine.createTask({ + id: "s1", + name: "t", + payload: "p", + delay: 0, + maxRetries: 0, + onSuccessPolicy: "mark-as-success", + onFailurePolicy: "delete", + prefix, + }); + + const taken = yield* takeTask(taskEngine, prefix, 30000); + yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "ok"); + + const lists = yield* getLists(prefix); + expect(lists.success).toEqual(["s1"]); + expect(lists.active).toEqual([]); + const task = yield* taskEngine.getTask(prefix, "s1"); + expect(task?.id).toBe("s1"); + }), + ); + + it.effect("onSuccessPolicy: keep removes from lists but keeps task", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(1000000000000); + const prefix = "success-keep"; + yield* taskEngine.createTask({ + id: "sk1", + name: "t", + payload: "p", + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "delete", + prefix, + }); + + const taken = yield* takeTask(taskEngine, prefix, 30000); + yield* writeSuccess(taskEngine, prefix, taken?.id ?? "", "ok"); + + const lists = yield* getLists(prefix); + expect(lists.success).toEqual([]); + expect(lists.active).toEqual([]); + expect(lists.wait).toEqual([]); + const task = yield* taskEngine.getTask(prefix, "sk1"); + expect(task?.id).toBe("sk1"); + }), + ); + + it.effect("structured payloads round-trip through msgpack unchanged", () => + Effect.gen(function* () { + const taskEngine = yield* TaskEngine.TaskEngine; + const prefix = "task-engine-msgpack-roundtrip"; + // nested objects/arrays, floats, unicode, empty collections + const payload = { + user: { id: "u1", tags: ["a", "b"], scores: [1.5, -2, 3e10] }, + note: "unicode ✓ émoji 🎉", + empty: [], + nested: { deep: { flag: true, none: null } }, + }; + const created = yield* taskEngine.createTask({ + id: "mp1", + name: "t", + payload, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "keep", + onFailurePolicy: "keep", + prefix, + }); + expect(created.payload).toEqual(payload); + expect(created.errors).toEqual([]); + + const fetched = yield* taskEngine.getTask(prefix, "mp1"); + expect(fetched?.payload).toEqual(payload); + + const taken = yield* takeTask(taskEngine, prefix, 30000); + expect(taken?.payload).toEqual(payload); + }), + ); + }, +); diff --git a/src/TaskEngine.ts b/src/TaskEngine.ts index fbb9390..c20f131 100644 --- a/src/TaskEngine.ts +++ b/src/TaskEngine.ts @@ -9,6 +9,7 @@ * @module */ import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -17,19 +18,20 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import taskEngineScript from "./lua/taskEngine.js"; -import * as Observability from "./Observability.js"; -import { RedisPool, type RedisPoolService } from "./RedisPool.js"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { type EngineTask, type EngineTaskInsert, EngineTaskSchema, type EngineTerminalResult, EngineTerminalResultSchema, - type Event, - EventSchema, - UnknownFromMsgpack, -} from "./Schemas.js"; +} from "./EngineRecord.js"; +import taskEngineScript from "./lua/taskEngine.js"; +import { UnknownFromMsgpack } from "./MessagePack.js"; +import * as NodeRedisPool from "./NodeRedisPool.js"; +import * as Observability from "./Observability.js"; +import { RedisPool, type RedisPoolService } from "./RedisPool.js"; +import { type Event, EventSchema } from "./TaskEvent.js"; const TypeId = "~effectmq/TaskEngine" as const; @@ -54,18 +56,72 @@ export type TaskEngineConfig = { * @since 0.3.0 */ export const maxMaintenanceBatchSize = 1_000; + +/** Predictable validation failure for task-engine configuration. */ +export class TaskEngineConfigurationError extends Data.TaggedError( + "TaskEngineConfigurationError", +)<{ + readonly field: "maintenanceBatchSize"; + readonly constraint: string; + readonly actual: unknown; +}> {} + +const validateConfig = ( + config: TaskEngineConfig = {}, +): Effect.Effect => { + const maintenanceBatchSize = config.maintenanceBatchSize ?? 100; + return Number.isSafeInteger(maintenanceBatchSize) && + maintenanceBatchSize >= 1 && + maintenanceBatchSize <= maxMaintenanceBatchSize + ? Effect.void + : Effect.fail( + new TaskEngineConfigurationError({ + field: "maintenanceBatchSize", + constraint: `an integer between 1 and ${maxMaintenanceBatchSize}`, + actual: maintenanceBatchSize, + }), + ); +}; /** * Wraps a Redis, script, encoding, or decoding failure at the engine boundary. * * @category Errors * @since 0.1.0 */ +export type TaskEngineErrorReason = + | { readonly _tag: "TransportFailure"; readonly operation: string } + | { readonly _tag: "ScriptFailure"; readonly operation: string } + | { + readonly _tag: "InvalidReply"; + readonly operation: string; + readonly expected: string; + } + | { + readonly _tag: "RelationshipLimit"; + readonly scope: "holder" | "retained"; + readonly maxCount: number; + } + | { readonly _tag: "IndeterminateCommit"; readonly operation: string } + | { readonly _tag: "LeaseLost"; readonly operation: string }; + export class TaskEngineError extends Data.TaggedError("TaskEngineError")<{ - readonly message?: string; + readonly reason: TaskEngineErrorReason; readonly cause: unknown; }> { - static of(message: string) { - return (cause: unknown) => new TaskEngineError({ cause, message }); + static invalidReply(operation: string, expected: string) { + return (cause: unknown) => + new TaskEngineError({ + reason: { _tag: "InvalidReply", operation, expected }, + cause, + }); + } + + static redis(operation: string, mutating = false) { + return (cause: unknown) => + new TaskEngineError({ + reason: classifyRedisFailure(operation, mutating, cause), + cause, + }); } } @@ -153,18 +209,49 @@ export class CursorExpired extends Data.TaggedError("CursorExpired")<{ readonly earliest: string; }> {} -const causeText = (cause: unknown, depth = 0): string => { +const diagnosticText = (cause: unknown, depth = 0): string => { if (depth >= 6) return String(cause); if (typeof cause !== "object" || cause === null) return String(cause); const parts = [String(cause)]; if ("message" in cause) parts.push(String(cause.message)); - if ("cause" in cause) parts.push(causeText(cause.cause, depth + 1)); + if ("cause" in cause) parts.push(diagnosticText(cause.cause, depth + 1)); return parts.join(" "); }; +const transportPattern = + /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket closed|connection (?:is )?closed|connection lost|read only/i; + +const classifyRedisFailure = ( + operation: string, + mutating: boolean, + cause: unknown, +): TaskEngineErrorReason => { + const diagnostic = diagnosticText(cause); + const relationship = diagnostic.match( + /STORAGE_RELATIONSHIP_LIMIT (holder|retained) (\d+)/, + ); + if (relationship) { + return { + _tag: "RelationshipLimit", + scope: relationship[1] as "holder" | "retained", + maxCount: Number(relationship[2]), + }; + } + if (diagnostic.includes("LEASE_LOST")) { + return { _tag: "LeaseLost", operation }; + } + if (transportPattern.test(diagnostic)) { + return { + _tag: mutating ? "IndeterminateCommit" : "TransportFailure", + operation, + }; + } + return { _tag: "ScriptFailure", operation }; +}; + const isLeaseLost = (error: TaskEngineError) => - causeText(error.cause).includes("LEASE_LOST"); + error.reason._tag === "LeaseLost"; const compareStreamIds = (left: string, right: string): number => { const [leftTime = "0", leftSequence = "0"] = left.split("-"); @@ -266,7 +353,7 @@ export class TaskEngine extends Context.Service< readonly takeTask: ( prefix: string, lockTimeout: number, - ) => Effect.Effect; + ) => Effect.Effect; /** Removes an unretained task generation and all queue memberships. */ readonly removeTask: ( prefix: string, @@ -307,7 +394,7 @@ export class TaskEngine extends Context.Service< TaskEngineError | CursorExpired | Schema.SchemaError >; } ->()("TaskEngine") {} +>()("@effectmq/core/TaskEngine") {} /** * The service interface represented by the {@link TaskEngine} tag. @@ -335,7 +422,7 @@ export const setMockTime = (time: Duration.Input) => Effect.gen(function* () { const redis = yield* RedisPool; yield* redis.send("SET", MOCKTIME_KEY, String(Duration.toMillis(time))); - }).pipe(Effect.mapError(TaskEngineError.of("Failed to set mock time"))); + }).pipe(Effect.mapError(TaskEngineError.redis("setMockTime", true))); /** * Advances the Redis-global mock clock by a duration. @@ -349,40 +436,120 @@ export const stepMockTime = (time: Duration.Input) => Effect.gen(function* () { const redis = yield* RedisPool; yield* redis.send("INCRBY", MOCKTIME_KEY, String(Duration.toMillis(time))); - }).pipe(Effect.mapError(TaskEngineError.of("Failed to step mock time"))); - -const asText = (value: unknown): string => - typeof value === "string" - ? value - : Buffer.from(value as Uint8Array).toString("utf8"); - -/** Fold a flat `[k1, v1, k2, v2, ...]` reply into a record, keys as utf8. */ -const entriesToRecord = (entries: ReadonlyArray) => { - const record: Record = {}; - for (let i = 0; i < entries.length; i += 2) { - record[asText(entries[i])] = entries[i + 1]; + }).pipe(Effect.mapError(TaskEngineError.redis("stepMockTime", true))); + +const invalidReply = (operation: string, expected: string, received: unknown) => + new TaskEngineError({ + reason: { _tag: "InvalidReply", operation, expected }, + cause: { received }, + }); + +const decodeText = ( + operation: string, + value: unknown, +): Effect.Effect => { + if (typeof value === "string") return Effect.succeed(value); + if (value instanceof Uint8Array) { + return Effect.try({ + try: () => Buffer.from(value).toString("utf8"), + catch: (cause) => + new TaskEngineError({ + reason: { + _tag: "InvalidReply", + operation, + expected: "UTF-8 text bytes", + }, + cause, + }), + }); } - return record; + return Effect.fail(invalidReply(operation, "text or Uint8Array", value)); }; +const decodeNumber = ( + operation: string, + value: unknown, +): Effect.Effect => + Effect.gen(function* () { + if (typeof value === "number" && Number.isFinite(value)) return value; + const text = yield* decodeText(operation, value); + const decoded = Number(text); + return Number.isFinite(decoded) + ? decoded + : yield* invalidReply(operation, "a finite number", value); + }); + +const decodeArray = ( + operation: string, + value: unknown, +): Effect.Effect, TaskEngineError> => + Array.isArray(value) + ? Effect.succeed(value) + : Effect.fail(invalidReply(operation, "an array", value)); + +const decodeTuple = ( + operation: string, + value: unknown, + length: number, +): Effect.Effect, TaskEngineError> => + Effect.flatMap(decodeArray(operation, value), (items) => + items.length === length + ? Effect.succeed(items) + : Effect.fail( + invalidReply(operation, `an array of length ${length}`, value), + ), + ); + +/** Fold a flat `[k1, v1, k2, v2, ...]` reply into a prototype-safe record. */ +const entriesToRecord = Effect.fnUntraced(function* ( + operation: string, + value: unknown, +) { + const entries = yield* decodeArray(operation, value); + if (entries.length % 2 !== 0) { + return yield* invalidReply( + operation, + "an even-length field/value array", + value, + ); + } + const record: Record = Object.create(null); + for (let index = 0; index < entries.length; index += 2) { + record[yield* decodeText(operation, entries[index])] = entries[index + 1]; + } + return record; +}); + /** Decode a flat `["id", id, "name", name, ...]` raw-entry reply from Lua. */ -const parseTask = (task: ReadonlyArray) => - Schema.decodeUnknownEffect(EngineTaskSchema)(entriesToRecord(task)).pipe( - Effect.mapError(TaskEngineError.of("Failed to decode task")), +const parseTask = Effect.fnUntraced(function* (task: unknown) { + const record = yield* entriesToRecord("decodeTask", task); + return yield* Schema.decodeUnknownEffect(EngineTaskSchema)(record).pipe( + Effect.mapError(TaskEngineError.invalidReply("decodeTask", "task record")), ); +}); -const parseTerminalResult = (result: ReadonlyArray) => - Schema.decodeUnknownEffect(EngineTerminalResultSchema)( - entriesToRecord(result), +const parseTerminalResult = Effect.fnUntraced(function* (result: unknown) { + const record = yield* entriesToRecord("decodeTerminalResult", result); + return yield* Schema.decodeUnknownEffect(EngineTerminalResultSchema)( + record, ).pipe( - Effect.mapError(TaskEngineError.of("Failed to decode terminal result")), + Effect.mapError( + TaskEngineError.invalidReply("decodeTerminalResult", "terminal result"), + ), ); +}); const packUnknown = Schema.encodeEffect(UnknownFromMsgpack); /** Encode a structured value as msgpack bytes for a script argument. */ const pack = (value: unknown) => packUnknown(value).pipe( - Effect.mapError(TaskEngineError.of("Failed to encode value")), + Effect.mapError( + (cause) => + new TaskEngineError({ + reason: { _tag: "ScriptFailure", operation: "encodeScriptArgument" }, + cause, + }), + ), ); const decodeEvents = Schema.decodeUnknownEffect(Schema.Array(EventSchema)); @@ -419,24 +586,14 @@ export const makeWithRedis = ( }: TaskEngineConfig = {}, ) => Effect.gen(function* () { - if ( - !Number.isSafeInteger(maintenanceBatchSize) || - maintenanceBatchSize < 1 || - maintenanceBatchSize > maxMaintenanceBatchSize - ) { - return yield* Effect.die( - new RangeError( - `maintenanceBatchSize must be an integer between 1 and ${maxMaintenanceBatchSize}`, - ), - ); - } + yield* validateConfig({ maintenanceBatchSize }); const debugFlag = debugMode ? "1" : "0"; const withPrefix = (key: string) => `${prefix}:${key}`; // Every operation receives its name followed by the debug flag and its // own arguments. The Lua dispatcher preserves the operation-local layout. const call = - (name: string, message: string) => + (name: string, mutating = false) => (...args: ReadonlyArray) => redis .evalScript( @@ -447,11 +604,11 @@ export const makeWithRedis = ( String(maintenanceBatchSize), ...args, ) - .pipe(Effect.mapError(TaskEngineError.of(message))); + .pipe(Effect.mapError(TaskEngineError.redis(name, mutating))); // binary replies: these functions return msgpack-encoded tasks const callBinary = - (name: string, message: string) => + (name: string, mutating = false) => (...args: ReadonlyArray) => redis .evalScript( @@ -462,61 +619,24 @@ export const makeWithRedis = ( String(maintenanceBatchSize), ...args, ) - .pipe(Effect.mapError(TaskEngineError.of(message))); - - const createTaskFn = callBinary< - readonly [unknown, unknown, ReadonlyArray] - >("effectmq_createTask", "Failed to create task"); - const getTaskFn = callBinary | null>( - "effectmq_getTask", - "Failed to get task", - ); - const getGenerationFn = call( - "effectmq_getGeneration", - "Failed to read task generation", - ); - const getResultFn = callBinary | null>( - "effectmq_getResult", - "Failed to get terminal result", - ); - const takeTaskFn = callBinary< - readonly [unknown, ReadonlyArray] | null - >("effectmq_takeTask", "Failed to take task"); - const writeSuccessFn = call( - "effectmq_writeSuccess", - "Failed to write success result", - ); - const writeErrorFn = call( - "effectmq_writeError", - "Failed to write error result", - ); - const removeTaskFn = call("effectmq_removeTask", "Failed to remove task"); - const forceRemoveTaskFn = call( - "effectmq_forceRemoveTask", - "Failed to force-remove task", - ); - const extendLockFn = call("effectmq_extendLock", "Failed to extend lock"); - const removeLockFn = call("effectmq_removeLock", "Failed to remove lock"); - const setScheduleFn = call( - "effectmq_setSchedule", - "Failed to set schedule", - ); - const consumeScheduleFn = call<[0 | 1, number | null]>( - "effectmq_consumeSchedule", - "Failed to consume schedule", - ); - const listTasksFn = call( - "effectmq_listTasks", - "Failed to list tasks", - ); - const maintainFn = call( - "effectmq_maintain", - "Failed to maintain queue", - ); - const eventCursorsFn = call( - "effectmq_eventCursors", - "Failed to read event cursors", - ); + .pipe(Effect.mapError(TaskEngineError.redis(name, mutating))); + + const createTaskFn = callBinary("effectmq_createTask", true); + const getTaskFn = callBinary("effectmq_getTask"); + const getGenerationFn = call("effectmq_getGeneration"); + const getResultFn = callBinary("effectmq_getResult"); + const takeTaskFn = callBinary("effectmq_takeTask", true); + const writeSuccessFn = call("effectmq_writeSuccess", true); + const writeErrorFn = call("effectmq_writeError", true); + const removeTaskFn = call("effectmq_removeTask", true); + const forceRemoveTaskFn = call("effectmq_forceRemoveTask", true); + const extendLockFn = call("effectmq_extendLock", true); + const removeLockFn = call("effectmq_removeLock", true); + const setScheduleFn = call("effectmq_setSchedule", true); + const consumeScheduleFn = call("effectmq_consumeSchedule", true); + const listTasksFn = call("effectmq_listTasks"); + const maintainFn = call("effectmq_maintain", true); + const eventCursorsFn = call("effectmq_eventCursors"); const withLeaseFence = ( operation: Effect.Effect, @@ -584,10 +704,23 @@ export const makeWithRedis = ( String(task.deadLetterRetentionMs ?? 30 * 24 * 60 * 60 * 1000), String(task.eventRetentionMs ?? 7 * 24 * 60 * 60 * 1000), ); + const [rawStatus, rawCursor, rawTask] = yield* decodeTuple( + "effectmq_createTask", + reply, + 3, + ); + const status = yield* decodeText("effectmq_createTask.status", rawStatus); + if (status !== "created" && status !== "existing") { + return yield* invalidReply( + "effectmq_createTask.status", + '"created" or "existing"', + rawStatus, + ); + } return { - status: asText(reply[0]) as TaskCreateResult["status"], - cursor: asText(reply[1]), - task: yield* parseTask(reply[2]), + status, + cursor: yield* decodeText("effectmq_createTask.cursor", rawCursor), + task: yield* parseTask(rawTask), } satisfies TaskCreateResult; }); @@ -599,9 +732,14 @@ export const makeWithRedis = ( getTask: Effect.fnUntraced(function* (prefix: string, id: string) { const reply = yield* getTaskFn(withPrefix(prefix), id); - return reply ? yield* parseTask(reply) : null; + return reply === null ? null : yield* parseTask(reply); }), - getGeneration: (prefix, id) => getGenerationFn(withPrefix(prefix), id), + getGeneration: (prefix, id) => + getGenerationFn(withPrefix(prefix), id).pipe( + Effect.flatMap((reply) => + decodeNumber("effectmq_getGeneration", reply), + ), + ), getResult: Effect.fnUntraced(function* ( prefix: string, id: string, @@ -612,7 +750,7 @@ export const makeWithRedis = ( id, String(generation), ); - return reply ? yield* parseTerminalResult(reply) : null; + return reply === null ? null : yield* parseTerminalResult(reply); }), writeSuccess: Effect.fnUntraced(function* ( prefix: string, @@ -666,25 +804,40 @@ export const makeWithRedis = ( limit > 1_000 ) { return yield* new TaskEngineError({ - message: "Invalid task-list page", + reason: { + _tag: "InvalidReply", + operation: "listTasks", + expected: "a non-negative cursor and limit between 1 and 1000", + }, cause: new RangeError( "cursor must be a non-negative integer and limit must be between 1 and 1000", ), }); } - const [nextCursor, ...items] = yield* listTasksFn( + const rawItems = yield* listTasksFn( withPrefix(prefix), list, cursor, String(limit), ); + const [rawNextCursor, ...rawTaskIds] = yield* decodeArray( + "effectmq_listTasks", + rawItems, + ); + const nextCursor = yield* decodeText( + "effectmq_listTasks.cursor", + rawNextCursor, + ); + const items = yield* Effect.forEach(rawTaskIds, (item) => + decodeText("effectmq_listTasks.taskId", item), + ); return { items, nextCursor: nextCursor === "" ? undefined : nextCursor, }; }), maintain: Effect.fnUntraced(function* (prefix: string) { - const values = yield* maintainFn(withPrefix(prefix)).pipe( + const reply = yield* maintainFn(withPrefix(prefix)).pipe( Effect.tapError((error) => Effect.all([ Metric.update( @@ -700,43 +853,76 @@ export const makeWithRedis = ( ]).pipe(Effect.asVoid), ), ); + const values = yield* decodeTuple("effectmq_maintain", reply, 7); + const numbers = yield* Effect.forEach(values, (value) => + decodeNumber("effectmq_maintain", value), + ); const health: Observability.QueueHealth = { - depth: Number(values[0] ?? 0), - oldestTaskAgeMs: Number(values[1] ?? 0), - sweepLagMs: Number(values[2] ?? 0), - dueBacklog: Number(values[3] ?? 0), - expiredLeaseBacklog: Number(values[4] ?? 0), - retentionBacklog: Number(values[5] ?? 0), - processed: Number(values[6] ?? 0), + depth: numbers[0], + oldestTaskAgeMs: numbers[1], + sweepLagMs: numbers[2], + dueBacklog: numbers[3], + expiredLeaseBacklog: numbers[4], + retentionBacklog: numbers[5], + processed: numbers[6], }; yield* Observability.recordQueueHealth(prefix, health); return health; }), - eventCursors: (prefix) => - eventCursorsFn(withPrefix(prefix)).pipe( - Effect.map(([first, earliest, latest]) => ({ - first: asText(first), - earliest: asText(earliest), - latest: asText(latest), - })), - ), + eventCursors: Effect.fnUntraced(function* (prefix: string) { + const [first, earliest, latest] = yield* eventCursorsFn( + withPrefix(prefix), + ).pipe( + Effect.flatMap((reply) => + decodeTuple("effectmq_eventCursors", reply, 3), + ), + ); + return { + first: yield* decodeText("effectmq_eventCursors.first", first), + earliest: yield* decodeText( + "effectmq_eventCursors.earliest", + earliest, + ), + latest: yield* decodeText("effectmq_eventCursors.latest", latest), + }; + }), takeTask: Effect.fnUntraced(function* ( prefix: string, lockTimeout: number, ) { - const leaseToken = `lease/${crypto.randomUUID()}`; + const crypto = yield* Crypto.Crypto; + const leaseToken = yield* crypto.randomUUIDv4.pipe( + Effect.map((uuid) => `lease/${uuid}`), + Effect.mapError( + (cause) => + new TaskEngineError({ + reason: { + _tag: "ScriptFailure", + operation: "generateLeaseToken", + }, + cause, + }), + ), + ); const reply = yield* takeTaskFn( withPrefix(prefix), leaseToken, String(lockTimeout), ); - return reply - ? ({ - leaseToken: asText(reply[0]), - task: yield* parseTask(reply[1]), - } satisfies TaskAttempt) - : null; + if (reply === null) return null; + const [rawLeaseToken, rawTask] = yield* decodeTuple( + "effectmq_takeTask", + reply, + 2, + ); + return { + leaseToken: yield* decodeText( + "effectmq_takeTask.leaseToken", + rawLeaseToken, + ), + task: yield* parseTask(rawTask), + } satisfies TaskAttempt; }), removeTask: (prefix, id) => removeTaskFn(withPrefix(prefix), id).pipe(Effect.asVoid), @@ -757,6 +943,9 @@ export const makeWithRedis = ( ).pipe(Effect.asVoid), setSchedule: (name, next) => { return setScheduleFn(prefix, name, String(next.getTime())).pipe( + Effect.flatMap((reply) => + decodeNumber("effectmq_setSchedule", reply), + ), Effect.map((next) => new Date(next)), ); }, @@ -767,10 +956,38 @@ export const makeWithRedis = ( String(toConsume.getTime()), String(next.getTime()), ).pipe( - Effect.map(([consumed, next]) => ({ - consumed: consumed === 1, - next: next ? new Date(next) : undefined, - })), + Effect.flatMap((reply) => + Effect.gen(function* () { + const values = yield* decodeArray( + "effectmq_consumeSchedule", + reply, + ); + if (values.length < 1 || values.length > 2) { + return yield* invalidReply( + "effectmq_consumeSchedule", + "a one- or two-item tuple", + reply, + ); + } + const consumed = yield* decodeNumber( + "effectmq_consumeSchedule.consumed", + values[0], + ); + const rawNext = values[1]; + return { + consumed: consumed === 1, + next: + rawNext === null || rawNext === undefined + ? undefined + : new Date( + yield* decodeNumber( + "effectmq_consumeSchedule.next", + rawNext, + ), + ), + }; + }), + ), ); }, @@ -790,13 +1007,27 @@ export const makeWithRedis = ( // replies with [stream, entries] tuples, node-redis with a Map // (binary type mapping) or an object keyed by stream name. // Normalize to the entry list of the single stream we read. - const entriesOf = ( - reply: unknown, - ): ReadonlyArray<[unknown, ReadonlyArray]> => { - if (reply instanceof Map) return [...reply.values()][0]; - if (Array.isArray(reply)) return reply[0][1]; - return Object.values(reply as object)[0]; - }; + const entriesOf = Effect.fnUntraced(function* (reply: unknown) { + let rawEntries: unknown; + if (reply instanceof Map) { + rawEntries = [...reply.values()][0]; + } else { + const streams = yield* decodeArray("xread", reply); + const stream = yield* decodeTuple("xread.stream", streams[0], 2); + rawEntries = stream[1]; + } + const entries = yield* decodeArray("xread.entries", rawEntries); + if (entries.length === 0) { + return yield* invalidReply( + "xread.entries", + "at least one stream entry", + reply, + ); + } + return yield* Effect.forEach(entries, (entry) => + decodeTuple("xread.entry", entry, 2), + ); + }); const readFrom = (cursor: string) => Stream.paginate(cursor, (cursor) => @@ -814,7 +1045,7 @@ export const makeWithRedis = ( cursor, ) .pipe( - Effect.mapError(TaskEngineError.of("Failed to poll stream")), + Effect.mapError(TaskEngineError.redis("xread")), Effect.repeat({ until: (value) => !!value, }), @@ -824,46 +1055,59 @@ export const makeWithRedis = ( // "new:"/"existing:" prefixed fields are task snapshots, the // rest is the tag-specific payload (values stay raw bytes — the // event schema decodes scalars and msgpack blobs per field) - const records = entriesOf(reply).map(([entryId, fields]) => { - const flat: Record = {}; - const newTask: Record = {}; - const existingTask: Record = {}; - for (let i = 0; i < fields.length; i += 2) { - const key = asText(fields[i]); - const value = fields[i + 1]; - if (key.startsWith("new:")) { - newTask[key.slice("new:".length)] = value; - } else if (key.startsWith("existing:")) { - existingTask[key.slice("existing:".length)] = value; - } else { - flat[key] = value; + const entries = yield* entriesOf(reply); + const records = yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const [entryId, rawFields] = entry; + const fields = yield* decodeArray("xread.fields", rawFields); + if (fields.length % 2 !== 0) { + return yield* invalidReply( + "xread.fields", + "an even-length field/value array", + rawFields, + ); } - } - const { - taskId, - generation, - protocolVersion, - schemaId, - _tag, - ...payloadFields - } = flat; - const tag = asText(_tag); - const payload = - tag === "task.created" - ? { ...payloadFields, newTask } - : tag === "task.updated" - ? { ...payloadFields, existingTask, newTask } - : payloadFields; - return { - id: asText(entryId), - taskId: asText(taskId), - generation, - protocolVersion, - schemaId, - _tag: tag, - payload, - }; - }); + const flat: Record = Object.create(null); + const newTask: Record = Object.create(null); + const existingTask: Record = + Object.create(null); + for (let i = 0; i < fields.length; i += 2) { + const key = yield* decodeText("xread.fieldName", fields[i]); + const value = fields[i + 1]; + if (key.startsWith("new:")) { + newTask[key.slice("new:".length)] = value; + } else if (key.startsWith("existing:")) { + existingTask[key.slice("existing:".length)] = value; + } else { + flat[key] = value; + } + } + const { + taskId, + generation, + protocolVersion, + schemaId, + _tag, + ...payloadFields + } = flat; + const tag = yield* decodeText("xread.tag", _tag); + const payload = + tag === "task.created" + ? { ...payloadFields, newTask } + : tag === "task.updated" + ? { ...payloadFields, existingTask, newTask } + : payloadFields; + return { + id: yield* decodeText("xread.entryId", entryId), + taskId: yield* decodeText("xread.taskId", taskId), + generation, + protocolVersion, + schemaId, + _tag: tag, + payload, + }; + }), + ); const events = yield* decodeEvents(records).pipe( Effect.tapError((error) => Effect.log(error.toString())), @@ -879,11 +1123,18 @@ export const makeWithRedis = ( Effect.gen(function* () { const [first, earliest, latest] = yield* eventCursorsFn( withPrefix(name), + ).pipe( + Effect.flatMap((reply) => + decodeTuple("effectmq_eventCursors", reply, 3), + ), ); const cursors = { - first: asText(first), - earliest: asText(earliest), - latest: asText(latest), + first: yield* decodeText("effectmq_eventCursors.first", first), + earliest: yield* decodeText( + "effectmq_eventCursors.earliest", + earliest, + ), + latest: yield* decodeText("effectmq_eventCursors.latest", latest), }; const cursor = options.cursor ?? cursors.latest; const streamWasTrimmed = @@ -914,15 +1165,34 @@ export const makeWithRedis = ( */ export const make = (config?: TaskEngineConfig) => Effect.gen(function* () { + yield* validateConfig(config); const redis = yield* RedisPool; return yield* makeWithRedis(redis, config); }); /** * Provides {@link TaskEngine} from an ambient {@link RedisPool} service. + * Use this for custom Redis implementations and test layers. * * @category Layers * @since 0.1.0 */ -export const layer = (config?: TaskEngineConfig) => +export const layerNoDeps = (config?: TaskEngineConfig) => Layer.effect(TaskEngine, make(config)); + +/** Configuration for the standard Node.js live service graph. */ +export interface LiveConfig { + readonly engine?: TaskEngineConfig; + readonly redis?: NodeRedisPool.RedisConfig; +} + +/** + * Provides a complete Node.js live graph: Redis connections, connection + * roles and health, Crypto, and the task engine. + */ +export const layer = (config: LiveConfig = {}) => + layerNoDeps(config.engine).pipe( + Layer.provideMerge( + Layer.merge(NodeRedisPool.layer(config.redis), NodeCrypto.layer), + ), + ); diff --git a/src/TaskEvent.ts b/src/TaskEvent.ts new file mode 100644 index 0000000..3a4f75a --- /dev/null +++ b/src/TaskEvent.ts @@ -0,0 +1,83 @@ +/** Public schemas for queue lifecycle events. @module */ +import * as Schema from "effect/Schema"; +import { + BooleanFromBytes, + EngineTaskSchema, + ExecutionStateSchema, + NumberFromBytes, + TaskLists, + TextFromBytes, +} from "./EngineRecord.js"; +import { UnknownFromMsgpack } from "./MessagePack.js"; +import { CompletionPolicySchema } from "./TaskRecord.js"; + +const eventBase = { + id: Schema.String, + taskId: Schema.String, + generation: NumberFromBytes, + protocolVersion: NumberFromBytes, + schemaId: TextFromBytes, +}; + +export const EventSchema = Schema.Union([ + Schema.TaggedStruct("task.created", { + ...eventBase, + payload: Schema.Struct({ + newTask: EngineTaskSchema, + state: TextFromBytes.pipe(Schema.decodeTo(ExecutionStateSchema)), + }), + }), + Schema.TaggedStruct("task.updated", { + ...eventBase, + payload: Schema.Struct({ + existingTask: EngineTaskSchema, + newTask: EngineTaskSchema, + state: TextFromBytes.pipe(Schema.decodeTo(ExecutionStateSchema)), + }), + }), + Schema.TaggedStruct("task.failed", { + ...eventBase, + payload: Schema.Struct({ + policy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), + error: UnknownFromMsgpack, + retryAt: NumberFromBytes.pipe(Schema.optional), + failureKind: TextFromBytes.pipe( + Schema.decodeTo(Schema.Literals(["handler", "stall"])), + ), + attempt: NumberFromBytes, + terminal: BooleanFromBytes, + }), + }), + Schema.TaggedStruct("task.completed", { + ...eventBase, + payload: Schema.Struct({ + success: UnknownFromMsgpack.pipe(Schema.optional), + policy: TextFromBytes.pipe(Schema.decodeTo(CompletionPolicySchema)), + }), + }), + Schema.TaggedStruct("task.moved", { + ...eventBase, + payload: Schema.Struct({ + from: TextFromBytes.pipe(Schema.decodeTo(TaskLists), Schema.optional), + to: TextFromBytes.pipe(Schema.decodeTo(TaskLists), Schema.optional), + previousState: TextFromBytes.pipe( + Schema.decodeTo(ExecutionStateSchema), + Schema.optional, + ), + newState: TextFromBytes.pipe( + Schema.decodeTo(ExecutionStateSchema), + Schema.optional, + ), + attempt: NumberFromBytes, + handlerFailureCount: NumberFromBytes, + stalledAttemptCount: NumberFromBytes, + }), + }), +]); + +export type Event = typeof EventSchema.Type; + +const EventTypeSchema = EventSchema.mapMembers((member) => + member.map((member) => member.fields._tag), +); +export type EventType = typeof EventTypeSchema.Type; diff --git a/src/TaskEvents.test.ts b/src/TaskEvents.test.ts index 7079790..08732c0 100644 --- a/src/TaskEvents.test.ts +++ b/src/TaskEvents.test.ts @@ -1,342 +1,384 @@ -import { Effect, Fiber, Schedule, Schema, Stream } from "effect"; +import { Deferred, Effect, Fiber, Schedule, Schema, Stream } from "effect"; import { Packr } from "msgpackr"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, Task, TaskEngine, TaskQueue } from "./index.js"; -import { TestRuntime } from "./testing/redisLayer.js"; +import { TestLayer } from "./testing/redisLayer.js"; // A typed queue with a deterministic id (idempotencyKey) so we can address a // specific task's events by id. const makeQueue = (name: string, retention?: Partial) => { - const def = Task.make({ + return Task.make({ name, payload: { userId: Schema.String, amount: Schema.Number }, success: Schema.String, error: Schema.Struct({ reason: Schema.String }), retention, idempotencyKey: (p) => p.userId, - }); - return TaskQueue.make(name, def); + }).pipe(Effect.map((definition) => TaskQueue.make(name, definition))); }; // Read events from the very start of the queue's stream until an event with one // of `stopTags` is seen, returning everything collected up to and including it. -const collectUntil = (queue: ReturnType, stopTag: string) => +const collectUntil = ( + queue: Effect.Success>, + stopTag: string, +) => TaskQueue.stream(queue, { cursor: "0" }).pipe( Stream.takeUntil((e) => e._tag === stopTag), Stream.runCollect, ); -describe("Task events", () => { - test("a duplicate offer returns the existing generation without an update event", () => - Effect.gen(function* () { - const queue = makeQueue("ev-create-update"); - - const collector = yield* collectUntil(queue, "task.moved").pipe( - Effect.forkChild, - ); - - const createdOutcome = yield* TaskQueue.offer(queue, { - userId: "u1", - amount: 1, - }); - const existingOutcome = yield* TaskQueue.offer(queue, { - userId: "u1", - amount: 2, - }); - - const events = yield* Fiber.join(collector); - const tags = events.map((e) => e._tag); - expect(tags).toContain("task.created"); - expect(tags).not.toContain("task.updated"); - expect(createdOutcome._tag).toBe("TaskCreated"); - expect(existingOutcome._tag).toBe("TaskExisting"); - expect(existingOutcome.task.payload).toEqual({ - userId: "u1", - amount: 1, - }); - expect(existingOutcome.handle.generation).toBe( - createdOutcome.handle.generation, - ); - - const created = events.find((e) => e._tag === "task.created"); - // Payload is decoded to a typed task, not a raw string. - if (created?._tag === "task.created") { - expect(created.payload.newTask.payload).toEqual({ - userId: "u1", - amount: 1, +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "Task events (real Redis time)", + (it) => { + it.effect( + "a duplicate offer returns the existing generation without an update event", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-create-update"); + + const collector = yield* collectUntil(queue, "task.moved").pipe( + Effect.forkChild, + ); + + const createdOutcome = yield* TaskQueue.offer(queue, { + userId: "u1", + amount: 1, + }); + const existingOutcome = yield* TaskQueue.offer(queue, { + userId: "u1", + amount: 2, + }); + + const events = yield* Fiber.join(collector); + const tags = events.map((e) => e._tag); + expect(tags).toContain("task.created"); + expect(tags).not.toContain("task.updated"); + expect(createdOutcome._tag).toBe("TaskCreated"); + expect(existingOutcome._tag).toBe("TaskExisting"); + expect(existingOutcome.task.payload).toEqual({ + userId: "u1", + amount: 1, + }); + expect(existingOutcome.handle.generation).toBe( + createdOutcome.handle.generation, + ); + + const created = events.find((e) => e._tag === "task.created"); + // Payload is decoded to a typed task, not a raw string. + if (created?._tag === "task.created") { + expect(created.payload.newTask.payload).toEqual({ + userId: "u1", + amount: 1, + }); + } + }), + ); + + it.effect( + "success emits task.completed, failure emits task.failed with willRetry", + () => + Effect.gen(function* () { + const okQueue = yield* makeQueue("ev-completed"); + const okCollector = yield* collectUntil( + okQueue, + "task.completed", + ).pipe(Effect.forkChild); + + yield* TaskQueue.offer(okQueue, { userId: "ok", amount: 1 }); + yield* TaskQueue.complete(okQueue, () => Effect.succeed("done")); + + const okEvents = yield* Fiber.join(okCollector); + const completed = okEvents.find((e) => e._tag === "task.completed"); + expect(completed?._tag).toBe("task.completed"); + if (completed?._tag === "task.completed") { + expect(completed.payload.success).toBe("done"); + } + + const failQueue = yield* makeQueue("ev-failed"); + const failCollector = yield* collectUntil( + failQueue, + "task.failed", + ).pipe(Effect.forkChild); + + yield* TaskQueue.offer( + failQueue, + { userId: "bad", amount: 1 }, + { onFailurePolicy: "mark-as-failure" }, + ); + yield* TaskQueue.complete(failQueue, () => + Effect.fail({ reason: "nope" }), + ); + + const failEvents = yield* Fiber.join(failCollector); + const failed = failEvents.find((e) => e._tag === "task.failed"); + expect(failed?._tag).toBe("task.failed"); + if (failed?._tag === "task.failed") { + // No retry schedule configured → no retryAt, and the error is decoded typed. + expect(failed.payload.retryAt).toBeUndefined(); + expect(failed.payload.error).toEqual({ reason: "nope" }); + } + }), + ); + + it.effect("wait resolves with the typed success value", () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-ok"); + const task = yield* TaskQueue.offer(queue, { userId: "w1", amount: 5 }); + + // Drive the task to completion in the background. + yield* TaskQueue.complete(queue, () => Effect.succeed("welcome")).pipe( + Effect.forkChild, + ); + + const result = yield* TaskQueue.wait(queue, task.handle); + expect(result).toBe("welcome"); + }), + ); + + it.effect( + "wait observes completion after its subscription has started", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-subscription-race"); + const offered = yield* TaskQueue.offer(queue, { + userId: "subscribed", + amount: 5, + }); + const waiting = yield* Deferred.make(); + const waiter = yield* Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(TaskQueue.wait(queue, offered.handle)), + Effect.forkChild, + ); + + yield* Deferred.await(waiting); + yield* Effect.yieldNow; + yield* TaskQueue.complete(queue, () => + Effect.succeed("after-subscribe"), + ); + + expect(yield* Fiber.join(waiter)).toBe("after-subscribe"); + }), + ); + + it.effect("wait fails with the typed error on terminal failure", () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-fail"); + const task = yield* TaskQueue.offer( + queue, + { userId: "w2", amount: 5 }, + { onFailurePolicy: "mark-as-failure" }, + ); + + yield* TaskQueue.complete(queue, () => + Effect.fail({ reason: "rejected" }), + ).pipe(Effect.forkChild); + + const outcome = yield* TaskQueue.wait(queue, task.handle).pipe( + Effect.flip, + ); + expect(outcome).toMatchObject({ + _tag: "TaskFailed", + failure: { reason: "rejected" }, }); - } - }).pipe(TestRuntime.runPromise)); - - test("success emits task.completed, failure emits task.failed with willRetry", () => - Effect.gen(function* () { - const okQueue = makeQueue("ev-completed"); - const okCollector = yield* collectUntil(okQueue, "task.completed").pipe( - Effect.forkChild, - ); - - yield* TaskQueue.offer(okQueue, { userId: "ok", amount: 1 }); - yield* TaskQueue.complete(okQueue, () => Effect.succeed("done")); - - const okEvents = yield* Fiber.join(okCollector); - const completed = okEvents.find((e) => e._tag === "task.completed"); - expect(completed?._tag).toBe("task.completed"); - if (completed?._tag === "task.completed") { - expect(completed.payload.success).toBe("done"); - } - - const failQueue = makeQueue("ev-failed"); - const failCollector = yield* collectUntil(failQueue, "task.failed").pipe( - Effect.forkChild, - ); - - yield* TaskQueue.offer( - failQueue, - { userId: "bad", amount: 1 }, - { onFailurePolicy: "mark-as-failure" }, - ); - yield* TaskQueue.complete(failQueue, () => - Effect.fail({ reason: "nope" }), - ); - - const failEvents = yield* Fiber.join(failCollector); - const failed = failEvents.find((e) => e._tag === "task.failed"); - expect(failed?._tag).toBe("task.failed"); - if (failed?._tag === "task.failed") { - // No retry schedule configured → no retryAt, and the error is decoded typed. - expect(failed.payload.retryAt).toBeUndefined(); - expect(failed.payload.error).toEqual({ reason: "nope" }); - } - }).pipe(TestRuntime.runPromise)); - - test("wait resolves with the typed success value", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-ok"); - const task = yield* TaskQueue.offer(queue, { userId: "w1", amount: 5 }); - - // Drive the task to completion in the background. - yield* TaskQueue.complete(queue, () => Effect.succeed("welcome")).pipe( - Effect.forkChild, - ); - - const result = yield* TaskQueue.wait(queue, task.handle); - expect(result).toBe("welcome"); - }).pipe(TestRuntime.runPromise)); - - test("wait observes completion after its subscription has started", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-subscription-race"); - const offered = yield* TaskQueue.offer(queue, { - userId: "subscribed", - amount: 5, - }); - const waiter = yield* TaskQueue.wait(queue, offered.handle).pipe( - Effect.forkChild, - ); - - yield* Effect.sleep("20 millis"); - yield* TaskQueue.complete(queue, () => Effect.succeed("after-subscribe")); - - expect(yield* Fiber.join(waiter)).toBe("after-subscribe"); - }).pipe(TestRuntime.runPromise)); - - test("wait fails with the typed error on terminal failure", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-fail"); - const task = yield* TaskQueue.offer( - queue, - { userId: "w2", amount: 5 }, - { onFailurePolicy: "mark-as-failure" }, - ); - - yield* TaskQueue.complete(queue, () => - Effect.fail({ reason: "rejected" }), - ).pipe(Effect.forkChild); - - const outcome = yield* TaskQueue.wait(queue, task.handle).pipe( - Effect.flip, - ); - expect(outcome).toMatchObject({ - _tag: "TaskFailed", - failure: { reason: "rejected" }, - }); - }).pipe(TestRuntime.runPromise)); - - test("wait resolves from durable state when a retained task already completed", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-already-complete"); - const offered = yield* TaskQueue.offer( - queue, - { userId: "done", amount: 1 }, - { onSuccessPolicy: "keep" }, - ); - yield* TaskQueue.complete(queue, () => Effect.succeed("stored")); - - expect(yield* TaskQueue.wait(queue, offered.handle)).toBe("stored"); - }).pipe(TestRuntime.runPromise)); - - test("wait reads a delete-policy result until its independent retention expires", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-expired", { resultMs: 100 }); - const engine = yield* TaskEngine.TaskEngine; - yield* TaskEngine.setMockTime(10_000_000); - const offered = yield* TaskQueue.offer(queue, { - userId: "expired", - amount: 1, - }); - yield* TaskQueue.complete(queue, () => Effect.succeed("discarded")); - - expect(yield* TaskQueue.wait(queue, offered.handle)).toBe("discarded"); - - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(queue.name); - - const expired = yield* TaskQueue.wait(queue, offered.handle).pipe( - Effect.flip, - ); - expect(expired).toMatchObject({ - _tag: "ResultExpired", - latestGeneration: 1, - }); - - const missing = yield* TaskQueue.wait(queue, { - ...offered.handle, - taskId: "never-created", - }).pipe(Effect.flip); - expect(missing).toMatchObject({ _tag: "TaskNotFound" }); - }).pipe(TestRuntime.runPromise)); - - test("wait has a typed caller timeout", () => - Effect.gen(function* () { - const queue = makeQueue("ev-wait-timeout"); - const offered = yield* TaskQueue.offer( - queue, - { userId: "pending", amount: 1 }, - { onSuccessPolicy: "keep" }, - ); - const timeout = yield* TaskQueue.wait(queue, offered.handle, { - timeout: "10 millis", - }).pipe(Effect.flip); - expect(timeout).toMatchObject({ _tag: "CallerTimeout" }); - }).pipe(TestRuntime.runPromise)); - - test("stream reports the earliest cursor when retained events were trimmed", () => - Effect.gen(function* () { - const queue = makeQueue("ev-cursor-expired"); - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - yield* TaskQueue.offer(queue, { userId: "trimmed", amount: 1 }); - const before = yield* engine.eventCursors(queue.name); - - yield* redis.send( - "XTRIM", - `~effectmq:v1:${queue.name}:events`, - "MAXLEN", - "1", - ); - const after = yield* engine.eventCursors(queue.name); - expect(after.earliest).not.toBe(before.first); - - const error = yield* TaskQueue.stream(queue, { - cursor: before.first, - }).pipe(Stream.runHead, Effect.flip); - expect(error).toMatchObject({ - _tag: "CursorExpired", - requested: before.first, - earliest: after.earliest, - }); - }).pipe(TestRuntime.runPromise)); - - test("configured event retention trims the stream approximately", () => - Effect.gen(function* () { - const definition = Task.make({ - name: "ev-configured-trim", - payload: { userId: Schema.String, amount: Schema.Number }, - success: Schema.String, - error: Schema.Struct({ reason: Schema.String }), - storageLimits: { maxEventEntries: 10 }, - idempotencyKey: (payload) => payload.userId, - }); - const queue = TaskQueue.make("ev-configured-trim", definition); - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - - yield* TaskQueue.offer(queue, { userId: "event-0", amount: 0 }); - const before = yield* engine.eventCursors(queue.name); - for (let index = 1; index < 120; index++) { - yield* TaskQueue.offer(queue, { - userId: `event-${index}`, - amount: index, + }), + ); + + it.effect( + "wait resolves from durable state when a retained task already completed", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-already-complete"); + const offered = yield* TaskQueue.offer( + queue, + { userId: "done", amount: 1 }, + { onSuccessPolicy: "keep" }, + ); + yield* TaskQueue.complete(queue, () => Effect.succeed("stored")); + + expect(yield* TaskQueue.wait(queue, offered.handle)).toBe("stored"); + }), + ); + + it.effect( + "wait reads a delete-policy result until its independent retention expires", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-expired", { resultMs: 100 }); + const engine = yield* TaskEngine.TaskEngine; + yield* TaskEngine.setMockTime(10_000_000); + const offered = yield* TaskQueue.offer(queue, { + userId: "expired", + amount: 1, + }); + yield* TaskQueue.complete(queue, () => Effect.succeed("discarded")); + + expect(yield* TaskQueue.wait(queue, offered.handle)).toBe( + "discarded", + ); + + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(queue.name); + + const expired = yield* TaskQueue.wait(queue, offered.handle).pipe( + Effect.flip, + ); + expect(expired).toMatchObject({ + _tag: "ResultExpired", + latestGeneration: 1, + }); + + const missing = yield* TaskQueue.wait(queue, { + ...offered.handle, + taskId: "never-created", + }).pipe(Effect.flip); + expect(missing).toMatchObject({ _tag: "TaskNotFound" }); + }), + ); + + it.effect("wait has a typed caller timeout", () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-wait-timeout"); + const offered = yield* TaskQueue.offer( + queue, + { userId: "pending", amount: 1 }, + { onSuccessPolicy: "keep" }, + ); + const timeout = yield* TaskQueue.wait(queue, offered.handle, { + timeout: "10 millis", + }).pipe(Effect.flip); + expect(timeout).toMatchObject({ _tag: "CallerTimeout" }); + }), + ); + + it.effect( + "stream reports the earliest cursor when retained events were trimmed", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-cursor-expired"); + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + yield* TaskQueue.offer(queue, { userId: "trimmed", amount: 1 }); + const before = yield* engine.eventCursors(queue.name); + + yield* redis.send( + "XTRIM", + `~effectmq:v1:${queue.name}:events`, + "MAXLEN", + "1", + ); + const after = yield* engine.eventCursors(queue.name); + expect(after.earliest).not.toBe(before.first); + + const error = yield* TaskQueue.stream(queue, { + cursor: before.first, + }).pipe(Stream.runHead, Effect.flip); + expect(error).toMatchObject({ + _tag: "CursorExpired", + requested: before.first, + earliest: after.earliest, + }); + }), + ); + + it.effect("configured event retention trims the stream approximately", () => + Effect.gen(function* () { + const definition = yield* Task.make({ + name: "ev-configured-trim", + payload: { userId: Schema.String, amount: Schema.Number }, + success: Schema.String, + error: Schema.Struct({ reason: Schema.String }), + storageLimits: { maxEventEntries: 10 }, + idempotencyKey: (payload) => payload.userId, }); - } - - const after = yield* engine.eventCursors(queue.name); - const retained = yield* redis.send( - "XLEN", - `~effectmq:v1:${queue.name}:events`, - ); - expect(after.earliest).not.toBe(before.first); - expect(retained).toBeLessThan(240); - expect(retained).toBeGreaterThanOrEqual(10); - }).pipe(TestRuntime.runPromise)); - - test("a corrupt event value fails the stream with a typed storage error", () => - Effect.gen(function* () { - const queue = makeQueue("ev-corrupt-value"); - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - yield* TaskQueue.offer(queue, { userId: "corrupt", amount: 1 }); - const cursor = (yield* engine.eventCursors(queue.name)).latest; - const packr = new Packr({ useRecords: false }); - - yield* redis.send( - "XADD", - `~effectmq:v1:${queue.name}:events`, - "*", - "taskId", - "corrupt", - "generation", - "1", - "protocolVersion", - "1", - "schemaId", - queue.task.schemaId, - "_tag", - "task.failed", - "policy", - "keep", - "error", - packr.pack("not-an-effectmq-envelope"), - "failureKind", - "handler", - "attempt", - "1", - "terminal", - "1", - ); - - const error = yield* TaskQueue.stream(queue, { cursor }).pipe( - Stream.runHead, - Effect.flip, - ); - expect(error).toMatchObject({ _tag: "CorruptStorageValue" }); - }).pipe(TestRuntime.runPromise)); - - test("execute offers and resolves with the handler's success value", () => - Effect.gen(function* () { - const queue = makeQueue("ev-execute"); - - // A worker that keeps pulling — including a fast handler that completes - // near-instantly, which execute must not miss. - yield* TaskQueue.complete(queue, () => Effect.succeed("sent")).pipe( - Effect.repeat(Schedule.forever), - Effect.forkChild, - ); - - const result = yield* TaskQueue.execute(queue, { - userId: "e1", - amount: 9, - }); - expect(result).toBe("sent"); - }).pipe(TestRuntime.runPromise)); -}); + const queue = TaskQueue.make("ev-configured-trim", definition); + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + + yield* TaskQueue.offer(queue, { userId: "event-0", amount: 0 }); + const before = yield* engine.eventCursors(queue.name); + for (let index = 1; index < 120; index++) { + yield* TaskQueue.offer(queue, { + userId: `event-${index}`, + amount: index, + }); + } + + const after = yield* engine.eventCursors(queue.name); + const retained = yield* redis.send( + "XLEN", + `~effectmq:v1:${queue.name}:events`, + ); + expect(after.earliest).not.toBe(before.first); + expect(retained).toBeLessThan(240); + expect(retained).toBeGreaterThanOrEqual(10); + }), + ); + + it.effect( + "a corrupt event value fails the stream with a typed storage error", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-corrupt-value"); + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + yield* TaskQueue.offer(queue, { userId: "corrupt", amount: 1 }); + const cursor = (yield* engine.eventCursors(queue.name)).latest; + const packr = new Packr({ useRecords: false }); + + yield* redis.send( + "XADD", + `~effectmq:v1:${queue.name}:events`, + "*", + "taskId", + "corrupt", + "generation", + "1", + "protocolVersion", + "1", + "schemaId", + queue.task.schemaId, + "_tag", + "task.failed", + "policy", + "keep", + "error", + packr.pack("not-an-effectmq-envelope"), + "failureKind", + "handler", + "attempt", + "1", + "terminal", + "1", + ); + + const error = yield* TaskQueue.stream(queue, { cursor }).pipe( + Stream.runHead, + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "CorruptStorageValue" }); + }), + ); + + it.effect( + "execute offers and resolves with the handler's success value", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("ev-execute"); + + // A worker that keeps pulling — including a fast handler that completes + // near-instantly, which execute must not miss. + yield* TaskQueue.complete(queue, () => Effect.succeed("sent")).pipe( + Effect.repeat(Schedule.forever), + Effect.forkChild, + ); + + const result = yield* TaskQueue.execute(queue, { + userId: "e1", + amount: 9, + }); + expect(result).toBe("sent"); + }), + ); + }, +); diff --git a/src/TaskQueue.test.ts b/src/TaskQueue.test.ts index f9d4f19..0729c10 100644 --- a/src/TaskQueue.test.ts +++ b/src/TaskQueue.test.ts @@ -1,21 +1,20 @@ import { Deferred, Effect, Schedule, Schema } from "effect"; import * as PersistenceRedis from "effect/unstable/persistence/Redis"; -import { describe, expect, test } from "vitest"; +import { describe, expect, layer } from "@effect/vitest"; import { RedisPool, Task, TaskEngine, TaskQueue } from "./index.js"; -import type { EngineTask } from "./Schemas.js"; -import { getLists, TestRuntime } from "./testing/redisLayer.js"; +import type { EngineTask } from "./EngineRecord.js"; +import { getLists, TestLayer } from "./testing/redisLayer.js"; import { takeTask } from "./testing/TaskAttemptHarness.js"; // A typed queue with a deterministic id so wait-list assertions are exact. const makeQueue = (name: string) => { - const def = Task.make({ + return Task.make({ name, payload: { userId: Schema.String, amount: Schema.Number }, success: Schema.String, error: Schema.Struct({ reason: Schema.String }), idempotencyKey: (p) => p.userId, - }); - return TaskQueue.make(name, def); + }).pipe(Effect.map((definition) => TaskQueue.make(name, definition))); }; // A queue whose task defines a retry schedule; `maxRetries` caps the attempts. @@ -23,7 +22,7 @@ const makeRetryQueue = ( name: string, opts?: { maxRetries?: number | null }, ) => { - const def = Task.make({ + return Task.make({ name, payload: { userId: Schema.String }, success: Schema.String, @@ -31,630 +30,789 @@ const makeRetryQueue = ( idempotencyKey: (p) => p.userId, retry: Schedule.spaced("10 seconds"), ...(opts?.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), - }); - return TaskQueue.make(name, def); + }).pipe(Effect.map((definition) => TaskQueue.make(name, definition))); }; -describe("TaskQueue", () => { - test("task storage limits reject invalid configuration", () => { - expect(() => - Task.make({ - name: "invalid-storage-limit", - payload: { value: Schema.String }, - success: Schema.Void, - error: Schema.Never, - storageLimits: { maxErrorEntries: -1 }, - }), - ).toThrow("maxErrorEntries must be a non-negative safe integer"); - }); - - test("a task's configured byte limit is enforced while offering", () => - Effect.gen(function* () { - const definition = Task.make({ - name: "tq-payload-limit", - payload: { value: Schema.String }, - success: Schema.Void, - error: Schema.Never, - storageLimits: { maxValueBytes: 8 }, - idempotencyKey: () => "limited", - }); - const queue = TaskQueue.make(definition.name, definition); - const error = yield* TaskQueue.offer(queue, { value: "too large" }).pipe( - Effect.flip, - ); - expect(error).toMatchObject({ - _tag: "StorageLimitExceeded", - kind: "payload", - maxBytes: 8, - }); - }).pipe(TestRuntime.runPromise)); - - test("outcome byte limits fail before writing terminal state", () => - Effect.gen(function* () { - const successDefinition = Task.make({ - name: "tq-success-limit", - payload: {}, - success: Schema.String, - error: Schema.Never, - storageLimits: { maxValueBytes: 100 }, - idempotencyKey: () => "limited-success", - }); - const successQueue = TaskQueue.make( - successDefinition.name, - successDefinition, - ); - yield* TaskQueue.offer(successQueue, {}); - const successError = yield* TaskQueue.complete(successQueue, () => - Effect.succeed("x".repeat(200)), - ).pipe(Effect.flip); - expect(successError).toMatchObject({ - _tag: "StorageLimitExceeded", - kind: "success", - maxBytes: 100, - }); - const engine = yield* TaskEngine.TaskEngine; - expect( - (yield* engine.getTask(successQueue.name, "limited-success"))?.outcome, - ).toBeUndefined(); - - const failureDefinition = Task.make({ - name: "tq-failure-limit", - payload: {}, - success: Schema.Never, - error: Schema.Struct({ reason: Schema.String }), - storageLimits: { maxValueBytes: 100 }, - idempotencyKey: () => "limited-failure", - }); - const failureQueue = TaskQueue.make( - failureDefinition.name, - failureDefinition, - ); - yield* TaskQueue.offer(failureQueue, {}); - const failureError = yield* TaskQueue.complete(failureQueue, () => - Effect.fail({ reason: "x".repeat(200) }), - ).pipe(Effect.flip); - expect(failureError).toMatchObject({ - _tag: "StorageLimitExceeded", - kind: "failure", - maxBytes: 100, - }); - expect( - (yield* engine.getTask(failureQueue.name, "limited-failure"))?.outcome, - ).toBeUndefined(); - }).pipe(TestRuntime.runPromise)); - - test("connection loss during offer reports an indeterminate write", async () => { - const queue = makeQueue("tq-indeterminate"); - const send: RedisPool.RedisSend = () => - Effect.fail( - new PersistenceRedis.RedisError({ - cause: new Error("read ECONNRESET"), +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "TaskQueue integration (real Redis time)", + (it) => { + describe("TaskQueue", () => { + it.effect("task storage limits reject invalid configuration", () => + Effect.gen(function* () { + const error = yield* Task.make({ + name: "invalid-storage-limit", + payload: { value: Schema.String }, + success: Schema.Void, + error: Schema.Never, + storageLimits: { maxErrorEntries: -1 }, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TaskConfigurationError", + field: "maxErrorEntries", + }); }), ); - const outcome = await Effect.gen(function* () { - const redisPool = yield* RedisPool.make(send, send); - const engine = yield* TaskEngine.make().pipe( - Effect.provideService(RedisPool.RedisPool, redisPool), - ); - return yield* TaskQueue.offer(queue, { - userId: "retry-me", - amount: 1, - }).pipe( - Effect.provideService(TaskEngine.TaskEngine, engine), - Effect.flip, + it.effect( + "a task's configured byte limit is enforced while offering", + () => + Effect.gen(function* () { + const definition = yield* Task.make({ + name: "tq-payload-limit", + payload: { value: Schema.String }, + success: Schema.Void, + error: Schema.Never, + storageLimits: { maxValueBytes: 8 }, + idempotencyKey: () => "limited", + }); + const queue = TaskQueue.make(definition.name, definition); + const error = yield* TaskQueue.offer(queue, { + value: "too large", + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "StorageLimitExceeded", + kind: "payload", + maxBytes: 8, + }); + }), ); - }).pipe(Effect.runPromise); - expect(outcome).toBeInstanceOf(TaskQueue.IndeterminateWriteError); - expect(outcome).toMatchObject({ - _tag: "IndeterminateWriteError", - queue: queue.name, - taskId: "retry-me", - }); - }); - - test("offer places a task with the deterministic id on the wait list", () => - Effect.gen(function* () { - const queue = makeQueue("tq-offer"); - const offered = yield* TaskQueue.offer(queue, { - userId: "u1", - amount: 10, - }); - - expect(offered._tag).toBe("TaskCreated"); - expect(offered.task.id).toBe("u1"); - expect(offered.task.generation).toBe(1); - expect(offered.handle).toMatchObject({ - _tag: "TaskHandle", - generation: 1, - queue: queue.name, - taskId: "u1", - }); - const lists = yield* getLists(queue.name); - expect(lists.wait).toEqual(["u1"]); - }).pipe(TestRuntime.runPromise)); - - test("v1 envelopes preserve opaque payloads, successes, and failures", () => - Effect.gen(function* () { - const definition = Task.make({ - name: "tq-storage-v1", - schemaId: "example/storage-v1", - payload: { id: Schema.String, value: Schema.Unknown }, - success: Schema.Unknown, - error: Schema.Unknown, - idempotencyKey: (payload) => payload.id, - }); - const queue = TaskQueue.make("tq-storage-v1", definition); - const opaque = { - binary: new Uint8Array([0, 255, 1]), - emptyArray: [], - emptyObject: {}, - nested: { none: null }, - text: "✓🎉", - }; - const offered = yield* TaskQueue.offer( - queue, - { id: "success", value: opaque }, - { onSuccessPolicy: "keep" }, - ); - expect(offered.task.payload.value).toEqual(opaque); - yield* TaskQueue.complete(queue, () => Effect.succeed(opaque)); - const succeeded = yield* TaskQueue.offer(queue, { - id: "success", - value: "ignored duplicate", - }); - expect(succeeded.task.success).toEqual(opaque); - - const failure = { reason: { nested: null }, values: [] }; - yield* TaskQueue.offer( - queue, - { id: "failure", value: null }, - { onFailurePolicy: "keep" }, + it.effect("outcome byte limits fail before writing terminal state", () => + Effect.gen(function* () { + const successDefinition = yield* Task.make({ + name: "tq-success-limit", + payload: {}, + success: Schema.String, + error: Schema.Never, + storageLimits: { maxValueBytes: 100 }, + idempotencyKey: () => "limited-success", + }); + const successQueue = TaskQueue.make( + successDefinition.name, + successDefinition, + ); + yield* TaskQueue.offer(successQueue, {}); + const successError = yield* TaskQueue.complete(successQueue, () => + Effect.succeed("x".repeat(200)), + ).pipe(Effect.flip); + expect(successError).toMatchObject({ + _tag: "StorageLimitExceeded", + kind: "success", + maxBytes: 100, + }); + const engine = yield* TaskEngine.TaskEngine; + expect( + (yield* engine.getTask(successQueue.name, "limited-success")) + ?.outcome, + ).toBeUndefined(); + + const failureDefinition = yield* Task.make({ + name: "tq-failure-limit", + payload: {}, + success: Schema.Never, + error: Schema.Struct({ reason: Schema.String }), + storageLimits: { maxValueBytes: 100 }, + idempotencyKey: () => "limited-failure", + }); + const failureQueue = TaskQueue.make( + failureDefinition.name, + failureDefinition, + ); + yield* TaskQueue.offer(failureQueue, {}); + const failureError = yield* TaskQueue.complete(failureQueue, () => + Effect.fail({ reason: "x".repeat(200) }), + ).pipe(Effect.flip); + expect(failureError).toMatchObject({ + _tag: "StorageLimitExceeded", + kind: "failure", + maxBytes: 100, + }); + expect( + (yield* engine.getTask(failureQueue.name, "limited-failure")) + ?.outcome, + ).toBeUndefined(); + }), ); - yield* TaskQueue.complete(queue, () => Effect.fail(failure)); - const failed = yield* TaskQueue.offer(queue, { - id: "failure", - value: "ignored duplicate", - }); - expect(failed.task.errors.at(-1)?.error).toEqual(failure); - }).pipe(TestRuntime.runPromise)); - - test("complete runs the handler, delivers the typed payload, and reports success", () => - Effect.gen(function* () { - const queue = makeQueue("tq-complete-ok"); - yield* TaskQueue.offer(queue, { userId: "u2", amount: 42 }); - - let seenPayload: { userId: string; amount: number } | undefined; - const done = yield* TaskQueue.complete(queue, (task) => { - seenPayload = task.payload; - return Effect.succeed("ok"); - }); - - expect(done).toBe("u2"); - // handler received the decoded typed payload, not the raw JSON string - expect(seenPayload).toEqual({ userId: "u2", amount: 42 }); - - const lists = yield* getLists(queue.name); - expect(lists.wait).toEqual([]); - expect(lists.active).toEqual([]); - }).pipe(TestRuntime.runPromise)); - - test("complete interrupts its handler when the heartbeat loses ownership", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("tq-heartbeat-lease-lost"); - yield* TaskQueue.offer(queue, { userId: "owned", amount: 1 }); - - const handlerStarted = yield* Deferred.make(); - let handlerInterrupted = false; - const leaseLost = new TaskEngine.LeaseLost({ - prefix: queue.name, - taskId: "owned", - cause: new TaskEngine.TaskEngineError({ cause: "test lease theft" }), - }); - const losingEngine = TaskEngine.TaskEngine.of({ - ...engine, - extendLock: () => - Deferred.await(handlerStarted).pipe( - Effect.andThen(Effect.fail(leaseLost)), - ), - }); - - const error = yield* TaskQueue.complete(queue, () => - Deferred.succeed(handlerStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring( - Effect.sync(() => { - handlerInterrupted = true; - }), - ), - ), - ).pipe( - Effect.provideService(TaskEngine.TaskEngine, losingEngine), - Effect.flip, + + it.effect( + "connection loss during offer reports an indeterminate write", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-indeterminate"); + const send: RedisPool.RedisSend = () => + Effect.fail( + new PersistenceRedis.RedisError({ + cause: new Error("read ECONNRESET"), + }), + ); + + const outcome = yield* Effect.gen(function* () { + const redisPool = yield* RedisPool.make(send, send); + const engine = yield* TaskEngine.make().pipe( + Effect.provideService(RedisPool.RedisPool, redisPool), + ); + return yield* TaskQueue.offer(queue, { + userId: "retry-me", + amount: 1, + }).pipe( + Effect.provideService(TaskEngine.TaskEngine, engine), + Effect.flip, + ); + }); + + expect(outcome).toBeInstanceOf(TaskQueue.IndeterminateWriteError); + expect(outcome).toMatchObject({ + _tag: "IndeterminateWriteError", + queue: queue.name, + taskId: "retry-me", + }); + }), ); - expect(error._tag).toBe("LeaseLost"); - expect(handlerInterrupted).toBe(true); - expect(yield* getLists(queue.name)).toMatchObject({ - active: ["owned"], - success: [], - failed: [], - }); - }).pipe(TestRuntime.runPromise)); - - test("heartbeat transport failures retry only within the configured bound", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("tq-heartbeat-retry"); - yield* TaskQueue.offer(queue, { userId: "retry", amount: 1 }); - let renewals = 0; - const recoveringEngine = TaskEngine.TaskEngine.of({ - ...engine, - extendLock: (prefix, id, token, timeout) => - Effect.sync(() => ++renewals).pipe( - Effect.flatMap((attempt) => - attempt < 3 - ? Effect.fail( + it.effect( + "offer recovery uses semantic engine reasons, not diagnostic wording", + () => + Effect.gen(function* () { + const base = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("tq-semantic-recovery"); + const failure = ( + reason: TaskEngine.TaskEngineErrorReason, + ): TaskEngine.TaskEngineService => + TaskEngine.TaskEngine.of({ + ...base, + offerTask: () => + Effect.fail( new TaskEngine.TaskEngineError({ - cause: new Error("temporary Redis transport failure"), + reason, + cause: new Error("diagnostic wording is irrelevant"), }), - ) - : engine.extendLock(prefix, id, token, timeout), - ), - ), - }); - - const processed = yield* TaskQueue.completeOne( - queue, - () => Effect.sleep("20 millis").pipe(Effect.as("ok")), - { - lockTimeout: "1 second", - lockRefresh: "100 millis", - heartbeatRetryDelay: "1 millis", - heartbeatRetryCount: 2, - }, - ).pipe(Effect.provideService(TaskEngine.TaskEngine, recoveringEngine)); - - expect(processed).toBe(true); - expect(renewals).toBe(3); - expect(yield* engine.getTask(queue.name, "retry")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("a duplicate offer does not mutate a leased generation", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("tq-duplicate-leased"); - const created = yield* TaskQueue.offer(queue, { - userId: "leased", - amount: 1, - }); - yield* takeTask(engine, queue.name, 30_000); - - const existing = yield* TaskQueue.offer(queue, { - userId: "leased", - amount: 999, - }); - - expect(existing._tag).toBe("TaskExisting"); - expect(existing.handle.generation).toBe(created.handle.generation); - expect(existing.task.payload.amount).toBe(1); - expect(yield* getLists(queue.name)).toMatchObject({ - active: ["leased"], - wait: [], - }); - }).pipe(TestRuntime.runPromise)); - - test("duplicate offers preserve delayed and retry-scheduled generations", () => - Effect.gen(function* () { - const delayedQueue = makeQueue("tq-duplicate-delayed"); - yield* TaskQueue.offer( - delayedQueue, - { userId: "delayed", amount: 1 }, - { delay: 60_000 }, + ), + }); + + const indeterminate = yield* TaskQueue.offer(queue, { + userId: "indeterminate", + amount: 1, + }).pipe( + Effect.provideService( + TaskEngine.TaskEngine, + failure({ + _tag: "IndeterminateCommit", + operation: "effectmq_createTask", + }), + ), + Effect.flip, + ); + expect(indeterminate._tag).toBe("IndeterminateWriteError"); + + const relationship = yield* TaskQueue.offer(queue, { + userId: "relationship", + amount: 1, + }).pipe( + Effect.provideService( + TaskEngine.TaskEngine, + failure({ + _tag: "RelationshipLimit", + scope: "retained", + maxCount: 7, + }), + ), + Effect.flip, + ); + expect(relationship).toMatchObject({ + _tag: "StorageCountLimitExceeded", + scope: "retained", + maxCount: 7, + }); + }), ); - const delayed = yield* TaskQueue.offer( - delayedQueue, - { userId: "delayed", amount: 999 }, - { delay: 0 }, + + it.effect( + "offer places a task with the deterministic id on the wait list", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-offer"); + const offered = yield* TaskQueue.offer(queue, { + userId: "u1", + amount: 10, + }); + + expect(offered._tag).toBe("TaskCreated"); + expect(offered.task.id).toBe("u1"); + expect(offered.task.generation).toBe(1); + expect(offered.handle).toMatchObject({ + _tag: "TaskHandle", + generation: 1, + queue: queue.name, + taskId: "u1", + }); + const lists = yield* getLists(queue.name); + expect(lists.wait).toEqual(["u1"]); + }), ); - expect(delayed).toMatchObject({ - _tag: "TaskExisting", - task: { delay: 60_000, payload: { amount: 1 } }, - }); - expect(yield* getLists(delayedQueue.name)).toMatchObject({ - scheduled: ["delayed"], - wait: [], - }); - - const retryQueue = makeRetryQueue("tq-duplicate-retry"); - yield* TaskQueue.offer(retryQueue, { userId: "retry" }); - yield* TaskQueue.complete(retryQueue, () => - Effect.fail({ reason: "first" }), + + it.effect( + "v1 envelopes preserve opaque payloads, successes, and failures", + () => + Effect.gen(function* () { + const definition = yield* Task.make({ + name: "tq-storage-v1", + schemaId: "example/storage-v1", + payload: { id: Schema.String, value: Schema.Unknown }, + success: Schema.Unknown, + error: Schema.Unknown, + idempotencyKey: (payload) => payload.id, + }); + const queue = TaskQueue.make("tq-storage-v1", definition); + const opaque = { + binary: new Uint8Array([0, 255, 1]), + emptyArray: [], + emptyObject: {}, + nested: { none: null }, + text: "✓🎉", + }; + const offered = yield* TaskQueue.offer( + queue, + { id: "success", value: opaque }, + { onSuccessPolicy: "keep" }, + ); + expect(offered.task.payload.value).toEqual(opaque); + yield* TaskQueue.complete(queue, () => Effect.succeed(opaque)); + const succeeded = yield* TaskQueue.offer(queue, { + id: "success", + value: "ignored duplicate", + }); + expect(succeeded.task.success).toEqual(opaque); + + const failure = { reason: { nested: null }, values: [] }; + yield* TaskQueue.offer( + queue, + { id: "failure", value: null }, + { onFailurePolicy: "keep" }, + ); + yield* TaskQueue.complete(queue, () => Effect.fail(failure)); + const failed = yield* TaskQueue.offer(queue, { + id: "failure", + value: "ignored duplicate", + }); + expect(failed.task.errors.at(-1)?.error).toEqual(failure); + }), ); - const retry = yield* TaskQueue.offer(retryQueue, { userId: "retry" }); - expect(retry).toMatchObject({ - _tag: "TaskExisting", - task: { errors: [{ error: { reason: "first" } }] }, - }); - expect(yield* getLists(retryQueue.name)).toMatchObject({ - scheduled: ["retry"], - wait: [], - }); - }).pipe(TestRuntime.runPromise)); - - test("an explicit new generation starts clean after terminal settlement", () => - Effect.gen(function* () { - const queue = makeQueue("tq-new-generation"); - const created = yield* TaskQueue.offer( - queue, - { userId: "repeat", amount: 1 }, - { onSuccessPolicy: "keep" }, + + it.effect( + "complete runs the handler, delivers the typed payload, and reports success", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-complete-ok"); + yield* TaskQueue.offer(queue, { userId: "u2", amount: 42 }); + + let seenPayload: { userId: string; amount: number } | undefined; + const done = yield* TaskQueue.complete(queue, (task) => { + seenPayload = task.payload; + return Effect.succeed("ok"); + }); + + expect(done).toBe("u2"); + // handler received the decoded typed payload, not the raw JSON string + expect(seenPayload).toEqual({ userId: "u2", amount: 42 }); + + const lists = yield* getLists(queue.name); + expect(lists.wait).toEqual([]); + expect(lists.active).toEqual([]); + }), ); - yield* TaskQueue.complete(queue, () => Effect.succeed("old-result")); - - const existing = yield* TaskQueue.offer(queue, { - userId: "repeat", - amount: 2, - }); - expect(existing._tag).toBe("TaskExisting"); - expect(existing.handle.generation).toBe(created.handle.generation); - expect(existing.task.payload.amount).toBe(1); - expect(existing.task.success).toBe("old-result"); - - const next = yield* TaskQueue.offer( - queue, - { userId: "repeat", amount: 2 }, - { onDuplicate: "new-generation", onSuccessPolicy: "keep" }, + + it.effect( + "complete interrupts its handler when the heartbeat loses ownership", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("tq-heartbeat-lease-lost"); + yield* TaskQueue.offer(queue, { userId: "owned", amount: 1 }); + + const handlerStarted = yield* Deferred.make(); + let handlerInterrupted = false; + const leaseLost = new TaskEngine.LeaseLost({ + prefix: queue.name, + taskId: "owned", + cause: new TaskEngine.TaskEngineError({ + reason: { _tag: "LeaseLost", operation: "extendLock" }, + cause: "test lease theft", + }), + }); + const losingEngine = TaskEngine.TaskEngine.of({ + ...engine, + extendLock: () => + Deferred.await(handlerStarted).pipe( + Effect.andThen(Effect.fail(leaseLost)), + ), + }); + + const error = yield* TaskQueue.complete(queue, () => + Deferred.succeed(handlerStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring( + Effect.sync(() => { + handlerInterrupted = true; + }), + ), + ), + ).pipe( + Effect.provideService(TaskEngine.TaskEngine, losingEngine), + Effect.flip, + ); + + expect(error._tag).toBe("LeaseLost"); + expect(handlerInterrupted).toBe(true); + expect(yield* getLists(queue.name)).toMatchObject({ + active: ["owned"], + success: [], + failed: [], + }); + }), ); - expect(next._tag).toBe("TaskCreated"); - expect(next.handle.generation).toBe(created.handle.generation + 1); - expect(next.task).toMatchObject({ - errors: [], - generation: 2, - payload: { userId: "repeat", amount: 2 }, - }); - expect(next.task.success).toBeUndefined(); - expect(yield* TaskQueue.wait(queue, created.handle)).toBe("old-result"); - expect(yield* getLists(queue.name)).toMatchObject({ - active: [], - success: [], - wait: ["repeat"], - }); - }).pipe(TestRuntime.runPromise)); - - test("complete routes a handler failure to the engine and reports false", () => - Effect.gen(function* () { - const queue = makeQueue("tq-complete-fail"); - yield* TaskQueue.offer( - queue, - { userId: "u3", amount: 7 }, - { - onFailurePolicy: "mark-as-failure", - }, + + it.effect( + "heartbeat transport failures retry only within the configured bound", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("tq-heartbeat-retry"); + yield* TaskQueue.offer(queue, { userId: "retry", amount: 1 }); + let renewals = 0; + const heartbeatRecovered = yield* Deferred.make(); + const recoveringEngine = TaskEngine.TaskEngine.of({ + ...engine, + extendLock: (prefix, id, token, timeout) => + Effect.sync(() => ++renewals).pipe( + Effect.flatMap((attempt) => + attempt < 3 + ? Effect.fail( + new TaskEngine.TaskEngineError({ + reason: { + _tag: "TransportFailure", + operation: "extendLock", + }, + cause: new Error( + "temporary Redis transport failure", + ), + }), + ) + : engine + .extendLock(prefix, id, token, timeout) + .pipe( + Effect.tap(() => + Deferred.succeed(heartbeatRecovered, undefined), + ), + ), + ), + ), + }); + + const processed = yield* TaskQueue.completeOne( + queue, + () => Deferred.await(heartbeatRecovered).pipe(Effect.as("ok")), + { + lockTimeout: "1 second", + lockRefresh: "100 millis", + heartbeatRetryDelay: "1 millis", + heartbeatRetryCount: 2, + }, + ).pipe( + Effect.provideService(TaskEngine.TaskEngine, recoveringEngine), + ); + + expect(processed).toBe(true); + expect(renewals).toBe(3); + expect(yield* engine.getTask(queue.name, "retry")).toBeNull(); + }), ); - const done = yield* TaskQueue.complete(queue, () => - Effect.fail({ reason: "nope" }), + it.effect("a duplicate offer does not mutate a leased generation", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("tq-duplicate-leased"); + const created = yield* TaskQueue.offer(queue, { + userId: "leased", + amount: 1, + }); + yield* takeTask(engine, queue.name, 30_000); + + const existing = yield* TaskQueue.offer(queue, { + userId: "leased", + amount: 999, + }); + + expect(existing._tag).toBe("TaskExisting"); + expect(existing.handle.generation).toBe(created.handle.generation); + expect(existing.task.payload.amount).toBe(1); + expect(yield* getLists(queue.name)).toMatchObject({ + active: ["leased"], + wait: [], + }); + }), ); - expect(done).toBe("u3"); - const lists = yield* getLists(queue.name); - expect(lists.failed).toEqual(["u3"]); - }).pipe(TestRuntime.runPromise)); - - test("a failing task with a retry schedule lands on the scheduled list", () => - Effect.gen(function* () { - const queue = makeRetryQueue("tq-retry-schedule"); - yield* TaskQueue.offer(queue, { userId: "s1" }); - - yield* TaskQueue.complete(queue, () => Effect.fail({ reason: "boom" })); - - const lists = yield* getLists(queue.name); - // Schedule.spaced("10 seconds") → first retry ~10s out, so it is scheduled. - expect(lists.scheduled).toEqual(["s1"]); - expect(lists.failed).toEqual([]); - }).pipe(TestRuntime.runPromise)); - - test("maxRetries cap of 0 skips retries even with a schedule", () => - Effect.gen(function* () { - // cap at 0 → the first failure can't retry (0 errors is not < 0) - const queue = makeRetryQueue("tq-retry-cap", { maxRetries: 0 }); - yield* TaskQueue.offer( - queue, - { userId: "c1" }, - { onFailurePolicy: "mark-as-failure" }, + it.effect( + "duplicate offers preserve delayed and retry-scheduled generations", + () => + Effect.gen(function* () { + const delayedQueue = yield* makeQueue("tq-duplicate-delayed"); + yield* TaskQueue.offer( + delayedQueue, + { userId: "delayed", amount: 1 }, + { delay: 60_000 }, + ); + const delayed = yield* TaskQueue.offer( + delayedQueue, + { userId: "delayed", amount: 999 }, + { delay: 0 }, + ); + expect(delayed).toMatchObject({ + _tag: "TaskExisting", + task: { delay: 60_000, payload: { amount: 1 } }, + }); + expect(yield* getLists(delayedQueue.name)).toMatchObject({ + scheduled: ["delayed"], + wait: [], + }); + + const retryQueue = yield* makeRetryQueue("tq-duplicate-retry"); + yield* TaskQueue.offer(retryQueue, { userId: "retry" }); + yield* TaskQueue.complete(retryQueue, () => + Effect.fail({ reason: "first" }), + ); + const retry = yield* TaskQueue.offer(retryQueue, { + userId: "retry", + }); + expect(retry).toMatchObject({ + _tag: "TaskExisting", + task: { errors: [{ error: { reason: "first" } }] }, + }); + expect(yield* getLists(retryQueue.name)).toMatchObject({ + scheduled: ["retry"], + wait: [], + }); + }), ); - yield* TaskQueue.complete(queue, () => Effect.fail({ reason: "boom" })); - - const lists = yield* getLists(queue.name); - expect(lists.scheduled).toEqual([]); - expect(lists.failed).toEqual(["c1"]); - }).pipe(TestRuntime.runPromise)); - - test("per-offer maxRetries overrides the definition cap", () => - Effect.gen(function* () { - // definition would allow 5 retries, but the offer caps at 0 → no retry - const queue = makeRetryQueue("tq-retry-override", { maxRetries: 5 }); - yield* TaskQueue.offer( - queue, - { userId: "o1" }, - { maxRetries: 0, onFailurePolicy: "mark-as-failure" }, + it.effect( + "an explicit new generation starts clean after terminal settlement", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-new-generation"); + const created = yield* TaskQueue.offer( + queue, + { userId: "repeat", amount: 1 }, + { onSuccessPolicy: "keep" }, + ); + yield* TaskQueue.complete(queue, () => + Effect.succeed("old-result"), + ); + + const existing = yield* TaskQueue.offer(queue, { + userId: "repeat", + amount: 2, + }); + expect(existing._tag).toBe("TaskExisting"); + expect(existing.handle.generation).toBe(created.handle.generation); + expect(existing.task.payload.amount).toBe(1); + expect(existing.task.success).toBe("old-result"); + + const next = yield* TaskQueue.offer( + queue, + { userId: "repeat", amount: 2 }, + { onDuplicate: "new-generation", onSuccessPolicy: "keep" }, + ); + expect(next._tag).toBe("TaskCreated"); + expect(next.handle.generation).toBe(created.handle.generation + 1); + expect(next.task).toMatchObject({ + errors: [], + generation: 2, + payload: { userId: "repeat", amount: 2 }, + }); + expect(next.task.success).toBeUndefined(); + expect(yield* TaskQueue.wait(queue, created.handle)).toBe( + "old-result", + ); + expect(yield* getLists(queue.name)).toMatchObject({ + active: [], + success: [], + wait: ["repeat"], + }); + }), ); - yield* TaskQueue.complete(queue, () => Effect.fail({ reason: "boom" })); - - const lists = yield* getLists(queue.name); - expect(lists.scheduled).toEqual([]); - expect(lists.failed).toEqual(["o1"]); - }).pipe(TestRuntime.runPromise)); -}); - -describe("TaskQueue managed task context", () => { - test("a nested offer records creator provenance without implicit retention", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const parent = makeQueue("tq-ctx-parent"); - const child = makeQueue("tq-ctx-child"); - yield* TaskQueue.offer(parent, { userId: "p1", amount: 1 }); - - let during: - | { child: EngineTask | null; parent: EngineTask | null } - | undefined; - yield* TaskQueue.complete(parent, () => - Effect.gen(function* () { - yield* TaskQueue.offer(child, { userId: "c1", amount: 2 }); - during = { - child: yield* engine.getTask(child.name, "c1"), - parent: yield* engine.getTask(parent.name, "p1"), - }; - return "ok"; - }).pipe(Effect.orDie), + it.effect( + "complete routes a handler failure to the engine and reports false", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-complete-fail"); + yield* TaskQueue.offer( + queue, + { userId: "u3", amount: 7 }, + { + onFailurePolicy: "mark-as-failure", + }, + ); + + const done = yield* TaskQueue.complete(queue, () => + Effect.fail({ reason: "nope" }), + ); + + expect(done).toBe("u3"); + const lists = yield* getLists(queue.name); + expect(lists.failed).toEqual(["u3"]); + }), ); - expect(during?.child?.creator).toEqual({ - queue: "~effectmq:v1:tq-ctx-parent", - id: "p1", - generation: 1, - }); - - // Parent settlement neither cancels, joins, nor otherwise affects the - // independently runnable spawned task. - expect((yield* getLists(child.name)).wait).toContain("c1"); - - yield* TaskQueue.complete(child, () => Effect.succeed("ok")); - expect(yield* engine.getTask(child.name, "c1")).toBeNull(); - }).pipe(TestRuntime.runPromise)); - - test("a creator failure does not fail, cancel, or join its spawned task", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const creator = makeQueue("tq-ctx-failure-creator"); - const spawned = makeQueue("tq-ctx-failure-spawned"); - yield* TaskQueue.offer(creator, { userId: "creator", amount: 1 }); - - yield* TaskQueue.complete(creator, () => - TaskQueue.offer(spawned, { userId: "spawned", amount: 2 }).pipe( - Effect.andThen(Effect.fail({ reason: "creator failed" })), - ), + it.effect( + "a failing task with a retry schedule lands on the scheduled list", + () => + Effect.gen(function* () { + const queue = yield* makeRetryQueue("tq-retry-schedule"); + yield* TaskQueue.offer(queue, { userId: "s1" }); + + yield* TaskQueue.complete(queue, () => + Effect.fail({ reason: "boom" }), + ); + + const lists = yield* getLists(queue.name); + // Schedule.spaced("10 seconds") → first retry ~10s out, so it is scheduled. + expect(lists.scheduled).toEqual(["s1"]); + expect(lists.failed).toEqual([]); + }), ); - const independent = yield* engine.getTask(spawned.name, "spawned"); - expect(independent?.creator).toMatchObject({ - queue: `~effectmq:v1:${creator.name}`, - id: "creator", - }); - expect((yield* getLists(spawned.name)).wait).toEqual(["spawned"]); - expect(independent?.errors).toEqual([]); - }).pipe(TestRuntime.runPromise)); - - test("explicit retention keeps the spawned result until the current task settles", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const parent = makeQueue("tq-ctx-det-parent"); - const child = makeQueue("tq-ctx-det-child"); - yield* TaskQueue.offer(parent, { userId: "p1", amount: 1 }); - - let during: - | { child: EngineTask | null; parent: EngineTask | null } - | undefined; - let holdCountDuring = 0; - yield* TaskQueue.complete(parent, () => + it.effect("maxRetries cap of 0 skips retries even with a schedule", () => Effect.gen(function* () { + // cap at 0 → the first failure can't retry (0 errors is not < 0) + const queue = yield* makeRetryQueue("tq-retry-cap", { + maxRetries: 0, + }); yield* TaskQueue.offer( - child, - { userId: "c1", amount: 2 }, - { retainResultUntil: "current-task-settles" }, + queue, + { userId: "c1" }, + { onFailurePolicy: "mark-as-failure" }, ); - during = { - child: yield* engine.getTask(child.name, "c1"), - parent: yield* engine.getTask(parent.name, "p1"), - }; - holdCountDuring = yield* redis.send( - "SCARD", - "~effectmq:v1:tq-ctx-det-child:task:c1:1:retained-by", + + yield* TaskQueue.complete(queue, () => + Effect.fail({ reason: "boom" }), ); - return "ok"; - }).pipe(Effect.orDie), + + const lists = yield* getLists(queue.name); + expect(lists.scheduled).toEqual([]); + expect(lists.failed).toEqual(["c1"]); + }), ); - expect(holdCountDuring).toBe(1); - expect(during?.child?.creator).toEqual({ - queue: "~effectmq:v1:tq-ctx-det-parent", - id: "p1", - generation: 1, - }); - expect( - yield* redis.send( - "SCARD", - "~effectmq:v1:tq-ctx-det-child:task:c1:1:retained-by", - ), - ).toBe(0); - }).pipe(TestRuntime.runPromise)); - - test("offering outside a handler records no holder or creator", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("tq-ctx-none"); - yield* TaskQueue.offer(queue, { userId: "u1", amount: 1 }); - - const task = yield* engine.getTask(queue.name, "u1"); - expect(task?.creator).toBeUndefined(); - }).pipe(TestRuntime.runPromise)); - - test("explicit current-task retention is rejected outside a handler", () => - Effect.gen(function* () { - const queue = makeQueue("tq-ctx-retention-required"); - const error = yield* TaskQueue.offer( - queue, - { userId: "u1", amount: 1 }, - { retainResultUntil: "current-task-settles" }, - ).pipe(Effect.flip); - - expect(error).toBeInstanceOf(TaskQueue.RetentionContextRequired); - }).pipe(TestRuntime.runPromise)); - - test("retention relationships stop at the holder's configured cap", () => - Effect.gen(function* () { - const parentDefinition = Task.make({ - name: "tq-ctx-limit-parent", - payload: { userId: Schema.String, amount: Schema.Number }, - success: Schema.String, - error: Schema.Struct({ reason: Schema.String }), - storageLimits: { maxRelationships: 1 }, - idempotencyKey: (payload) => payload.userId, - }); - const parent = TaskQueue.make(parentDefinition.name, parentDefinition); - const first = makeQueue("tq-ctx-limit-first"); - const second = makeQueue("tq-ctx-limit-second"); - yield* TaskQueue.offer(parent, { userId: "parent", amount: 1 }); - - let limitError: unknown; - yield* TaskQueue.complete(parent, () => + it.effect("per-offer maxRetries overrides the definition cap", () => Effect.gen(function* () { + // definition would allow 5 retries, but the offer caps at 0 → no retry + const queue = yield* makeRetryQueue("tq-retry-override", { + maxRetries: 5, + }); yield* TaskQueue.offer( - first, - { userId: "first", amount: 1 }, - { retainResultUntil: "current-task-settles" }, + queue, + { userId: "o1" }, + { maxRetries: 0, onFailurePolicy: "mark-as-failure" }, ); - limitError = yield* TaskQueue.offer( - second, - { userId: "second", amount: 1 }, - { retainResultUntil: "current-task-settles" }, - ).pipe(Effect.flip); - return "ok"; - }).pipe(Effect.orDie), + + yield* TaskQueue.complete(queue, () => + Effect.fail({ reason: "boom" }), + ); + + const lists = yield* getLists(queue.name); + expect(lists.scheduled).toEqual([]); + expect(lists.failed).toEqual(["o1"]); + }), + ); + }); + + describe("TaskQueue managed task context", () => { + it.effect( + "a nested offer records creator provenance without implicit retention", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const parent = yield* makeQueue("tq-ctx-parent"); + const child = yield* makeQueue("tq-ctx-child"); + yield* TaskQueue.offer(parent, { userId: "p1", amount: 1 }); + + let during: + | { child: EngineTask | null; parent: EngineTask | null } + | undefined; + yield* TaskQueue.complete(parent, () => + Effect.gen(function* () { + yield* TaskQueue.offer(child, { userId: "c1", amount: 2 }); + during = { + child: yield* engine.getTask(child.name, "c1"), + parent: yield* engine.getTask(parent.name, "p1"), + }; + return "ok"; + }).pipe(Effect.orDie), + ); + + expect(during?.child?.creator).toEqual({ + queue: "~effectmq:v1:tq-ctx-parent", + id: "p1", + generation: 1, + }); + + // Parent settlement neither cancels, joins, nor otherwise affects the + // independently runnable spawned task. + expect((yield* getLists(child.name)).wait).toContain("c1"); + + yield* TaskQueue.complete(child, () => Effect.succeed("ok")); + expect(yield* engine.getTask(child.name, "c1")).toBeNull(); + }), + ); + + it.effect( + "a creator failure does not fail, cancel, or join its spawned task", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const creator = yield* makeQueue("tq-ctx-failure-creator"); + const spawned = yield* makeQueue("tq-ctx-failure-spawned"); + yield* TaskQueue.offer(creator, { userId: "creator", amount: 1 }); + + yield* TaskQueue.complete(creator, () => + TaskQueue.offer(spawned, { userId: "spawned", amount: 2 }).pipe( + Effect.orDie, + Effect.andThen(Effect.fail({ reason: "creator failed" })), + ), + ); + + const independent = yield* engine.getTask(spawned.name, "spawned"); + expect(independent?.creator).toMatchObject({ + queue: `~effectmq:v1:${creator.name}`, + id: "creator", + }); + expect((yield* getLists(spawned.name)).wait).toEqual(["spawned"]); + expect(independent?.errors).toEqual([]); + }), ); - expect(limitError).toMatchObject({ - _tag: "StorageCountLimitExceeded", - resource: "relationships", - scope: "holder", - actualCount: 1, - maxCount: 1, - }); - }).pipe(TestRuntime.runPromise)); -}); + it.effect( + "explicit retention keeps the spawned result until the current task settles", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const parent = yield* makeQueue("tq-ctx-det-parent"); + const child = yield* makeQueue("tq-ctx-det-child"); + yield* TaskQueue.offer(parent, { userId: "p1", amount: 1 }); + + let during: + | { child: EngineTask | null; parent: EngineTask | null } + | undefined; + let holdCountDuring = 0; + yield* TaskQueue.complete(parent, () => + Effect.gen(function* () { + yield* TaskQueue.offer( + child, + { userId: "c1", amount: 2 }, + { retainResultUntil: "current-task-settles" }, + ); + during = { + child: yield* engine.getTask(child.name, "c1"), + parent: yield* engine.getTask(parent.name, "p1"), + }; + holdCountDuring = yield* redis.send( + "SCARD", + "~effectmq:v1:tq-ctx-det-child:task:c1:1:retained-by", + ); + return "ok"; + }).pipe(Effect.orDie), + ); + + expect(holdCountDuring).toBe(1); + expect(during?.child?.creator).toEqual({ + queue: "~effectmq:v1:tq-ctx-det-parent", + id: "p1", + generation: 1, + }); + expect( + yield* redis.send( + "SCARD", + "~effectmq:v1:tq-ctx-det-child:task:c1:1:retained-by", + ), + ).toBe(0); + }), + ); + + it.effect("offering outside a handler records no holder or creator", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("tq-ctx-none"); + yield* TaskQueue.offer(queue, { userId: "u1", amount: 1 }); + + const task = yield* engine.getTask(queue.name, "u1"); + expect(task?.creator).toBeUndefined(); + }), + ); + + it.effect( + "explicit current-task retention is rejected outside a handler", + () => + Effect.gen(function* () { + const queue = yield* makeQueue("tq-ctx-retention-required"); + const error = yield* TaskQueue.offer( + queue, + { userId: "u1", amount: 1 }, + { retainResultUntil: "current-task-settles" }, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(TaskQueue.RetentionContextRequired); + }), + ); + + it.effect( + "retention relationships stop at the holder's configured cap", + () => + Effect.gen(function* () { + const parentDefinition = yield* Task.make({ + name: "tq-ctx-limit-parent", + payload: { userId: Schema.String, amount: Schema.Number }, + success: Schema.String, + error: Schema.Struct({ reason: Schema.String }), + storageLimits: { maxRelationships: 1 }, + idempotencyKey: (payload) => payload.userId, + }); + const parent = TaskQueue.make( + parentDefinition.name, + parentDefinition, + ); + const first = yield* makeQueue("tq-ctx-limit-first"); + const second = yield* makeQueue("tq-ctx-limit-second"); + yield* TaskQueue.offer(parent, { userId: "parent", amount: 1 }); + + let limitError: unknown; + yield* TaskQueue.complete(parent, () => + Effect.gen(function* () { + yield* TaskQueue.offer( + first, + { userId: "first", amount: 1 }, + { retainResultUntil: "current-task-settles" }, + ); + limitError = yield* TaskQueue.offer( + second, + { userId: "second", amount: 1 }, + { retainResultUntil: "current-task-settles" }, + ).pipe(Effect.flip); + return "ok"; + }).pipe(Effect.orDie), + ); + + expect(limitError).toMatchObject({ + _tag: "StorageCountLimitExceeded", + resource: "relationships", + scope: "holder", + actualCount: 1, + maxCount: 1, + }); + }), + ); + }); + }, +); diff --git a/src/TaskQueue.ts b/src/TaskQueue.ts index df05656..e0d54f1 100644 --- a/src/TaskQueue.ts +++ b/src/TaskQueue.ts @@ -6,25 +6,28 @@ * * @module */ -import { Schedule, Stream } from "effect"; +import * as Clock from "effect/Clock"; +import type * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Function from "effect/Function"; import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { EngineTerminalResult } from "./EngineRecord.js"; +import { nextRunAt } from "./RetrySchedule.js"; import { type CompletionPolicy, decodeTask, - type EngineTerminalResult, TaskErrorSchema, -} from "./Schemas.js"; +} from "./TaskRecord.js"; import * as StorageProtocol from "./StorageProtocol.js"; import type * as Task from "./Task.js"; import * as TaskContext from "./TaskContext.js"; import * as TaskEngine from "./TaskEngine.js"; -import { nextRunAt } from "./utils.js"; const TypeId = "~effectmq/TaskQueue" as const; @@ -42,10 +45,11 @@ export interface TaskQueue< Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, + IdentityR = Crypto.Crypto, > { readonly [TypeId]: typeof TypeId; readonly name: string; - readonly task: Task.TaskDefinition; + readonly task: Task.TaskDefinition; } /** * Creates a typed queue descriptor from a stable name and task definition. @@ -58,10 +62,11 @@ export const make = < Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, + IdentityR = never, >( name: string, - taskDefinition: Task.TaskDefinition, -): TaskQueue => { + taskDefinition: Task.TaskDefinition, +): TaskQueue => { return { [TypeId]: TypeId, name, @@ -95,15 +100,20 @@ const takeAvailable = Effect.fnUntraced(function* < Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, + IdentityR = never, >( - queue: TaskQueue, + queue: TaskQueue, options?: TakeOptions, ): Effect.fn.Return< TaskAttempt | null, | StorageProtocol.StorageProtocolError | TaskEngine.TaskEngineError | Schema.SchemaError, - TaskEngine.TaskEngine | Payload["DecodingServices"] + | TaskEngine.TaskEngine + | Crypto.Crypto + | Payload["DecodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"] > { const engine = yield* TaskEngine.TaskEngine; const poolInterval = Duration.toMillis( @@ -137,7 +147,11 @@ const takeUnsafe = Effect.fnUntraced(function* < Success extends Schema.Top = Schema.Void, Error extends Schema.Top = Schema.Never, R = never, ->(queue: TaskQueue, options?: TakeOptions) { + IdentityR = never, +>( + queue: TaskQueue, + options?: TakeOptions, +) { const attempt = yield* takeAvailable(queue, options); if (attempt === null) { return yield* Effect.die("Polling take unexpectedly returned null"); @@ -198,37 +212,6 @@ export class RetentionContextRequired extends Data.TaggedError( readonly taskId: string; }> {} -const causeText = (cause: unknown, depth = 0): string => { - if (depth >= 4) return String(cause); - if (typeof cause !== "object" || cause === null || !("cause" in cause)) { - return String(cause); - } - return `${String(cause)} ${causeText(cause.cause, depth + 1)}`; -}; - -const isIndeterminateConnectionFailure = ( - error: TaskEngine.TaskEngineError, -): boolean => - /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket closed|connection (?:is )?closed|connection lost|read only/i.test( - causeText(error.cause), - ); - -const relationshipLimit = ( - error: TaskEngine.TaskEngineError, -): StorageProtocol.StorageCountLimitExceeded | undefined => { - const match = causeText(error.cause).match( - /STORAGE_RELATIONSHIP_LIMIT (holder|retained) (\d+)/, - ); - if (!match) return undefined; - const maxCount = Number(match[2]); - return new StorageProtocol.StorageCountLimitExceeded({ - resource: "relationships", - scope: match[1] as "holder" | "retained", - actualCount: maxCount, - maxCount, - }); -}; - declare const TaskHandleSuccess: unique symbol; declare const TaskHandleError: unique symbol; @@ -301,6 +284,89 @@ export class CallerTimeout extends Data.TaggedError("CallerTimeout")<{ readonly timeout: Duration.Input; }> {} +/** Recoverable failures produced while offering a task generation. */ +export type OfferError = + | IndeterminateWriteError + | RetentionContextRequired + | Task.TaskIdentityGenerationError + | StorageProtocol.StorageProtocolError + | TaskEngine.TaskEngineError + | Schema.SchemaError; + +/** Services required to encode an offer and decode its returned task record. */ +export type OfferRequirements< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + IdentityR, +> = + | TaskEngine.TaskEngine + | Payload["EncodingServices"] + | Payload["DecodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"] + | IdentityR; + +/** Infrastructure and codec failures produced while completing an attempt. */ +export type CompleteError = + | StorageProtocol.StorageProtocolError + | TaskEngine.LeaseLost + | TaskEngine.TaskEngineError + | Schema.SchemaError; + +/** Services required by completion, including handler and retry environments. */ +export type CompleteRequirements< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + QueueR, + HandlerR, +> = + | TaskEngine.TaskEngine + | Crypto.Crypto + | QueueR + | HandlerR + | Payload["DecodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"] + | Success["EncodingServices"] + | Error["EncodingServices"]; + +/** Recoverable terminal-protocol failures produced by {@link wait}. */ +export type WaitError = + | TaskFailed + | TaskNotFound + | ResultExpired + | CallerTimeout + | TaskEngine.CursorExpired + | TaskEngine.TaskEngineError + | StorageProtocol.StorageProtocolError + | Schema.SchemaError; + +/** Services required to decode durable task and terminal event state. */ +export type WaitRequirements< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> = + | TaskEngine.TaskEngine + | Payload["DecodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"]; + +/** Exact failure union of {@link offer} followed by {@link wait}. */ +export type ExecuteError = OfferError | WaitError; + +/** Exact service union of {@link offer} followed by {@link wait}. */ +export type ExecuteRequirements< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + IdentityR, +> = + | OfferRequirements + | WaitRequirements; + /** * The generation-safe result of offering a task. * @@ -343,19 +409,19 @@ export type OfferOutcome< * import { Effect, Schema } from "effect" * import { Task, TaskEngine, TaskQueue } from "@effectmq/core" * - * const resize = Task.make({ - * name: "resize-image", - * payload: { imageId: Schema.String }, - * success: Schema.String, - * error: Schema.String, - * idempotencyKey: ({ imageId }) => imageId - * }) - * const images = TaskQueue.make("images", resize) - * - * const enqueue = TaskQueue.offer(images, { imageId: "img-42" }).pipe( - * Effect.map(({ handle }) => handle), - * Effect.provide(TaskEngine.layer()) - * ) + * const enqueue = Effect.gen(function* () { + * const resize = yield* Task.make({ + * name: "resize-image", + * payload: { imageId: Schema.String }, + * success: Schema.String, + * error: Schema.String, + * idempotencyKey: ({ imageId }) => imageId + * }) + * const images = TaskQueue.make("images", resize) + * return yield* TaskQueue.offer(images, { imageId: "img-42" }).pipe( + * Effect.map(({ handle }) => handle) + * ) + * }).pipe(Effect.provide(TaskEngine.layer())) * ``` * * @category Operations @@ -366,21 +432,18 @@ export const offer = Effect.fnUntraced(function* < Success extends Schema.Top, Error extends Schema.Top, R = never, + IdentityR = never, >( - queue: TaskQueue, + queue: TaskQueue, payload: Payload["Type"], options?: TaskOptions, ): Effect.fn.Return< OfferOutcome, - | IndeterminateWriteError - | RetentionContextRequired - | StorageProtocol.StorageProtocolError - | TaskEngine.TaskEngineError - | Schema.SchemaError, - TaskEngine.TaskEngine | Payload["DecodingServices"] + OfferError, + OfferRequirements > { const encodePayload = Schema.encodeEffect(queue.task.payloadSchema); - const id = options?.taskId ?? queue.task.idempotencyKey(payload); + const id = options?.taskId ?? (yield* queue.task.idempotencyKey(payload)); const engine = yield* TaskEngine.TaskEngine; const currentTask = yield* TaskContext.currentTask; if (options?.retainResultUntil && currentTask === undefined) { @@ -431,17 +494,30 @@ export const offer = Effect.fnUntraced(function* < | StorageProtocol.StorageCountLimitExceeded | TaskEngine.TaskEngineError > => { - const limit = relationshipLimit(cause); - if (limit) return Effect.fail(limit); - return isIndeterminateConnectionFailure(cause) - ? Effect.fail( + switch (cause.reason._tag) { + case "RelationshipLimit": + return Effect.fail( + new StorageProtocol.StorageCountLimitExceeded({ + resource: "relationships", + scope: cause.reason.scope, + actualCount: cause.reason.maxCount, + maxCount: cause.reason.maxCount, + }), + ); + case "IndeterminateCommit": + return Effect.fail( new IndeterminateWriteError({ cause, queue: queue.name, taskId: id, }), - ) - : Effect.fail(cause); + ); + case "TransportFailure": + case "ScriptFailure": + case "InvalidReply": + case "LeaseLost": + return Effect.fail(cause); + } }, ), ); @@ -473,13 +549,14 @@ export const offer = Effect.fnUntraced(function* < * @category Operations * @since 0.1.0 */ -export const extendLock = Effect.fnUntraced(function* < +const extendLock = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, R = never, + IdentityR = never, >( - queue: TaskQueue, + queue: TaskQueue, attempt: TaskAttempt, lockTimeout?: Duration.Input, ) { @@ -496,39 +573,14 @@ export const extendLock = Effect.fnUntraced(function* < ); }); -/** - * Voluntarily releases a low-level attempt back to runnable work. - * - * The exact lease token is required. Releasing does not record a stalled - * failure; lease expiry recovery does. - * - * @category Operations - * @since 0.1.0 - */ -export const release = Effect.fnUntraced(function* < - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, - R = never, ->( - queue: TaskQueue, - attempt: TaskAttempt, -) { - const engine = yield* TaskEngine.TaskEngine; - return yield* engine.removeLock( - queue.name, - attempt.task.id, - attempt.leaseToken, - ); -}); - const succeed = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, R = never, + IdentityR = never, >( - queue: TaskQueue, + queue: TaskQueue, attempt: TaskAttempt, success: Success["Type"], ) { @@ -556,8 +608,9 @@ const fail = Effect.fnUntraced(function* < Success extends Schema.Top, Error extends Schema.Top, R = never, + IdentityR = never, >( - queue: TaskQueue, + queue: TaskQueue, attempt: TaskAttempt, failure: Error["Type"], ) { @@ -570,12 +623,13 @@ const fail = Effect.fnUntraced(function* < ? attempt.task.maxRetries : queue.task.maxRetries; + const failedAt = new Date(yield* Clock.currentTimeMillis); const retryAt = queue.task.retrySchedule && attempt.task.handlerFailureCount < maxRetries ? yield* nextRunAt( queue.task.retrySchedule, new Date(attempt.task.createdAt.getTime() + attempt.task.delay), - [...attempt.task.errors, { timestamp: new Date(), error: failure }], + [...attempt.task.errors, { timestamp: failedAt, error: failure }], ) : undefined; return yield* engine.writeError( @@ -634,9 +688,10 @@ const processAttempt = Effect.fnUntraced(function* < Success extends Schema.Top, Error extends Schema.Top, TR = never, + IdentityR = never, R = never, >( - self: TaskQueue, + self: TaskQueue, attempt: TaskAttempt, handler: TaskHandler, options?: ProcessingOptions, @@ -672,15 +727,11 @@ const processAttempt = Effect.fnUntraced(function* < ); const handlerResult = handler(attempt.task).pipe( - Effect.provide( - TaskContext.layer({ - currentTask: { - queue: self.name, - id: attempt.task.id, - generation: attempt.task.generation, - }, - }), - ), + Effect.provideService(TaskContext.currentTask, { + queue: self.name, + id: attempt.task.id, + generation: attempt.task.generation, + }), Effect.result, ); const result = yield* Effect.raceFirst(handlerResult, heartBeat); @@ -711,18 +762,19 @@ const processAttempt = Effect.fnUntraced(function* < * import { Effect, Schema } from "effect" * import { Task, TaskQueue } from "@effectmq/core" * - * const greet = Task.make({ - * name: "greet", - * payload: { name: Schema.String }, - * success: Schema.String, - * error: Schema.String + * const processNext = Effect.gen(function* () { + * const greet = yield* Task.make({ + * name: "greet", + * payload: { name: Schema.String }, + * success: Schema.String, + * error: Schema.String + * }) + * const greetings = TaskQueue.make("greetings", greet) + * return yield* TaskQueue.complete( + * greetings, + * ({ payload }) => Effect.succeed(`Hello, ${payload.name}!`) + * ) * }) - * const greetings = TaskQueue.make("greetings", greet) - * - * const processNext = TaskQueue.complete( - * greetings, - * ({ payload }) => Effect.succeed(`Hello, ${payload.name}!`) - * ) * ``` * * @category Operations @@ -734,65 +786,52 @@ export const complete: { Success extends Schema.Top, Error extends Schema.Top, TR = never, + IdentityR = never, R = never, >( handler: TaskHandler, ): ( - self: TaskQueue, + self: TaskQueue, ) => Effect.Effect< string, - | StorageProtocol.StorageProtocolError - | TaskEngine.LeaseLost - | TaskEngine.TaskEngineError - | Schema.SchemaError, - TR | R + CompleteError, + CompleteRequirements >; < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, TR = never, + IdentityR = never, R = never, >( - self: TaskQueue, + self: TaskQueue, handler: TaskHandler, ): Effect.Effect< string, - | StorageProtocol.StorageProtocolError - | TaskEngine.LeaseLost - | TaskEngine.TaskEngineError - | Schema.SchemaError, - TR | R + CompleteError, + CompleteRequirements >; } = Function.dual( 2, - < + Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, TR = never, + IdentityR = never, R = never, >( - self: TaskQueue, + self: TaskQueue, handler: TaskHandler, - ): Effect.Effect< + ): Effect.fn.Return< string, - | StorageProtocol.StorageProtocolError - | Schema.SchemaError - | TaskEngine.LeaseLost - | TaskEngine.TaskEngineError, - | TaskEngine.TaskEngine - | TR - | R - | Payload["DecodingServices"] - | Success["EncodingServices"] - | Error["EncodingServices"] - > => { - return Effect.gen(function* () { - const attempt = yield* takeUnsafe(self); - return yield* processAttempt(self, attempt, handler); - }); - }, + CompleteError, + CompleteRequirements + > { + const attempt = yield* takeUnsafe(self); + return yield* processAttempt(self, attempt, handler); + }), ); /** @@ -807,12 +846,17 @@ export const completeOne = Effect.fnUntraced(function* < Success extends Schema.Top, Error extends Schema.Top, TR = never, + IdentityR = never, R = never, >( - self: TaskQueue, + self: TaskQueue, handler: TaskHandler, options?: ProcessingOptions, -) { +): Effect.fn.Return< + boolean, + CompleteError, + CompleteRequirements +> { const attempt = yield* takeAvailable(self, { poll: false, lockTimeout: options?.lockTimeout, @@ -844,8 +888,10 @@ export const stream = < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, + QueueR, + IdentityR, >( - queue: TaskQueue, + queue: TaskQueue, { cursor, pollInterval, @@ -853,80 +899,96 @@ export const stream = < cursor?: string; pollInterval?: Duration.Duration; } = {}, -) => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; +) => Stream.unwrap(streamEffect(queue, { cursor, pollInterval })); - return engine.stream(queue.name, { cursor, pollInterval }).pipe( - Stream.mapEffect((event) => - Effect.gen(function* () { - if (event._tag === "task.created") { - const task = event.payload.newTask; - return { - ...event, - payload: { - ...event.payload, - newTask: yield* decodeTask(queue.task, task), - }, - }; - } - if (event._tag === "task.updated") { - const { existingTask, newTask } = event.payload; - return { - ...event, - payload: { - ...event.payload, - existingTask: yield* decodeTask(queue.task, existingTask), - newTask: yield* decodeTask(queue.task, newTask), - }, - }; - } - if (event._tag === "task.failed") { - const decodeError = Schema.decodeEffect(queue.task.errorSchema); - const rawFailure = event.payload.error; - const builtIn = - typeof rawFailure === "object" && - rawFailure !== null && - "_tag" in rawFailure && - Object.values(StorageProtocol.builtInErrorTags).includes( - rawFailure._tag as never, - ); - const failure = builtIn - ? rawFailure - : yield* StorageProtocol.decodeValue( - rawFailure, - queue.task.schemaId, - "failure", - ); - return { - ...event, - payload: { - ...event.payload, - error: builtIn ? failure : yield* decodeError(failure), - }, - }; - } - if (event._tag === "task.completed") { - const decodeSuccess = Schema.decodeEffect(queue.task.successSchema); - const success = yield* StorageProtocol.decodeValue( - event.payload.success, - queue.task.schemaId, - "success", +const streamEffect = Effect.fnUntraced(function* < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + QueueR, + IdentityR, +>( + queue: TaskQueue, + { + cursor, + pollInterval, + }: { + cursor?: string; + pollInterval?: Duration.Duration; + } = {}, +) { + const engine = yield* TaskEngine.TaskEngine; + + return engine.stream(queue.name, { cursor, pollInterval }).pipe( + Stream.mapEffect((event) => + Effect.gen(function* () { + if (event._tag === "task.created") { + const task = event.payload.newTask; + return { + ...event, + payload: { + ...event.payload, + newTask: yield* decodeTask(queue.task, task), + }, + }; + } + if (event._tag === "task.updated") { + const { existingTask, newTask } = event.payload; + return { + ...event, + payload: { + ...event.payload, + existingTask: yield* decodeTask(queue.task, existingTask), + newTask: yield* decodeTask(queue.task, newTask), + }, + }; + } + if (event._tag === "task.failed") { + const decodeError = Schema.decodeEffect(queue.task.errorSchema); + const rawFailure = event.payload.error; + const builtIn = + typeof rawFailure === "object" && + rawFailure !== null && + "_tag" in rawFailure && + Object.values(StorageProtocol.builtInErrorTags).includes( + rawFailure._tag as never, ); - return { - ...event, - payload: { - ...event.payload, - success: yield* decodeSuccess(success), - }, - }; - } + const failure = builtIn + ? rawFailure + : yield* StorageProtocol.decodeValue( + rawFailure, + queue.task.schemaId, + "failure", + ); + return { + ...event, + payload: { + ...event.payload, + error: builtIn ? failure : yield* decodeError(failure), + }, + }; + } + if (event._tag === "task.completed") { + const decodeSuccess = Schema.decodeEffect(queue.task.successSchema); + const success = yield* StorageProtocol.decodeValue( + event.payload.success, + queue.task.schemaId, + "success", + ); + return { + ...event, + payload: { + ...event.payload, + success: yield* decodeSuccess(success), + }, + }; + } - return event; - }), - ), - ); - }).pipe(Stream.unwrap); + return event; + }), + ), + ); +}); /** * Configures a caller-local deadline for {@link wait}. @@ -953,15 +1015,21 @@ export interface WaitOptions { * @category Operations * @since 0.2.0 */ -export const wait = < +export const wait = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, + QueueR, + IdentityR, >( - queue: TaskQueue, + queue: TaskQueue, handle: TaskHandle, options: WaitOptions = {}, -) => { +): Effect.fn.Return< + Success["Type"], + WaitError, + WaitRequirements +> { const operation = Effect.gen(function* () { if (handle.protocolVersion !== StorageProtocol.protocolVersion) { return yield* new StorageProtocol.UnsupportedProtocolVersion({ @@ -1124,7 +1192,7 @@ export const wait = < }); const timeout = options.timeout; - return timeout === undefined + return yield* timeout === undefined ? operation : operation.pipe( Effect.timeoutOrElse({ @@ -1138,7 +1206,7 @@ export const wait = < ), }), ); -}; +}); /** * Offer a task and await its outcome through the same generation-safe handle * protocol as {@link wait}. @@ -1156,14 +1224,16 @@ export const execute = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, + QueueR, + IdentityR, >( - queue: TaskQueue, + queue: TaskQueue, payload: Payload["Type"], options?: TaskOptions, ): Effect.fn.Return< Success["Type"], - Error["Type"], - TaskEngine.TaskEngine | Payload["DecodingServices"] + ExecuteError, + ExecuteRequirements > { const offered = yield* offer(queue, payload, options); return yield* wait(queue, offered.handle); diff --git a/src/TaskRecord.ts b/src/TaskRecord.ts new file mode 100644 index 0000000..fe576bb --- /dev/null +++ b/src/TaskRecord.ts @@ -0,0 +1,277 @@ +/** Public schemas and codecs for typed task records. @module */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import * as StorageProtocol from "./StorageProtocol.js"; + +export const CompletionPolicySchema = Schema.Literals([ + "delete", + "keep", + "mark-as-success", + "mark-as-failure", +]); +export type CompletionPolicy = typeof CompletionPolicySchema.Type; + +export const TaskIdentitySchema = Schema.Struct({ + queue: Schema.String, + id: Schema.String, + generation: Schema.Number, +}); +export type TaskIdentity = typeof TaskIdentitySchema.Type; + +export const TaskOutcomeSchema = Schema.Literals(["success", "failure"]); +export type TaskOutcome = typeof TaskOutcomeSchema.Type; + +export const DateFromNumberSchema = Schema.Number.pipe( + Schema.decodeTo(Schema.Date, { + decode: SchemaGetter.transform((value) => new Date(value)), + encode: SchemaGetter.transform((value) => value.getTime()), + }), +); + +export const errorEntrySchema = (error: Error) => + Schema.Struct({ + error, + timestamp: DateFromNumberSchema, + retryAt: Schema.optional(DateFromNumberSchema), + }); + +export interface ErrorEntry { + readonly error: Error; + readonly timestamp: Date; + readonly retryAt?: Date; +} + +export class StalledErrorSchema extends Schema.TaggedError()( + "~effectmq/Error/Stalled", + { timestamp: Schema.Number }, +) { + static of(timestamp: number) { + return new StalledErrorSchema({ timestamp }); + } +} + +export class CanceledErrorSchema extends Schema.TaggedError()( + "~effectmq/Error/Canceled", + { timestamp: Schema.Number }, +) { + static of(timestamp: number) { + return new CanceledErrorSchema({ timestamp }); + } +} + +export const TaskErrorSchema = Schema.Union([ + StalledErrorSchema, + CanceledErrorSchema, +]); +export type TaskErrorSchema = typeof TaskErrorSchema.Type; + +export interface Task< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> { + readonly _tag: "Task"; + readonly id: string; + readonly generation: number; + readonly name: string; + readonly payload: Payload["Type"]; + readonly success?: Success["Type"]; + readonly errors: readonly ErrorEntry< + Error["Type"] | StalledErrorSchema | CanceledErrorSchema + >[]; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly delay: number; + readonly maxRetries: number; + readonly maxStalledCount: number; + readonly maxErrorEntries: number; + readonly maxRelationships: number; + readonly maxEventEntries: number; + readonly taskRecordRetentionMs: number; + readonly resultRetentionMs: number; + readonly terminalIndexRetentionMs: number; + readonly deadLetterRetentionMs: number; + readonly eventRetentionMs: number; + readonly attempt: number; + readonly handlerFailureCount: number; + readonly stalledAttemptCount: number; + readonly onSuccessPolicy: CompletionPolicy; + readonly onFailurePolicy: CompletionPolicy; +} + +/** Decoded durable record accepted by {@link decodeTask}. */ +export interface StoredTaskRecord { + readonly id: string; + readonly protocolVersion: number; + readonly schemaId: string; + readonly generation: number; + readonly name: string; + readonly delay: number; + readonly maxRetries: number; + readonly maxStalledCount: number; + readonly maxErrorEntries: number; + readonly maxRelationships: number; + readonly maxEventEntries: number; + readonly taskRecordRetentionMs: number; + readonly resultRetentionMs: number; + readonly terminalIndexRetentionMs: number; + readonly deadLetterRetentionMs: number; + readonly eventRetentionMs: number; + readonly attempt: number; + readonly handlerFailureCount: number; + readonly stalledAttemptCount: number; + readonly onSuccessPolicy: CompletionPolicy; + readonly onFailurePolicy: CompletionPolicy; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly payload: unknown; + readonly success?: unknown; + readonly errors: readonly { + readonly timestamp: number; + readonly error: unknown; + readonly retryAt?: number; + }[]; + readonly creator?: TaskIdentity; + readonly outcome?: TaskOutcome; +} + +export const makeTaskSchema = < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +>(config: { + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; +}) => { + const payloadDecoder = Schema.Unknown.pipe( + Schema.decodeTo(config.payloadSchema), + ); + return Schema.Struct({ + _tag: Schema.tagDefaultOmit("Task"), + id: Schema.String, + generation: Schema.Number, + name: Schema.String, + delay: Schema.Number, + maxRetries: Schema.Number, + maxStalledCount: Schema.Number, + maxErrorEntries: Schema.Number, + maxRelationships: Schema.Number, + maxEventEntries: Schema.Number, + taskRecordRetentionMs: Schema.Number, + resultRetentionMs: Schema.Number, + terminalIndexRetentionMs: Schema.Number, + deadLetterRetentionMs: Schema.Number, + eventRetentionMs: Schema.Number, + attempt: Schema.Number, + handlerFailureCount: Schema.Number, + stalledAttemptCount: Schema.Number, + onSuccessPolicy: CompletionPolicySchema, + onFailurePolicy: CompletionPolicySchema, + createdAt: Schema.Date, + updatedAt: Schema.Date, + payload: payloadDecoder, + errors: errorEntrySchema( + Schema.Unknown.pipe( + Schema.decodeTo(Schema.Union([TaskErrorSchema, config.errorSchema])), + ), + ).pipe(Schema.Array), + success: Schema.Unknown.pipe( + Schema.decodeTo(config.successSchema), + Schema.optional, + ), + }); +}; + +export const decodeTask = Effect.fnUntraced(function* < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +>( + config: { + readonly schemaId: string; + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; + }, + task: StoredTaskRecord, +): Effect.fn.Return< + Task, + Schema.SchemaError | StorageProtocol.StorageProtocolError, + | Payload["DecodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"] +> { + const decode = Schema.decodeEffect(makeTaskSchema(config)); + if ( + !StorageProtocol.readableProtocolVersions.includes( + task.protocolVersion as 1, + ) + ) { + return yield* new StorageProtocol.UnsupportedProtocolVersion({ + encountered: task.protocolVersion, + supported: StorageProtocol.readableProtocolVersions, + }); + } + if (task.schemaId !== config.schemaId) { + return yield* new StorageProtocol.SchemaIdentityMismatch({ + expected: config.schemaId, + encountered: task.schemaId, + }); + } + const errors = yield* Effect.forEach(task.errors, (entry) => { + const value = entry.error; + const isBuiltIn = + typeof value === "object" && + value !== null && + "_tag" in value && + Object.values(StorageProtocol.builtInErrorTags).includes( + value._tag as never, + ); + return isBuiltIn + ? Effect.succeed(entry) + : StorageProtocol.decodeValue(value, config.schemaId, "failure").pipe( + Effect.map((error) => ({ ...entry, error })), + ); + }); + return (yield* decode({ + ...task, + payload: yield* StorageProtocol.decodeValue( + task.payload, + config.schemaId, + "payload", + ), + success: + task.success === undefined + ? undefined + : yield* StorageProtocol.decodeValue( + task.success, + config.schemaId, + "success", + ), + errors, + })) as Task; +}); + +export const encodeTask = < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +>( + config: { + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; + }, + task: Task, +) => { + const schema = makeTaskSchema(config); + return Schema.encodeEffect(schema)(task as typeof schema.Type); +}; + +export type TaskSchema< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> = ReturnType>; diff --git a/src/Worker.test.ts b/src/Worker.test.ts index 9bef7f1..7490212 100644 --- a/src/Worker.test.ts +++ b/src/Worker.test.ts @@ -1,19 +1,16 @@ import { Deferred, Effect, Fiber, Schedule, Schema } from "effect"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, Task, TaskEngine, TaskQueue, Worker } from "./index.js"; -import { TestRuntime } from "./testing/redisLayer.js"; +import { TestLayer } from "./testing/redisLayer.js"; const makeQueue = (name: string) => - TaskQueue.make( + Task.make({ name, - Task.make({ - name, - payload: { id: Schema.String }, - success: Schema.String, - error: Schema.Struct({ reason: Schema.String }), - idempotencyKey: (payload) => payload.id, - }), - ); + payload: { id: Schema.String }, + success: Schema.String, + error: Schema.Struct({ reason: Schema.String }), + idempotencyKey: (payload) => payload.id, + }).pipe(Effect.map((definition) => TaskQueue.make(name, definition))); const waitUntilRemoved = ( engine: TaskEngine.TaskEngineService, @@ -27,85 +24,93 @@ const waitUntilRemoved = ( }), ); -describe("Worker", () => { - test("uses isolated worker and maintenance Redis roles", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const base = yield* RedisPool.RedisPool; - const queue = makeQueue("worker-roles"); - yield* TaskQueue.offer(queue, { id: "one" }); +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "Worker (real Redis time)", + (it) => { + it.effect("uses isolated worker and maintenance Redis roles", () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const base = yield* RedisPool.RedisPool; + const queue = yield* makeQueue("worker-roles"); + yield* TaskQueue.offer(queue, { id: "one" }); - const calls = { producer: 0, worker: 0, maintenance: 0 }; - const counted = ( - role: keyof typeof calls, - ): RedisPool.RedisPoolService => ({ - ...base, - evalScript: (source, options, ...args) => { - calls[role]++; - return base.evalScript(source, options, ...args); - }, - }); - const roles = RedisPool.makeConnectionRoles( - counted("producer"), - counted("worker"), - counted("maintenance"), - ); - const handled = yield* Deferred.make(); - const worker = Worker.make( - queue, - () => Deferred.succeed(handled, undefined).pipe(Effect.as("processed")), - { - pollInterval: "5 millis", - maintenanceInterval: "5 millis", - }, - ); + const calls = { producer: 0, worker: 0, maintenance: 0 }; + const counted = ( + role: keyof typeof calls, + ): RedisPool.RedisPoolService => ({ + ...base, + evalScript: (source, options, ...args) => { + calls[role]++; + return base.evalScript(source, options, ...args); + }, + }); + const roles = RedisPool.makeConnectionRoles( + counted("producer"), + counted("worker"), + counted("maintenance"), + ); + const handled = yield* Deferred.make(); + const worker = Worker.make( + queue, + () => + Deferred.succeed(handled, undefined).pipe(Effect.as("processed")), + { + pollInterval: "5 millis", + maintenanceInterval: "5 millis", + }, + ); - const fiber = yield* Worker.run(worker).pipe( - Effect.provideService(RedisPool.RedisConnectionRoles, roles), - Effect.forkChild, - ); - yield* Deferred.await(handled); - yield* waitUntilRemoved(engine, queue.name, "one"); - yield* Fiber.interrupt(fiber); + const fiber = yield* Worker.run(worker).pipe( + Effect.provideService(RedisPool.RedisConnectionRoles, roles), + Effect.forkChild, + ); + yield* Deferred.await(handled); + yield* waitUntilRemoved(engine, queue.name, "one"); + yield* Fiber.interrupt(fiber); - expect(calls.worker).toBeGreaterThan(0); - expect(calls.maintenance).toBeGreaterThan(0); - expect(calls.producer).toBe(0); - }).pipe(TestRuntime.runPromise)); + expect(calls.worker).toBeGreaterThan(0); + expect(calls.maintenance).toBeGreaterThan(0); + expect(calls.producer).toBe(0); + }), + ); - test("shutdown stops acquisition and drains an in-flight handler", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const queue = makeQueue("worker-drain"); - yield* TaskQueue.offer(queue, { id: "drain" }); - const started = yield* Deferred.make(); - const finish = yield* Deferred.make(); - const worker = Worker.make( - queue, - () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(finish)), - Effect.as("drained"), - ), - { - pollInterval: "5 millis", - drainTimeout: "2 seconds", - }, - ); + it.effect( + "shutdown stops acquisition and drains an in-flight handler", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const queue = yield* makeQueue("worker-drain"); + yield* TaskQueue.offer(queue, { id: "drain" }); + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const worker = Worker.make( + queue, + () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as("drained"), + ), + { + pollInterval: "5 millis", + drainTimeout: "2 seconds", + }, + ); - const workerFiber = yield* Worker.run(worker).pipe(Effect.forkChild); - yield* Deferred.await(started); - const stopFiber = yield* Fiber.interrupt(workerFiber).pipe( - Effect.forkChild, - ); + const workerFiber = yield* Worker.run(worker).pipe(Effect.forkChild); + yield* Deferred.await(started); + const stopFiber = yield* Fiber.interrupt(workerFiber).pipe( + Effect.forkChild, + ); - // The worker is shutting down, but its owned attempt remains alive until - // the cooperative handler finishes and acknowledges. - expect( - (yield* engine.getTask(queue.name, "drain"))?.outcome, - ).toBeUndefined(); - yield* Deferred.succeed(finish, undefined); - yield* Fiber.join(stopFiber); - expect(yield* engine.getTask(queue.name, "drain")).toBeNull(); - }).pipe(TestRuntime.runPromise)); -}); + // The worker is shutting down, but its owned attempt remains alive until + // the cooperative handler finishes and acknowledges. + expect( + (yield* engine.getTask(queue.name, "drain"))?.outcome, + ).toBeUndefined(); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(stopFiber); + expect(yield* engine.getTask(queue.name, "drain")).toBeNull(); + }), + ); + }, +); diff --git a/src/Worker.ts b/src/Worker.ts index e8c5eb1..1659519 100644 --- a/src/Worker.ts +++ b/src/Worker.ts @@ -4,16 +4,15 @@ * * @module */ -import { - Data, - Duration, - Effect, - FiberSet, - Option, - Ref, - Schedule, - type Schema, -} from "effect"; +import type * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FiberSet from "effect/FiberSet"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import type * as Schema from "effect/Schema"; import { RedisConnectionRoles } from "./RedisPool.js"; import * as TaskEngine from "./TaskEngine.js"; import * as TaskQueue from "./TaskQueue.js"; @@ -53,10 +52,17 @@ export interface Worker< Success extends Schema.Top, Error extends Schema.Top, QueueR = never, + QueueIdentityR = never, HandlerR = never, > { readonly [TypeId]: typeof TypeId; - readonly queue: TaskQueue.TaskQueue; + readonly queue: TaskQueue.TaskQueue< + Payload, + Success, + Error, + QueueR, + QueueIdentityR + >; readonly handler: TaskQueue.TaskHandler; readonly options: WorkerOptions; } @@ -70,19 +76,20 @@ export interface Worker< * import { Effect, Schema } from "effect" * import { Task, TaskQueue, Worker } from "@effectmq/core" * - * const email = Task.make({ - * name: "email", - * payload: { address: Schema.String }, - * success: Schema.Void, - * error: Schema.String + * const worker = Effect.gen(function* () { + * const email = yield* Task.make({ + * name: "email", + * payload: { address: Schema.String }, + * success: Schema.Void, + * error: Schema.String + * }) + * const emails = TaskQueue.make("emails", email) + * return Worker.make( + * emails, + * ({ payload }) => Effect.log(`Emailing ${payload.address}`), + * { concurrency: 2 } + * ) * }) - * const emails = TaskQueue.make("emails", email) - * - * const worker = Worker.make( - * emails, - * ({ payload }) => Effect.log(`Emailing ${payload.address}`), - * { concurrency: 2 } - * ) * ``` * * @category Constructors @@ -93,12 +100,13 @@ export const make = < Success extends Schema.Top, Error extends Schema.Top, QueueR = never, + QueueIdentityR = never, HandlerR = never, >( - queue: TaskQueue.TaskQueue, + queue: TaskQueue.TaskQueue, handler: TaskQueue.TaskHandler, options: WorkerOptions = {}, -): Worker => ({ +): Worker => ({ [TypeId]: TypeId, queue, handler, @@ -124,31 +132,35 @@ class WorkerSlotStopped extends Data.TaggedError("WorkerSlotStopped") {} * @category Operations * @since 0.3.0 */ -export const run = < +export const run = Effect.fnUntraced(function* < Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, QueueR, + QueueIdentityR, HandlerR, >( - worker: Worker, -): Effect.Effect< + worker: Worker, +): Effect.fn.Return< never, never, | RedisConnectionRoles + | Crypto.Crypto | QueueR | HandlerR | Payload["DecodingServices"] | Success["EncodingServices"] | Error["EncodingServices"] -> => - Effect.scoped( +> { + return yield* Effect.scoped( Effect.gen(function* () { const roles = yield* RedisConnectionRoles; - const workerEngine = yield* TaskEngine.makeWithRedis(roles.worker); + const workerEngine = yield* TaskEngine.makeWithRedis(roles.worker).pipe( + Effect.orDie, + ); const maintenanceEngine = yield* TaskEngine.makeWithRedis( roles.maintenance, - ); + ).pipe(Effect.orDie); const accepting = yield* Ref.make(true); const slots = yield* FiberSet.make(); const pollInterval = worker.options.pollInterval ?? Duration.seconds(1); @@ -211,3 +223,4 @@ export const run = < return yield* Effect.never; }), ); +}); diff --git a/src/cli/InspectPreReleaseData.test.ts b/src/cli/InspectPreReleaseData.test.ts new file mode 100644 index 0000000..bb1debb --- /dev/null +++ b/src/cli/InspectPreReleaseData.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Redis from "effect/unstable/persistence/Redis"; +import { configuration, inspect } from "./InspectPreReleaseData.js"; +import * as RedisPool from "../RedisPool.js"; + +const service = (send: RedisPool.RedisSend): RedisPool.RedisPoolService => + RedisPool.RedisPool.of({ + send, + sendBinary: send, + evalScript: () => + Effect.fail(new Redis.RedisError({ cause: "not used by inspection" })), + }); + +it.effect("requires the Redis URL through Effect Config", () => + Effect.gen(function* () { + const error = yield* configuration.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({}), + ), + Effect.flip, + ); + expect(error._tag).toBe("ConfigError"); + }), +); + +it.effect("scans all pages and reports legacy keys", () => + Effect.gen(function* () { + const replies: unknown[] = [ + ["7", ["~effectmq:v1:current", "~effectmq:legacy:a"]], + ["0", ["~effectmq:legacy:b"]], + ]; + const result = yield* inspect({ + match: "~effectmq:*", + count: 100, + assertDrained: false, + }).pipe( + Effect.provideService( + RedisPool.RedisPool, + service(() => Effect.succeed(replies.shift() as A)), + ), + ); + expect(result).toMatchObject({ + status: "pre-v1-data-found", + legacyKeyCount: 2, + keys: ["~effectmq:legacy:a", "~effectmq:legacy:b"], + }); + }), +); + +it.effect("maps scan and malformed-reply failures semantically", () => + Effect.gen(function* () { + const scanError = yield* inspect({ + match: "*", + count: 1, + assertDrained: false, + }).pipe( + Effect.provideService( + RedisPool.RedisPool, + service(() => + Effect.fail(new Redis.RedisError({ cause: "scan rejected" })), + ), + ), + Effect.flip, + ); + expect(scanError).toMatchObject({ + _tag: "InspectionError", + operation: "scan", + }); + + const replyError = yield* inspect({ + match: "*", + count: 1, + assertDrained: false, + }).pipe( + Effect.provideService( + RedisPool.RedisPool, + service(() => Effect.succeed({ cursor: "0" } as A)), + ), + Effect.flip, + ); + expect(replyError).toMatchObject({ + _tag: "InspectionError", + operation: "decode-reply", + }); + }), +); + +it.effect("inspection remains interruptible while Redis is blocked", () => + Effect.gen(function* () { + const fiber = yield* inspect({ + match: "*", + count: 1, + assertDrained: false, + }).pipe( + Effect.provideService( + RedisPool.RedisPool, + service(() => Effect.never), + ), + Effect.forkChild, + ); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + }), +); diff --git a/src/cli/InspectPreReleaseData.ts b/src/cli/InspectPreReleaseData.ts new file mode 100644 index 0000000..76ff983 --- /dev/null +++ b/src/cli/InspectPreReleaseData.ts @@ -0,0 +1,108 @@ +/** Effect program for the read-only pre-v1 storage release gate. @module */ +import * as Config from "effect/Config"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as NodeRedisPool from "../NodeRedisPool.js"; +import * as RedisPool from "../RedisPool.js"; + +export interface InspectionConfig { + readonly redisUrl: string; + readonly match: string; + readonly count: number; + readonly assertDrained: boolean; +} + +export const configuration: Config.Config = Config.all({ + redisUrl: Config.string("EFFECTMQ_REDIS_URL"), + match: Config.string("EFFECTMQ_SCAN_MATCH").pipe( + Config.withDefault("~effectmq:*"), + ), + count: Config.number("EFFECTMQ_SCAN_COUNT").pipe(Config.withDefault(500)), + assertDrained: Config.boolean("EFFECTMQ_ASSERT_DRAINED").pipe( + Config.withDefault(false), + ), +}); + +export class InspectionError extends Data.TaggedError("InspectionError")<{ + readonly operation: "scan" | "decode-reply"; + readonly cause: unknown; +}> {} + +export class LegacyDataFound extends Data.TaggedError("LegacyDataFound")<{ + readonly count: number; +}> {} + +const decodeScanReply = ( + value: unknown, +): Effect.Effect => { + if (!Array.isArray(value) || value.length !== 2) { + return Effect.fail( + new InspectionError({ + operation: "decode-reply", + cause: { expected: "[cursor, keys]", received: value }, + }), + ); + } + const [cursor, keys] = value; + if ( + typeof cursor !== "string" || + !Array.isArray(keys) || + !keys.every((key) => typeof key === "string") + ) { + return Effect.fail( + new InspectionError({ + operation: "decode-reply", + cause: { expected: "[string, string[]]", received: value }, + }), + ); + } + return Effect.succeed([cursor, keys]); +}; + +export const inspect = Effect.fnUntraced(function* ( + config: Omit, +) { + const redis = yield* RedisPool.RedisPool; + const legacyKeys: string[] = []; + let cursor = "0"; + do { + const reply = yield* redis + .send( + "SCAN", + cursor, + "MATCH", + config.match, + "COUNT", + String(config.count), + ) + .pipe( + Effect.mapError( + (cause) => new InspectionError({ operation: "scan", cause }), + ), + Effect.flatMap(decodeScanReply), + ); + cursor = reply[0]; + for (const key of reply[1]) { + if (!key.startsWith("~effectmq:v1:")) legacyKeys.push(key); + } + } while (cursor !== "0"); + + legacyKeys.sort(); + const result = { + status: legacyKeys.length === 0 ? "drained" : "pre-v1-data-found", + legacyKeyCount: legacyKeys.length, + keys: legacyKeys, + } as const; + yield* Effect.sync(() => console.log(JSON.stringify(result, null, 2))); + if (config.assertDrained && legacyKeys.length > 0) { + return yield* new LegacyDataFound({ count: legacyKeys.length }); + } + return result; +}); + +export const main = Effect.gen(function* () { + const config = yield* configuration; + return yield* inspect(config).pipe( + Effect.provide(NodeRedisPool.layer({ url: config.redisUrl })), + ); +}); diff --git a/src/cli/inspect-pre-release-data.ts b/src/cli/inspect-pre-release-data.ts index 2020d18..b1c1a2b 100644 --- a/src/cli/inspect-pre-release-data.ts +++ b/src/cli/inspect-pre-release-data.ts @@ -1,45 +1,6 @@ #!/usr/bin/env node /** Read-only release gate for pre-v1 EffectMQ keys. */ -import process from "node:process"; -import { createClient } from "redis"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import { main } from "./InspectPreReleaseData.js"; -const url = process.env.EFFECTMQ_REDIS_URL ?? "redis://127.0.0.1:6379"; -const assertDrained = process.argv.includes("--assert-drained"); -const client = createClient({ url }); - -client.on("error", (error) => { - console.error( - "Redis connection error:", - error instanceof Error ? error.message : error, - ); -}); - -await client.connect(); -try { - const legacyKeys: string[] = []; - for await (const keys of client.scanIterator({ - MATCH: "~effectmq:*", - COUNT: 500, - })) { - for (const key of keys) { - if (!key.startsWith("~effectmq:v1:")) legacyKeys.push(key); - } - } - legacyKeys.sort(); - - console.log( - JSON.stringify( - { - status: legacyKeys.length === 0 ? "drained" : "pre-v1-data-found", - legacyKeyCount: legacyKeys.length, - keys: legacyKeys, - }, - null, - 2, - ), - ); - - if (assertDrained && legacyKeys.length > 0) process.exitCode = 1; -} finally { - await client.close(); -} +NodeRuntime.runMain(main); diff --git a/src/index.ts b/src/index.ts index f957878..7dae9ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,10 @@ export * as NodeRedisPool from "./NodeRedisPool.js"; * @since 0.3.0 */ export * as Observability from "./Observability.js"; +/** Public schemas and codecs for typed task records. */ +export * as TaskRecord from "./TaskRecord.js"; +/** Public schemas for queue lifecycle events. */ +export * as TaskEvent from "./TaskEvent.js"; /** * Minimal Redis command, script-cache, and workload-role services. * diff --git a/src/testing/FaultInjection.test.ts b/src/testing/FaultInjection.test.ts index 5a86a04..dd393a5 100644 --- a/src/testing/FaultInjection.test.ts +++ b/src/testing/FaultInjection.test.ts @@ -1,98 +1,113 @@ import { Effect } from "effect"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, TaskEngine } from "../index.js"; import * as FaultInjection from "./FaultInjection.js"; -import { TestRuntime } from "./redisLayer.js"; +import { TestLayer } from "./redisLayer.js"; -describe("deterministic queue-boundary faults", () => { - test("retries safely around offer, acquire, heartbeat, ack, event, and cleanup", () => - Effect.gen(function* () { - const engine = yield* TaskEngine.TaskEngine; - const redis = yield* RedisPool.RedisPool; - const fault = yield* FaultInjection.make({ +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "deterministic queue-boundary faults (real Redis time)", + (it) => { + it.layer( + FaultInjection.layer({ acknowledgement: 1, acquire: 1, cleanup: 1, event: 1, heartbeat: 1, offer: 1, - }); - const prefix = "fault-boundaries"; - yield* TaskEngine.setMockTime(5_000_000); - const insert = { - prefix, - id: "task", - name: "fault", - payload: null, - delay: 0, - maxRetries: 0, - onSuccessPolicy: "mark-as-success" as const, - onFailurePolicy: "mark-as-failure" as const, - }; + }), + )((it) => { + it.effect( + "retries safely around offer, acquire, heartbeat, ack, event, and cleanup", + () => + Effect.gen(function* () { + const engine = yield* TaskEngine.TaskEngine; + const redis = yield* RedisPool.RedisPool; + const fault = yield* FaultInjection.FaultInjection; + const prefix = "fault-boundaries"; + yield* TaskEngine.setMockTime(5_000_000); + const insert = { + prefix, + id: "task", + name: "fault", + payload: null, + delay: 0, + maxRetries: 0, + onSuccessPolicy: "mark-as-success" as const, + onFailurePolicy: "mark-as-failure" as const, + }; - // The response is lost after Redis commits. Retrying the same identity - // observes the original generation rather than creating another task. - const offerFault = yield* fault - .after("offer", engine.offerTask(insert)) - .pipe(Effect.flip); - expect(offerFault).toMatchObject({ - _tag: "InjectedFault", - point: "offer", - }); - const duplicate = yield* engine.offerTask(insert); - expect(duplicate.status).toBe("existing"); - expect(duplicate.task.generation).toBe(1); + // The response is lost after Redis commits. Retrying the same identity + // observes the original generation rather than creating another task. + const offerFault = yield* fault + .after("offer", engine.offerTask(insert)) + .pipe(Effect.flip); + expect(offerFault).toMatchObject({ + _tag: "InjectedFault", + point: "offer", + }); + const duplicate = yield* engine.offerTask(insert); + expect(duplicate.status).toBe("existing"); + expect(duplicate.task.generation).toBe(1); - // Losing an acquire response leaves a fenced lease, not a second owner. - const acquireFault = yield* fault - .after("acquire", engine.takeTask(prefix, 100)) - .pipe(Effect.flip); - expect(acquireFault).toMatchObject({ point: "acquire" }); - expect(yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:active`)).toBe( - 1, - ); - expect(yield* engine.takeTask(prefix, 100)).toBeNull(); - yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:task`); - yield* TaskEngine.stepMockTime(101); - yield* engine.maintain(prefix); - const attempt = yield* engine.takeTask(prefix, 100); - if (attempt === null) throw new Error("expected recovered attempt"); + // Losing an acquire response leaves a fenced lease, not a second owner. + const acquireFault = yield* fault + .after("acquire", engine.takeTask(prefix, 100)) + .pipe(Effect.flip); + expect(acquireFault).toMatchObject({ point: "acquire" }); + expect( + yield* redis.send("ZCARD", `~effectmq:v1:${prefix}:active`), + ).toBe(1); + expect(yield* engine.takeTask(prefix, 100)).toBeNull(); + yield* redis.send("DEL", `~effectmq:v1:${prefix}:lock:task`); + yield* TaskEngine.stepMockTime(101); + yield* engine.maintain(prefix); + const attempt = yield* engine.takeTask(prefix, 100); + if (attempt === null) throw new Error("expected recovered attempt"); - const heartbeatFault = yield* fault - .before( - "heartbeat", - engine.extendLock(prefix, "task", attempt.leaseToken, 10_000), - ) - .pipe(Effect.flip); - expect(heartbeatFault).toMatchObject({ point: "heartbeat" }); - yield* engine.extendLock(prefix, "task", attempt.leaseToken, 10_000); + const heartbeatFault = yield* fault + .before( + "heartbeat", + engine.extendLock(prefix, "task", attempt.leaseToken, 10_000), + ) + .pipe(Effect.flip); + expect(heartbeatFault).toMatchObject({ point: "heartbeat" }); + yield* engine.extendLock( + prefix, + "task", + attempt.leaseToken, + 10_000, + ); - // Completion and its event are one atomic script. Losing either response - // cannot undo the terminal state, and the old token is fenced. - const ackFault = yield* fault - .after( - "acknowledgement", - engine.writeSuccess(prefix, "task", attempt.leaseToken, "done"), - ) - .pipe(Effect.flip); - expect(ackFault).toMatchObject({ point: "acknowledgement" }); - const lateAck = yield* engine - .writeSuccess(prefix, "task", attempt.leaseToken, "late") - .pipe(Effect.flip); - expect(lateAck).toMatchObject({ _tag: "LeaseLost" }); + // Completion and its event are one atomic script. Losing either response + // cannot undo the terminal state, and the old token is fenced. + const ackFault = yield* fault + .after( + "acknowledgement", + engine.writeSuccess(prefix, "task", attempt.leaseToken, "done"), + ) + .pipe(Effect.flip); + expect(ackFault).toMatchObject({ point: "acknowledgement" }); + const lateAck = yield* engine + .writeSuccess(prefix, "task", attempt.leaseToken, "late") + .pipe(Effect.flip); + expect(lateAck).toMatchObject({ _tag: "LeaseLost" }); - const eventFault = yield* fault - .after("event", engine.eventCursors(prefix)) - .pipe(Effect.flip); - expect(eventFault).toMatchObject({ point: "event" }); - const cursors = yield* engine.eventCursors(prefix); - expect(cursors.latest).not.toBe("0-0"); + const eventFault = yield* fault + .after("event", engine.eventCursors(prefix)) + .pipe(Effect.flip); + expect(eventFault).toMatchObject({ point: "event" }); + const cursors = yield* engine.eventCursors(prefix); + expect(cursors.latest).not.toBe("0-0"); - const cleanupFault = yield* fault - .before("cleanup", engine.maintain(prefix)) - .pipe(Effect.flip); - expect(cleanupFault).toMatchObject({ point: "cleanup" }); - const health = yield* engine.maintain(prefix); - expect(health.processed).toBeLessThanOrEqual(100); - }).pipe(TestRuntime.runPromise)); -}); + const cleanupFault = yield* fault + .before("cleanup", engine.maintain(prefix)) + .pipe(Effect.flip); + expect(cleanupFault).toMatchObject({ point: "cleanup" }); + const health = yield* engine.maintain(prefix); + expect(health.processed).toBeLessThanOrEqual(100); + }), + ); + }); + }, +); diff --git a/src/testing/FaultInjection.ts b/src/testing/FaultInjection.ts index c5715de..ced3308 100644 --- a/src/testing/FaultInjection.ts +++ b/src/testing/FaultInjection.ts @@ -1,5 +1,5 @@ /** Deterministic, occurrence-counted failures for production-boundary tests. */ -import { Data, Effect, Ref } from "effect"; +import { Context, Data, Effect, Layer, Ref } from "effect"; export type FaultPoint = | "offer" @@ -29,6 +29,11 @@ export interface FaultInjector { ) => Effect.Effect; } +export class FaultInjection extends Context.Service< + FaultInjection, + FaultInjector +>()("effectmq/testing/FaultInjection") {} + /** * Fail on the configured hit number for each point. A value of `1` fails the * first hit, `2` the second, and an omitted point never fails. @@ -53,3 +58,6 @@ export const make = (plan: Partial> = {}) => after: (point, effect) => effect.pipe(Effect.tap(() => hit(point))), } satisfies FaultInjector; }); + +export const layer = (plan: Partial> = {}) => + Layer.effect(FaultInjection, make(plan)); diff --git a/src/testing/TaskStateProperty.test.ts b/src/testing/TaskStateProperty.test.ts index 878523e..2782c05 100644 --- a/src/testing/TaskStateProperty.test.ts +++ b/src/testing/TaskStateProperty.test.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { RedisPool, TaskEngine } from "../index.js"; -import { TestRuntime } from "./redisLayer.js"; +import { TestLayer } from "./redisLayer.js"; import * as Model from "./TaskStateModel.js"; const value = (result: Model.ModelResult): A => { @@ -36,226 +36,245 @@ const expectedList = (state: Model.ExecutionState) => { } }; -describe("generated Redis state-machine sequences", () => { - test( - "preserve ownership, terminal, relationship, and work-bound invariants", - () => - Effect.gen(function* () { - const redis = yield* RedisPool.RedisPool; - const engine = yield* TaskEngine.TaskEngine; - const now = 2_000_000_000_000; - // Redis lock expiry uses real wall-clock TTLs even when the engine's - // due-time clock is mocked. Keep ordinary generated attempts well - // clear of incidental expiry; expiry branches advance mock time and - // remove the lock explicitly below. - const leaseMs = 30_000; - yield* TaskEngine.setMockTime(now); +layer(TestLayer, { excludeTestServices: true, timeout: "60 seconds" })( + "generated Redis state-machine sequences (real Redis time)", + (it) => { + it.effect( + "preserve ownership, terminal, relationship, and work-bound invariants", + () => + Effect.gen(function* () { + const redis = yield* RedisPool.RedisPool; + const engine = yield* TaskEngine.TaskEngine; + const now = 2_000_000_000_000; + // Redis lock expiry uses real wall-clock TTLs even when the engine's + // due-time clock is mocked. Keep ordinary generated attempts well + // clear of incidental expiry; expiry branches advance mock time and + // remove the lock explicitly below. + const leaseMs = 30_000; + yield* TaskEngine.setMockTime(now); - for (let seed = 1; seed <= 32; seed++) { - const random = randomFor(seed); - const prefix = `property-${seed}`; - const internal = `~effectmq:v1:${prefix}`; - const id = "task"; - let model = value(Model.offer(Model.make(), { id })); - let renewals = 0; - let operations = 0; - let terminal = false; - yield* engine.createTask({ - prefix, - id, - name: "property", - payload: { seed }, - delay: 0, - maxRetries: 100, - maxStalledCount: 2, - onSuccessPolicy: "mark-as-success", - onFailurePolicy: "mark-as-failure", - }); + for (let seed = 1; seed <= 32; seed++) { + const random = randomFor(seed); + const prefix = `property-${seed}`; + const internal = `~effectmq:v1:${prefix}`; + const id = "task"; + let model = value(Model.offer(Model.make(), { id })); + let renewals = 0; + let operations = 0; + let terminal = false; + yield* engine.createTask({ + prefix, + id, + name: "property", + payload: { seed }, + delay: 0, + maxRetries: 100, + maxStalledCount: 2, + onSuccessPolicy: "mark-as-success", + onFailurePolicy: "mark-as-failure", + }); - const assertRedisInvariants = Effect.fnUntraced(function* () { - Model.assertInvariants(model); - const task = model.tasks.get(id); - const lists = { - wait: yield* redis.send>( - "LRANGE", - `${internal}:wait`, - "0", - "-1", - ), - scheduled: yield* redis.send>( - "ZRANGE", - `${internal}:scheduled`, - "0", - "-1", - ), - active: yield* redis.send>( - "ZRANGE", - `${internal}:active`, - "0", - "-1", - ), - success: yield* redis.send>( - "ZRANGE", - `${internal}:success`, - "0", - "-1", - ), - failed: yield* redis.send>( - "ZRANGE", - `${internal}:failed`, - "0", - "-1", - ), - }; - const memberships = Object.entries(lists) - .filter(([, ids]) => ids.includes(id)) - .map(([name]) => name); - if (task === undefined) { + const assertRedisInvariants = Effect.fnUntraced(function* () { + Model.assertInvariants(model); + const task = model.tasks.get(id); + const lists = { + wait: yield* redis.send>( + "LRANGE", + `${internal}:wait`, + "0", + "-1", + ), + scheduled: yield* redis.send>( + "ZRANGE", + `${internal}:scheduled`, + "0", + "-1", + ), + active: yield* redis.send>( + "ZRANGE", + `${internal}:active`, + "0", + "-1", + ), + success: yield* redis.send>( + "ZRANGE", + `${internal}:success`, + "0", + "-1", + ), + failed: yield* redis.send>( + "ZRANGE", + `${internal}:failed`, + "0", + "-1", + ), + }; + const memberships = Object.entries(lists) + .filter(([, ids]) => ids.includes(id)) + .map(([name]) => name); + if (task === undefined) { + expect( + memberships, + `seed ${seed}, operation ${operations}`, + ).toEqual([]); + expect(yield* engine.getTask(prefix, id)).toBeNull(); + return; + } expect( memberships, `seed ${seed}, operation ${operations}`, - ).toEqual([]); - expect(yield* engine.getTask(prefix, id)).toBeNull(); - return; - } - expect( - memberships, - `seed ${seed}, operation ${operations}`, - ).toEqual([expectedList(task.state)]); - const lockExists = Number( - yield* redis.send("EXISTS", `${internal}:lock:${id}`), - ); - expect(lockExists).toBe(task.state === "leased" ? 1 : 0); - expect( - Number( - yield* redis.send( - "SCARD", - `${internal}:task:${id}:${task.generation}:retained-by`, + ).toEqual([expectedList(task.state)]); + const lockExists = Number( + yield* redis.send("EXISTS", `${internal}:lock:${id}`), + ); + expect(lockExists).toBe(task.state === "leased" ? 1 : 0); + expect( + Number( + yield* redis.send( + "SCARD", + `${internal}:task:${id}:${task.generation}:retained-by`, + ), ), - ), - ).toBe(task.retainedBy.size); - const stored = yield* engine.getTask(prefix, id); - expect(stored?.handlerFailureCount).toBe(task.handlerFailureCount); - expect(stored?.stalledAttemptCount).toBe(task.stalledAttemptCount); - const health = yield* engine.maintain(prefix); - expect(health.processed).toBeLessThanOrEqual(100); - }); + ).toBe(task.retainedBy.size); + const stored = yield* engine.getTask(prefix, id); + expect(stored?.handlerFailureCount).toBe( + task.handlerFailureCount, + ); + expect(stored?.stalledAttemptCount).toBe( + task.stalledAttemptCount, + ); + const health = yield* engine.maintain(prefix); + expect(health.processed).toBeLessThanOrEqual(100); + }); - yield* assertRedisInvariants(); - while (!terminal && operations < 20) { - operations++; - const task = model.tasks.get(id); - if (task === undefined) break; - if (task.state === "waiting" || task.state === "retry-scheduled") { - const attempt = yield* engine.takeTask(prefix, leaseMs); - expect(attempt).not.toBeNull(); - if (attempt === null) - throw new Error("expected generated attempt"); - model = value(Model.acquire(model, id, attempt.leaseToken)); - } else if (task.state === "leased") { - const choice = random(); - if (choice < 0.18 && renewals < 2) { - yield* engine.extendLock( - prefix, - id, - task.leaseToken ?? "", - leaseMs, - ); - model = value(Model.renew(model, id, task.leaseToken ?? "")); - renewals++; - } else if (choice < 0.36) { - yield* engine.removeLock(prefix, id, task.leaseToken ?? ""); - model = value( - Model.fail(model, { + yield* assertRedisInvariants(); + while (!terminal && operations < 20) { + operations++; + const task = model.tasks.get(id); + if (task === undefined) break; + if ( + task.state === "waiting" || + task.state === "retry-scheduled" + ) { + const attempt = yield* engine.takeTask(prefix, leaseMs); + expect(attempt).not.toBeNull(); + if (attempt === null) + throw new Error("expected generated attempt"); + model = value(Model.acquire(model, id, attempt.leaseToken)); + } else if (task.state === "leased") { + const choice = random(); + if (choice < 0.18 && renewals < 2) { + yield* engine.extendLock( + prefix, id, - leaseToken: task.leaseToken ?? "", - retry: true, - }), - ); - // Voluntary release and a retry are both immediately runnable; - // correct the model's failure count because release is not a failure. - const released = model.tasks.get(id); - if (released !== undefined) { - model = { - tasks: new Map(model.tasks).set(id, { - ...released, - handlerFailureCount: released.handlerFailureCount - 1, + task.leaseToken ?? "", + leaseMs, + ); + model = value(Model.renew(model, id, task.leaseToken ?? "")); + renewals++; + } else if (choice < 0.36) { + yield* engine.removeLock(prefix, id, task.leaseToken ?? ""); + model = value( + Model.fail(model, { + id, + leaseToken: task.leaseToken ?? "", + retry: true, }), - }; - } - } else if (choice < 0.58) { - yield* engine.writeError( - prefix, - id, - task.leaseToken ?? "", - { reason: "retry", seed }, - now, - ); - model = value( - Model.fail(model, { + ); + // Voluntary release and a retry are both immediately runnable; + // correct the model's failure count because release is not a failure. + const released = model.tasks.get(id); + if (released !== undefined) { + model = { + tasks: new Map(model.tasks).set(id, { + ...released, + handlerFailureCount: released.handlerFailureCount - 1, + }), + }; + } + } else if (choice < 0.58) { + yield* engine.writeError( + prefix, id, - leaseToken: task.leaseToken ?? "", - retry: true, - }), - ); - } else if (choice < 0.76) { - yield* redis.send("DEL", `${internal}:lock:${id}`); - yield* TaskEngine.stepMockTime(leaseMs + 1); - yield* engine.maintain(prefix); - model = value(Model.expire(model, { id, maxStalledCount: 2 })); - } else if (choice < 0.9) { - yield* engine.writeSuccess(prefix, id, task.leaseToken ?? "", { - ok: true, - }); - model = value(Model.succeed(model, id, task.leaseToken ?? "")); - terminal = true; - } else { - yield* engine.writeError(prefix, id, task.leaseToken ?? "", { - reason: "terminal", - seed, - }); - model = value( - Model.fail(model, { + task.leaseToken ?? "", + { reason: "retry", seed }, + now, + ); + model = value( + Model.fail(model, { + id, + leaseToken: task.leaseToken ?? "", + retry: true, + }), + ); + } else if (choice < 0.76) { + yield* redis.send("DEL", `${internal}:lock:${id}`); + yield* TaskEngine.stepMockTime(leaseMs + 1); + yield* engine.maintain(prefix); + model = value( + Model.expire(model, { id, maxStalledCount: 2 }), + ); + } else if (choice < 0.9) { + yield* engine.writeSuccess( + prefix, id, - leaseToken: task.leaseToken ?? "", - retry: false, - }), - ); + task.leaseToken ?? "", + { + ok: true, + }, + ); + model = value( + Model.succeed(model, id, task.leaseToken ?? ""), + ); + terminal = true; + } else { + yield* engine.writeError(prefix, id, task.leaseToken ?? "", { + reason: "terminal", + seed, + }); + model = value( + Model.fail(model, { + id, + leaseToken: task.leaseToken ?? "", + retry: false, + }), + ); + terminal = true; + } + } else { terminal = true; } - } else { - terminal = true; + yield* assertRedisInvariants(); } - yield* assertRedisInvariants(); - } - const final = model.tasks.get(id); - if ( - final?.state === "waiting" || - final?.state === "retry-scheduled" - ) { - const attempt = yield* engine.takeTask(prefix, leaseMs); - if (attempt === null) throw new Error("expected final attempt"); - model = value(Model.acquire(model, id, attempt.leaseToken)); - } - const leased = model.tasks.get(id); - if (leased?.state === "leased") { - yield* engine.writeSuccess( - prefix, - id, - leased.leaseToken ?? "", - "forced-terminal", - ); - model = value(Model.succeed(model, id, leased.leaseToken ?? "")); + const final = model.tasks.get(id); + if ( + final?.state === "waiting" || + final?.state === "retry-scheduled" + ) { + const attempt = yield* engine.takeTask(prefix, leaseMs); + if (attempt === null) throw new Error("expected final attempt"); + model = value(Model.acquire(model, id, attempt.leaseToken)); + } + const leased = model.tasks.get(id); + if (leased?.state === "leased") { + yield* engine.writeSuccess( + prefix, + id, + leased.leaseToken ?? "", + "forced-terminal", + ); + model = value(Model.succeed(model, id, leased.leaseToken ?? "")); + operations++; + yield* assertRedisInvariants(); + } + yield* engine.removeTask(prefix, id); + model = value(Model.remove(model, id)); operations++; yield* assertRedisInvariants(); } - yield* engine.removeTask(prefix, id); - model = value(Model.remove(model, id)); - operations++; - yield* assertRedisInvariants(); - } - }).pipe(TestRuntime.runPromise), - 30_000, - ); -}); + }), + 30_000, + ); + }, +); diff --git a/src/testing/TypeAssertions.ts b/src/testing/TypeAssertions.ts new file mode 100644 index 0000000..557a1ae --- /dev/null +++ b/src/testing/TypeAssertions.ts @@ -0,0 +1,23 @@ +/** Compile-time assertions used by public declaration contract tests. */ + +export type IsAny = 0 extends 1 & A ? true : false; + +export type IsUnknown = + IsAny extends true + ? false + : unknown extends A + ? [keyof A] extends [never] + ? true + : false + : false; + +export type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? (() => T extends B ? 1 : 2) extends () => T extends A ? 1 : 2 + ? true + : false + : false; + +export type Expect = Condition; + +export type ExpectFalse = Condition; diff --git a/src/testing/redisLayer.test.ts b/src/testing/redisLayer.test.ts new file mode 100644 index 0000000..7f26c02 --- /dev/null +++ b/src/testing/redisLayer.test.ts @@ -0,0 +1,160 @@ +import { EventEmitter } from "node:events"; +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; +import { + acquireTracked, + makeTestResourceRegistry, + TestInfrastructureError, +} from "./redisLayer.js"; + +const testFailure = (operation: "redis-ready" = "redis-ready") => + new TestInfrastructureError({ cause: new Error(operation), operation }); + +it.effect("tracked resources release after success and failure", () => + Effect.gen(function* () { + const registry = makeTestResourceRegistry(); + let releases = 0; + const resource = acquireTracked( + registry, + "resource", + Effect.succeed("value"), + () => + Effect.sync(() => { + releases += 1; + }), + ); + + yield* Effect.scoped(resource); + expect(registry.active.size).toBe(0); + expect(releases).toBe(1); + + const failed = yield* Effect.scoped( + Effect.gen(function* () { + yield* resource; + return yield* Effect.fail(testFailure()); + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(registry.active.size).toBe(0); + expect(releases).toBe(2); + }), +); + +it.effect("partial acquisition releases resources already acquired", () => + Effect.gen(function* () { + const registry = makeTestResourceRegistry(); + let released = false; + const exit = yield* Effect.scoped( + Effect.gen(function* () { + yield* acquireTracked(registry, "first", Effect.succeed("first"), () => + Effect.sync(() => { + released = true; + }), + ); + yield* acquireTracked( + registry, + "second", + Effect.fail(testFailure()), + () => Effect.void, + ); + }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(released).toBe(true); + expect(registry.active.size).toBe(0); + }), +); + +it.effect("scope cleanup runs after a defect, timeout, and interruption", () => + Effect.gen(function* () { + const registry = makeTestResourceRegistry(); + + const defectExit = yield* Effect.scoped( + Effect.gen(function* () { + yield* acquireTracked( + registry, + "defect", + Effect.succeed(undefined), + () => Effect.void, + ); + return yield* Effect.die("assertion failed"); + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(defectExit)).toBe(true); + expect(registry.active.size).toBe(0); + + const timeoutStarted = yield* Deferred.make(); + const timeoutReleased = yield* Deferred.make(); + const timeoutFiber = yield* Effect.scoped( + Effect.gen(function* () { + yield* acquireTracked( + registry, + "timeout", + Effect.succeed(undefined), + () => Deferred.succeed(timeoutReleased, undefined), + ); + yield* Deferred.succeed(timeoutStarted, undefined); + return yield* Effect.never; + }), + ).pipe(Effect.timeout("1 second"), Effect.forkChild); + yield* Deferred.await(timeoutStarted); + yield* TestClock.adjust("1 second"); + yield* Fiber.await(timeoutFiber); + yield* Deferred.await(timeoutReleased); + expect(registry.active.size).toBe(0); + + const interruptStarted = yield* Deferred.make(); + const interruptReleased = yield* Deferred.make(); + const interruptFiber = yield* Effect.scoped( + Effect.gen(function* () { + yield* acquireTracked( + registry, + "interrupt", + Effect.succeed(undefined), + () => Deferred.succeed(interruptReleased, undefined), + ); + yield* Deferred.succeed(interruptStarted, undefined); + return yield* Effect.never; + }), + ).pipe(Effect.forkChild); + yield* Deferred.await(interruptStarted); + yield* Fiber.interrupt(interruptFiber); + yield* Deferred.await(interruptReleased); + expect(registry.active.size).toBe(0); + }), +); + +it.effect("scope cleanup removes listeners and interrupts child fibers", () => + Effect.gen(function* () { + const registry = makeTestResourceRegistry(); + const emitter = new EventEmitter(); + const listener = () => undefined; + + const child = yield* Effect.scoped( + Effect.gen(function* () { + yield* acquireTracked( + registry, + "listener", + Effect.sync(() => { + emitter.on("event", listener); + }), + () => + Effect.sync(() => { + emitter.off("event", listener); + }), + ); + return yield* Effect.never.pipe(Effect.forkScoped); + }), + ); + + const childExit = yield* Fiber.await(child); + expect(Exit.isFailure(childExit)).toBe(true); + expect(emitter.listenerCount("event")).toBe(0); + expect(registry.active.size).toBe(0); + }), +); diff --git a/src/testing/redisLayer.ts b/src/testing/redisLayer.ts index 44c43ca..22f830c 100644 --- a/src/testing/redisLayer.ts +++ b/src/testing/redisLayer.ts @@ -1,47 +1,160 @@ -import { spawn } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RedisContainer } from "@testcontainers/redis"; -import { Context, Effect, Layer, ManagedRuntime, Schedule } from "effect"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import * as Redis from "effect/unstable/persistence/Redis"; import { Redis as IORedis } from "ioredis"; import { RedisPool, TaskEngine } from "../index.js"; -const redisContainer = (image: string) => { - const ef = Effect.tryPromise({ - try: () => new RedisContainer(image).start(), - catch: (cause) => new Redis.RedisError({ cause }), +export type TestInfrastructureOperation = + | "container-start" + | "container-stop" + | "client-create" + | "client-disconnect" + | "directory-create" + | "directory-remove" + | "redis-server-start" + | "redis-server-stop" + | "redis-ready" + | "sentinel-setup" + | "sentinel-teardown"; + +export class TestInfrastructureError extends Data.TaggedError( + "TestInfrastructureError", +)<{ + readonly operation: TestInfrastructureOperation; + readonly cause: unknown; +}> {} + +export type TestRedisAddressValue = + | { readonly url: string } + | { readonly socket: { readonly path: string; readonly tls: false } }; + +export class TestRedisAddress extends Context.Service< + TestRedisAddress, + TestRedisAddressValue +>()("effectmq/testing/TestRedisAddress") {} + +export interface TestResourceRegistry { + readonly active: Set; + nextId: number; +} + +export const makeTestResourceRegistry = (): TestResourceRegistry => ({ + active: new Set(), + nextId: 0, +}); + +export const acquireTracked = ( + registry: TestResourceRegistry, + label: string, + acquire: Effect.Effect, + release: (resource: A) => Effect.Effect, +) => { + return Effect.acquireRelease( + acquire.pipe( + Effect.map((resource) => { + registry.nextId += 1; + const resourceId = `${label}#${registry.nextId}`; + registry.active.add(resourceId); + return { resource, resourceId } as const; + }), + ), + ({ resource, resourceId }) => + release(resource).pipe( + Effect.orDie, + Effect.ensuring( + Effect.sync(() => { + registry.active.delete(resourceId); + }), + ), + ), + ).pipe(Effect.map(({ resource }) => resource)); +}; + +const infrastructureError = + (operation: TestInfrastructureOperation) => (cause: unknown) => + new TestInfrastructureError({ cause, operation }); + +const stopChild = (child: ChildProcess) => + Effect.tryPromise({ + try: async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGTERM"); + await exited; + }, + catch: infrastructureError("redis-server-stop"), }); - return Effect.acquireRelease(ef, (container) => - Effect.promise(() => container.stop()), +const redisContainer = (registry: TestResourceRegistry, image: string) => + acquireTracked( + registry, + "redis-container", + Effect.tryPromise({ + try: () => new RedisContainer(image).start(), + catch: infrastructureError("container-start"), + }), + (container) => + Effect.tryPromise({ + try: () => container.stop(), + catch: infrastructureError("container-stop"), + }), ); -}; -const containerClient = (image: string) => +const containerClient = (registry: TestResourceRegistry, image: string) => Effect.gen(function* () { - const container = yield* redisContainer(image); - return yield* Effect.acquireRelease( - Effect.succeed( - new IORedis({ - host: container.getHost(), - port: container.getMappedPort(6379), + const container = yield* redisContainer(registry, image); + const client = yield* acquireTracked( + registry, + "ioredis-client", + Effect.try({ + try: () => + new IORedis({ + host: container.getHost(), + port: container.getMappedPort(6379), + }), + catch: infrastructureError("client-create"), + }), + (client) => + Effect.try({ + try: () => client.disconnect(), + catch: infrastructureError("client-disconnect"), }), - ), - (client) => Effect.succeed(client.disconnect()), ); + return { + address: { url: container.getConnectionUrl() } as const, + client, + }; }); -// A `redis-server` child process on a per-worker unix socket, for environments -// without Docker (opt in with EFFECTMQ_TEST_REDIS=local). -const localServerClient = () => +const localServerClient = (registry: TestResourceRegistry) => Effect.gen(function* () { - const socket = join( - mkdtempSync(join(tmpdir(), "effectmq-redis-")), - "redis.sock", + const directory = yield* acquireTracked( + registry, + "redis-directory", + Effect.try({ + try: () => mkdtempSync(join(tmpdir(), "effectmq-redis-")), + catch: infrastructureError("directory-create"), + }), + (path) => + Effect.try({ + try: () => rmSync(path, { force: true, recursive: true }), + catch: infrastructureError("directory-remove"), + }), ); - yield* Effect.acquireRelease( + const socket = join(directory, "redis.sock"); + yield* acquireTracked( + registry, + "redis-server", Effect.try({ try: () => spawn( @@ -49,79 +162,104 @@ const localServerClient = () => ["--port", "0", "--unixsocket", socket, "--save", ""], { stdio: "ignore" }, ), - catch: (cause) => new Redis.RedisError({ cause }), + catch: infrastructureError("redis-server-start"), }), - (child) => Effect.sync(() => child.kill()), + stopChild, ); - const client = yield* Effect.acquireRelease( - Effect.succeed(new IORedis({ path: socket, lazyConnect: true })), - (client) => Effect.succeed(client.disconnect()), + const client = yield* acquireTracked( + registry, + "ioredis-client", + Effect.try({ + try: () => new IORedis({ path: socket, lazyConnect: true }), + catch: infrastructureError("client-create"), + }), + (client) => + Effect.try({ + try: () => client.disconnect(), + catch: infrastructureError("client-disconnect"), + }), ); - // the server needs a moment to create the socket; retry until it answers yield* Effect.tryPromise({ try: () => client.ping(), - catch: (cause) => new Redis.RedisError({ cause }), + catch: infrastructureError("redis-ready"), }).pipe( Effect.retry({ schedule: Schedule.spaced("100 millis"), times: 50 }), ); - return client; + return { + address: { socket: { path: socket, tls: false } } as const, + client, + }; }); export const redisContainerLayer = ({ image = "redis:7", }: { - image?: string; + readonly image?: string; } = {}) => - Effect.gen(function* () { - const client = - process.env.EFFECTMQ_TEST_REDIS === "local" - ? yield* localServerClient() - : yield* containerClient(image); - - const toArg = (arg: string | Uint8Array) => - typeof arg === "string" || Buffer.isBuffer(arg) ? arg : Buffer.from(arg); - - const send = ( - command: string, - ...args: ReadonlyArray - ) => - Effect.tryPromise({ - try: () => client.call(command, ...args.map(toArg)) as Promise, - catch: (cause) => new Redis.RedisError({ cause }), - }); - - // callBuffer returns bulk strings as Buffers, keeping msgpack bytes intact - const sendBinary = ( - command: string, - ...args: ReadonlyArray - ) => - Effect.tryPromise({ - try: () => client.callBuffer(command, ...args.map(toArg)) as Promise, - catch: (cause) => new Redis.RedisError({ cause }), - }); - - const redisPool = yield* RedisPool.make(send, sendBinary); - const roles = RedisPool.makeConnectionRoles( - redisPool, - redisPool, - redisPool, - ); - const redis = yield* Redis.make({ send }); - return Context.make(RedisPool.RedisPool, redisPool).pipe( - Context.add(RedisPool.RedisConnectionRoles, roles), - Context.add(Redis.Redis, redis), - ); - }).pipe(Layer.effectContext); -const taskEngineLayer = TaskEngine.layer({ - debugMode: true, -}); -// const + Layer.effectContext( + Effect.gen(function* () { + const registry = makeTestResourceRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (registry.active.size > 0) { + throw new Error( + `Leaked test resources: ${Array.from(registry.active).join(", ")}`, + ); + } + }), + ); + + const connection = + process.env.EFFECTMQ_TEST_REDIS === "local" + ? yield* localServerClient(registry) + : yield* containerClient(registry, image); + const { client } = connection; + + const toArg = (arg: string | Uint8Array) => + typeof arg === "string" || Buffer.isBuffer(arg) + ? arg + : Buffer.from(arg); + + const send = ( + command: string, + ...args: ReadonlyArray + ) => + Effect.tryPromise({ + try: () => client.call(command, ...args.map(toArg)) as Promise, + catch: (cause) => new Redis.RedisError({ cause }), + }); -const layers = taskEngineLayer.pipe(Layer.provideMerge(redisContainerLayer())); -export const TestRuntime = ManagedRuntime.make(layers); -// Warm the runtime (container start + layer build) at import time so the -// first test in a file doesn't pay for it inside its own timeout budget. -await TestRuntime.runPromise(Effect.void); + const sendBinary = ( + command: string, + ...args: ReadonlyArray + ) => + Effect.tryPromise({ + try: () => + client.callBuffer(command, ...args.map(toArg)) as Promise, + catch: (cause) => new Redis.RedisError({ cause }), + }); + + const redisPool = yield* RedisPool.make(send, sendBinary); + const roles = RedisPool.makeConnectionRoles( + redisPool, + redisPool, + redisPool, + ); + const redis = yield* Redis.make({ send }); + return Context.make(RedisPool.RedisPool, redisPool).pipe( + Context.add(RedisPool.RedisConnectionRoles, roles), + Context.add(Redis.Redis, redis), + Context.add(TestRedisAddress, connection.address), + ); + }), + ); + +const taskEngineLayer = TaskEngine.layerNoDeps({ debugMode: true }); + +export const TestLayer = Layer.merge( + taskEngineLayer.pipe(Layer.provideMerge(redisContainerLayer())), + NodeCrypto.layer, +); export const getLists = (prefix: string) => Effect.gen(function* () { @@ -141,36 +279,3 @@ export const getLists = (prefix: string) => })).items, }; }); - -// // ); -// export const startRedis = async () => { -// const container = await new RedisContainer("redis:7").start(); -// const client = new IORedis({ -// host: container.getHost(), -// port: container.getMappedPort(6379), -// }); - -// const redisService = Redis.make({ -// send: (command: string, ...args: ReadonlyArray) => -// Effect.tryPromise({ -// try: () => client.call(command, ...args) as Promise, -// catch: (cause) => new Redis.RedisError({ cause }), -// }), -// }); - -// const layer = Layer.effect(Redis.Redis, redisService); - -// const stop = async () => { -// client.disconnect(); -// await container.stop(); -// }; - -// return { container, client, layer, stop }; -// }; - -// export type StartedRedis = { -// container: StartedRedisContainer; -// client: IORedis; -// layer: Layer.Layer; -// stop: () => Promise; -// }; diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..aabbbbc --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["./src/**/*.test.ts", "./src/testing/**/*.ts"], + "exclude": ["./src/scratchpad/**"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 4fdc870..99a5c04 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,12 +1,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - // The empty workspace-root tsconfig.json is invalid JSON; pin esbuild to - // this package's tsconfig so it doesn't walk up and choke on it. - esbuild: { tsconfigRaw: "{}" }, test: { include: ["src/**/*.test.ts"], // testcontainers: first use in a worker may pull + boot a Redis image + hookTimeout: 60_000, + teardownTimeout: 30_000, testTimeout: 30_000, }, }); From cf96ff451eab3d8b84db85a4bbcfd4ffa0cf49ae Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Thu, 20 Aug 2026 07:02:07 -0300 Subject: [PATCH 3/8] chore: stop tracking macOS metadata --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d24f10b1f54f312cdfa6d0c9a32f405249df7b8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z<-XCKMqDg&r5Y7Hp*`#Y>3w1&ruHr6#0kFwK@GHHT8jSzpK}@p+ut z-H63{6|pmA_nY6{><8T+#u)eKagQ;VF=j(UbgvC>jZztb{bpi+ z9q`*Nmcnns@~__?P17VVy4`oa)v&gAnoXzKYTgBpvJ8r_nCE^tyFu$x$~3C=FuF>{ z<;dMTlUWfa*?6W3l6VX$x7SG)%hH$gEY4MJpaYI$J0rKfSey*{9nnATEjwZ{IO?L_ zUoIVc|KRZSV)T?wGWntj<-oU+U4u2eg7UeZ=U|qlGJ61DonPk>5(C5lF+dD#E(7LF zuv?o;0j-=EAO?P50QUz04bd}LX;fPWba;KnxP^!UI=&?kg+b3?r4b?^T$cjsQf{6Y zT$h7im^{y5rBRnNu4aaD%*^%Ug{#@YFH}0?o<{140b*d4frd6+Jpa$(FSGWMzgj{T zF+dFbGX}Ud42C`|%ABp=mWOApfOZcJ1@kIYKtNx)1b~72NLvMUT%Znlp212Zj)Hzw Q4oDXPMF@4oz%MZH0q^Web^rhX From c5ee816ac6c100bad9bb730c15252d6f0db202a5 Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Thu, 20 Aug 2026 07:08:19 -0300 Subject: [PATCH 4/8] chore: add Claude OpenSpec instructions --- .claude/commands/opsx/apply.md | 184 +++++++++++ .claude/commands/opsx/archive.md | 225 +++++++++++++ .claude/commands/opsx/explore.md | 193 +++++++++++ .claude/commands/opsx/propose.md | 145 +++++++++ .claude/commands/opsx/sync.md | 258 +++++++++++++++ .claude/commands/opsx/update.md | 87 +++++ .claude/skills/openspec-apply-change/SKILL.md | 188 +++++++++++ .../skills/openspec-archive-change/SKILL.md | 182 +++++++++++ .claude/skills/openspec-explore/SKILL.md | 308 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 149 +++++++++ .claude/skills/openspec-sync-specs/SKILL.md | 262 +++++++++++++++ .../skills/openspec-update-change/SKILL.md | 91 ++++++ 12 files changed, 2272 insertions(+) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/commands/opsx/update.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md create mode 100644 .claude/skills/openspec-update-change/SKILL.md diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 0000000..731c789 --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,184 @@ +--- +name: "OPSX: Apply" +description: "Implement tasks from an OpenSpec change (Experimental)" +allowed-tools: Bash(openspec:*) +category: "Workflow" +tags: ["workflow", "artifacts", "experimental"] +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.