queasy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "mcp__acp__Bash",
5
+ "mcp__acp__Write",
6
+ "mcp__acp__Edit",
7
+ "WebFetch(domain:raw.githubusercontent.com)",
8
+ "WebFetch(domain:github.com)",
9
+ "Bash(find:*)",
10
+ "Bash(node --test:*)",
11
+ "Bash(node:*)",
12
+ "Bash(redis-cli:*)",
13
+ "Bash(npm test:*)",
14
+ "Bash(npm run typecheck:*)",
15
+ "Bash(for i in 1 2 3)",
16
+ "Bash(do echo \"=== Run $i ===\")",
17
+ "Bash(done)",
18
+ "Bash(npm run format:*)",
19
+ "Bash(npx biome migrate:*)",
20
+ "Bash(npm run lint:*)",
21
+ "Bash(npm run test:coverage:*)",
22
+ "Bash(git -C /home/aravind/code/queasy tag -l)",
23
+ "Bash(test:*)",
24
+ "Bash(git -C /home/aravind/code/queasy log --oneline -10)"
25
+ ]
26
+ }
27
+ }
package/.luarc.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
3
+ "diagnostics": {
4
+ "globals": ["redis", "cjson"]
5
+ },
6
+ "runtime": {
7
+ "version": "Lua 5.1"
8
+ },
9
+ "workspace": {
10
+ "library": [],
11
+ "checkThirdParty": false
12
+ }
13
+ }
@@ -0,0 +1,39 @@
1
+ // Folder-specific settings
2
+ //
3
+ // For a full list of overridable settings, and general information on folder-specific settings,
4
+ // see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5
+ {
6
+ "languages": {
7
+ "JavaScript": {
8
+ "format_on_save": "on",
9
+ "formatter": { "language_server": { "name": "biome" } },
10
+ "code_actions_on_format": {
11
+ "source.fixAll.biome": true,
12
+ "source.organizeImports.biome": true
13
+ }
14
+ },
15
+ "TypeScript": {
16
+ "format_on_save": "on",
17
+ "formatter": { "language_server": { "name": "biome" } },
18
+ "code_actions_on_format": {
19
+ "source.fixAll.biome": true,
20
+ "source.organizeImports.biome": true
21
+ }
22
+ },
23
+ "TSX": {
24
+ "format_on_save": "on",
25
+ "formatter": { "language_server": { "name": "biome" } },
26
+ "code_actions_on_format": {
27
+ "source.fixAll.biome": true,
28
+ "source.organizeImports.biome": true
29
+ }
30
+ },
31
+ "JSON": { "format_on_save": "on", "formatter": { "language_server": { "name": "biome" } } },
32
+ "JSONC": {
33
+ "format_on_save": "on",
34
+ "formatter": { "language_server": { "name": "biome" } }
35
+ },
36
+ "CSS": { "format_on_save": "on", "formatter": { "language_server": { "name": "biome" } } },
37
+ "Lua": { "format_on_save": "on", "formatter": "language_server" }
38
+ }
39
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,102 @@
1
+ This file provides guidance to coding agents working in this repository.
2
+
3
+ ## Commands
4
+
5
+ ```sh
6
+ npm test # Run all tests (requires Redis on localhost:6379)
7
+ npm test -- --test-name-pattern="pattern" # Run tests matching a pattern
8
+ npm test -- test/queue.test.js # Run a single test file
9
+ npm run lint # Lint with Biome
10
+ npm run format # Auto-format with Biome
11
+ npm run typecheck # TypeScript check via jsconfig.json (JSDoc types)
12
+ npm run docker:up # Start Redis via Docker Compose
13
+ npm run docker:down # Stop Redis
14
+ ```
15
+
16
+ Tests require a running Redis instance. Use `docker:up` first if needed.
17
+
18
+ ## Architecture
19
+
20
+ Queasy is a Redis-backed job queue for Node.js with **at-least-once** delivery semantics, singleton jobs (only one active job per ID), fail handlers, and worker-thread-based processing.
21
+
22
+ ### JS layer
23
+
24
+ The JS side is split across several modules:
25
+
26
+ - **`src/client.js`** (`Client` class): Top-level entry point. Wraps a `node-redis` connection, loads the Lua script into Redis via `FUNCTION LOAD REPLACE` on construction, and manages named `Queue` instances. Generates a unique `clientId` for heartbeats. All Redis `fCall` invocations live here (`dispatch`, `cancel`, `dequeue`, `finish`, `fail`, `retry`, `bump`). Exported from `src/index.js`.
27
+ - **`src/queue.js`** (`Queue` class): Represents a single named queue. Holds dequeue options and handler path. `listen()` attaches a handler and starts a `setInterval` polling loop that calls `dequeue()`. `dequeue()` checks pool capacity, fetches jobs from Redis, and processes each via the pool. Handles retry/fail logic (backoff calculation, stall-count checks) on the JS side.
28
+ - **`src/pool.js`** (`Pool` class): Manages a set of `Worker` threads. Each worker has a `capacity` (default 100 units). `process()` picks the worker with the most spare capacity, posts the job, and returns a promise. Handles job timeouts: a timed-out job marks the worker as unhealthy, replaces it with a fresh one, and terminates the old worker once only stalled jobs remain.
29
+ - **`src/worker.js`**: Runs inside a `Worker` thread. Receives `exec` messages, dynamically imports the handler module, calls `handle(data, job)`, and posts back `done` messages (with optional error info).
30
+ - **`src/constants.js`**: Default retry options, heartbeat/timeout intervals, worker capacity, dequeue polling interval.
31
+ - **`src/errors.js`**: `PermanentError` (thrown to skip retries) and `StallError`.
32
+ - **`src/utils.js`**: `generateId()` helper.
33
+ - **`src/types.ts`**: JSDoc type definitions for IDE support (not runtime code).
34
+
35
+ ### Lua layer (`src/queasy.lua`)
36
+
37
+ All queue state mutations are atomic Redis functions registered under the `queasy` library. The Lua functions are the single source of truth for state transitions — no queue logic should be duplicated in JS.
38
+
39
+ Each registered function calls `redis.setresp(3)` for RESP3 typed responses.
40
+
41
+ ### Redis data structures
42
+
43
+ All keys for a queue named `foo` share the `{foo}` hash tag for cluster compatibility:
44
+
45
+ | Key pattern | Type | Purpose |
46
+ |---|---|---|
47
+ | `{foo}` | Sorted set | Waiting jobs. Score = `run_at`. Blocked jobs have score `-run_at` (negative, so they're excluded by the `ZRANGEBYSCORE 0 now` in dequeue). |
48
+ | `{foo}:expiry` | Sorted set | Client heartbeat expiries. Member = `client_id`, score = expiry timestamp. |
49
+ | `{foo}:checkouts:{client_id}` | Set | Job IDs currently checked out by this client. |
50
+ | `{foo}:waiting_job:{id}` | Hash | Metadata for a waiting job (`id`, `data`, `retry_count`, `stall_count`, and update flags if blocked). |
51
+ | `{foo}:active_job:{id}` | Hash | Metadata for an active job (same fields, moved via `RENAME` on dequeue). |
52
+
53
+ ### Job lifecycle
54
+
55
+ `dispatch` → waiting set → `dequeue` → active (hash renamed, added to client's checkouts) → `finish` (success) or `retry` (retriable failure) or stall (via `sweep`)
56
+
57
+ Key state transitions in Lua:
58
+ - **Blocked jobs**: When dispatching a job whose `id` already has an `active_job` hash, the waiting entry gets a negative score to prevent dequeue. On `finish`, the negative score is flipped positive to unblock.
59
+ - **Retry** (`do_retry`): `RENAME`s `active_job` back to `waiting_job` and `ZADD`s with the backoff timestamp. If a blocked waiting job exists for the same id, its saved update flags are re-applied.
60
+ - **Permanent failure** (`fail`): Dispatches a new job into the fail queue (`{name}-fail`) with `data = [original_id, job_data, error]`, then calls `finish` to clean up.
61
+ - **Stall detection** (`sweep`): Embedded inside `bump` and `dequeue` — not a standalone registered function. Finds clients in the expiry set whose score is <= `now`, retrieves their checkouts via `SMEMBERS`, calls `handle_stall` on each job, then cleans up the client.
62
+
63
+ ### Heartbeats
64
+
65
+ Heartbeats are **per-client, per-queue** (not per-job). The JS side sends `queasy_bump` calls at `HEARTBEAT_INTERVAL = 5000ms`. The Lua side stores client expiry in the `{queue}:expiry` sorted set with a score of `now + HEARTBEAT_TIMEOUT` (10000ms). If a client's expiry passes, `sweep` returns all its checked-out jobs to the waiting state with incremented `stall_count`.
66
+
67
+ `bump` returns `0` if the client was already swept (removed from the expiry set), signaling the JS side that it has been evicted.
68
+
69
+ ### Update semantics on re-dispatch
70
+
71
+ When dispatching a job with an existing `id`, the `update_data`, `update_run_at`, and `reset_counts` flags control which fields are overwritten vs. preserved (`HSET` vs. `HSETNX` in Lua). If the job is blocked (active copy exists), these flags are stored in the waiting hash and re-applied when the blocked job is eventually retried via `do_retry`.
72
+
73
+ ## Lua function reference
74
+
75
+ | Function | Keys | Args | Purpose |
76
+ |---|---|---|---|
77
+ | `queasy_dispatch` | `{queue}` | id, run_at, data, update_data, update_run_at, reset_counts | Add/update a waiting job |
78
+ | `queasy_dequeue` | `{queue}` | client_id, now, expiry, limit | Dequeue ready jobs; also triggers sweep |
79
+ | `queasy_cancel` | `{queue}` | id | Remove a waiting job |
80
+ | `queasy_bump` | `{queue}` | client_id, now, expiry | Client heartbeat; also triggers sweep. Returns 0 if client was evicted. |
81
+ | `queasy_finish` | `{queue}` | id, client_id, now | Job completed; unblocks waiting job if any |
82
+ | `queasy_retry` | `{queue}` | id, client_id, retry_at, now | Retriable failure; increments retry_count, calls do_retry |
83
+ | `queasy_fail` | `{queue}`, `{fail_queue}` | id, client_id, fail_job_id, fail_job_data, now | Permanent failure; dispatches fail job, then finishes original |
84
+ | `queasy_version` | (none) | (none) | Returns library version (currently 1) |
85
+
86
+ Note: `sweep`, `do_retry`, `handle_stall`, `finish`, `dispatch` also exist as internal Lua helpers but are not registered as Redis functions.
87
+
88
+ ## Test structure
89
+
90
+ - `test/redis-functions.test.js` — Tests Lua functions directly via `redis.fCall()`. Exercises all state transitions at the Redis level.
91
+ - `test/queue.test.js` — Tests the JS `Client`/`Queue` API (dispatch, cancel, listen). The `listen()` tests use fixture handlers in `test/fixtures/`.
92
+ - `test/fixtures/` — Minimal handler modules. Each exports a `handle(data, job)` function.
93
+
94
+ Queue names in tests use `{curly-brace}` syntax (e.g., `{test-api-queue}`) to keep all related keys on the same Redis node.
95
+
96
+ ## Conventions
97
+
98
+ - ESM modules throughout (`"type": "module"` in package.json).
99
+ - JSDoc types with a `types.ts` file for IDE support; run `npm run typecheck` to verify.
100
+ - Biome for formatting (tabs, single quotes, 100-char width). Run `npm run format` before committing.
101
+ - Lua booleans are passed as strings (`'true'`/`'false'`) between JS and Lua — comparisons in Lua use string equality (e.g., `args[4] == 'true'`).
102
+ - `redis.setresp(3)` is called in each registered Lua function for RESP3 typed responses. This means `HGETALL` returns `{ map = {...} }`, `SMEMBERS` returns `{ set = {...} }`, `ZSCORE` returns `{ double = number }`, etc.
package/CLAUDE.md ADDED
@@ -0,0 +1,83 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```sh
8
+ npm test # Run all tests (requires Redis on localhost:6379)
9
+ npm test -- --test-name-pattern="pattern" # Run tests matching a pattern
10
+ node --test test/queue.test.js # Run a single test file
11
+ npm run lint # Lint with Biome
12
+ npm run format # Auto-format with Biome
13
+ npm run typecheck # TypeScript check via jsconfig.json (JSDoc types)
14
+ npm run docker:up # Start Redis via Docker Compose
15
+ npm run docker:down # Stop Redis
16
+ ```
17
+
18
+ Tests require a running Redis instance. Use `docker:up` first if needed.
19
+
20
+ ## Architecture
21
+
22
+ Queasy is a Redis-backed job queue with **at-least-once** delivery semantics. The core logic lives in two layers:
23
+
24
+ - **JS layer** (`src/queue.js`): The `queue()` factory returns `{ dispatch, cancel, listen }`. On first use, it uploads the Lua script to Redis via `FUNCTION LOAD REPLACE`. A `WeakSet` (`initializedClients`) tracks which Redis clients have already had the functions loaded. `listen()` is currently a TODO stub.
25
+ - **Lua layer** (`src/queasy.lua`): All queue state mutations are atomic Redis functions registered under the `queasy` library. No queue logic should be duplicated in JS — the Lua functions are the single source of truth for state transitions.
26
+
27
+ ### Redis data structures
28
+
29
+ All keys for a queue named `foo` share the `{foo}` hash tag for cluster compatibility:
30
+
31
+ | Key pattern | Type | Purpose |
32
+ |---|---|---|
33
+ | `{foo}:waiting` | Sorted set | Waiting jobs. Score = `run_at`. Blocked jobs have score `-run_at` (negative, so they're excluded from dequeue). |
34
+ | `{foo}:active` | Sorted set | Active jobs. Member = `{id}:{worker_id}`, score = heartbeat deadline (`now + 10s`). |
35
+ | `{foo}:waiting_job:{id}` | Hash | Metadata for a waiting job. |
36
+ | `{foo}:active_job:{id}` | Hash | Metadata for an active job (same fields, moved via RENAME on dequeue). |
37
+
38
+ ### Job lifecycle
39
+
40
+ `dispatch` → waiting set → `dequeue` → active set → `finish` (success) or `retry`/`fail` (failure) or `sweep` (stall)
41
+
42
+ Key state transitions in Lua:
43
+ - **Blocked jobs**: When dispatching a job whose `id` already has an active job, the waiting entry gets a negative score to prevent dequeue. On `finish` or `do_retry`, the negative score is flipped positive to unblock.
44
+ - **Retry**: `do_retry` RENAMEs `active_job` back to `waiting_job` and ZADDs with a backoff `next_run_at`. If a blocked waiting job exists for the same id, it is re-dispatched after the retry.
45
+ - **Permanent failure**: `fail` checks if a `{queue}-fail:waiting` key exists (i.e., a failure queue has been dispatched to). If so, it creates a new job there with `data = [original_id, job_data, error]`, then calls `finish` to clean up.
46
+ - **Stall detection**: `sweep` scans the active set for entries whose heartbeat deadline has passed, then calls `handle_stall` which increments `stall_count` and either retries or calls `fail`.
47
+
48
+ ### Heartbeats
49
+
50
+ `ACTIVE_TIMEOUT_MS = 10000` (10s) in Lua. The JS side sends `queasy_bump` calls at `HEARTBEAT_INTERVAL = 5000` (5s) to keep active jobs alive.
51
+
52
+ ### Update semantics on re-dispatch
53
+
54
+ When dispatching a job with an existing `id`, the `update_data`, `update_run_at`, `update_retry_strategy`, and `reset_counts` flags control which fields are overwritten vs. preserved (HSET vs. HSETNX in Lua). If the job is blocked (active copy exists), these flags are stored in the waiting hash and re-applied when the blocked job is eventually dispatched.
55
+
56
+ ## Lua function reference
57
+
58
+ | Function | Keys | Args | When called |
59
+ |---|---|---|---|
60
+ | `queasy_dispatch` | waiting, active | id, run_at, data, retry opts, update flags | Adding/updating a job |
61
+ | `queasy_cancel` | waiting | id | Removing a waiting job |
62
+ | `queasy_dequeue` | waiting, active | worker_id, now, limit | Polling for ready jobs |
63
+ | `queasy_bump` | active | id, worker_id, now | Heartbeat keepalive |
64
+ | `queasy_finish` | waiting, active | id, worker_id | Job completed successfully |
65
+ | `queasy_retry` | waiting, active | id, worker_id, next_run_at, error | Job failed, may retry or fail permanently |
66
+ | `queasy_fail` | waiting, active | id, worker_id, error | Permanent failure |
67
+ | `queasy_sweep` | waiting, active | now, next_run_at | Detecting stalled jobs |
68
+
69
+ ## Test structure
70
+
71
+ - `test/redis-functions.test.js` — Tests Lua functions directly via `redis.fCall()`. Exercises all state transitions at the Redis level.
72
+ - `test/queue.test.js` — Tests the JS `queue()` API (dispatch, cancel, listen). The `listen()` tests use fixture handlers in `test/fixtures/`.
73
+ - `test/fixtures/` — Minimal handler modules (`success-handler`, `failure-handler`, `slow-handler`, `data-logger-handler`, `permanent-error-handler`). Each exports a `handle(data, job)` function.
74
+
75
+ Queue names in tests use `{curly-brace}` syntax (e.g., `{test-api-queue}`) to keep all related keys on the same Redis node.
76
+
77
+ ## Conventions
78
+
79
+ - ESM modules throughout (`"type": "module"` in package.json).
80
+ - JSDoc types with a `types.ts` file for IDE support; run `npm run typecheck` to verify.
81
+ - Biome for formatting (tabs, single quotes, 100-char width). Run `npm run format` before committing.
82
+ - All Lua booleans arrive as strings (`'true'`/`'false'`) from `redis.fCall` — comparisons in Lua must use string equality.
83
+ - `redis.setresp(3)` is called in each registered function to get RESP3 map responses (needed for `HGETALL` returning `{ map: {...} }` instead of a flat array).
package/License.md ADDED
@@ -0,0 +1,7 @@
1
+ ISC License
2
+
3
+ Copyright 2026 github.com/aravindet
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/Readme.md ADDED
@@ -0,0 +1,130 @@
1
+ # Queasy 🤢
2
+
3
+ A Redis-backed job queue for Node.js, featuring (in comparison with design inspiration BullMQ):
4
+
5
+ - **Singleton jobs**: Guarantees that no more than one job with a given ID is be processed at a time, without trampolines or dropping jobs (“unsafe deduplication”).
6
+ - **Fail handlers**: Guaranteed at-least-once handlers for failed or stalled jobs, which permits reliable periodic jobs without a external scheduling or “reviver” systems.
7
+ - **Instant config changes**: Most configuration changes take effect immediately no matter the queue length, as they apply at dequeue time.
8
+ - **Worker threads**: Jobs are processed in worker threads, preventing main process stalling and failing health checks due to CPU-bound jobs
9
+ - **Capacity model**: Worker capacity flexibly shared between heterogenous queues based on priority and demand, rather than queue-specific “concurrency”.
10
+ - **Job timeout**: Enforced by draining and terminating worker threads with timed out jobs
11
+ - **Zombie protection**: Clients that have lost locks detect this and exit at next heartbeat
12
+ - **Fine-grained updates**: Control over individual attributes when one job updates another with the same ID
13
+
14
+ ### Terminology
15
+
16
+ A _client_ is an instance of Quesy that connects to a Redis database. A _job_ is the basic unit of work that is _dispatched_ into a _queue_.
17
+
18
+ A _handler_ is JavaScript code that performs work. There are two kinds of handlers: _task handlers_, which process jobs, and _fail handlers_, which are invoked when a job fails permanently. Handlers run on _workers_, which are Node.js worker threads. By default, a Queasy client automatically creates one worker per CPU.
19
+
20
+ ### Job attributes
21
+
22
+ - `id`: string; generated if unspecified. See _update semantics_ below for more information.
23
+ - `data`: a JSON-serializable value passed to handlers
24
+ - `runAt`: number; a unix timestamp, to delay job execution until at least that time
25
+ - `stallCount`: number; how many times has this job caused the client or worker to stall?
26
+ - `retryCount`: number; how many times has this job caused the handler to throw an error?
27
+
28
+ ### Job lifecycle
29
+
30
+ 1. A job when first dispatched is created in the _waiting_ state, unless there is a currently active job with the same ID. In that case, it is created in the _blocked_ state.
31
+ 2. Jobs are dequeued from the _waiting_ state and enter the _active_ state.
32
+ 3. When an active job finishes or fails permanently, it is deleted. Any blocked job with the same ID then moves to the _waiting_ state. (A new job may also be added to the separate fail queue.)
33
+ 4. When an active job stalls or fails (with retries left), it returns to the _waiting_ state. Any blocked job with the same ID then updates this waiting job.
34
+
35
+ ### Worker capacity
36
+
37
+ When the client is created, a pool of worker threads are created. Every worker is initialized with 100 units of _capacity_. When a handler is registered, it specifies its _size_ using the same units. When a worker starts processing a job with that handler, its capacity is decreased by this size; this is reversed when that job completes or fails.
38
+
39
+ Queues are dequeued based on their priority and the ratio of available capacity to handler size.
40
+
41
+ ### Timeout handling
42
+
43
+ When a worker start processing a job, a timer is started; if the job completes or throws, the timer is cleared. If the timeout occurs, the job is marked stalled and the worker is removed from the pool so it no longer receives new jobs. A new worker is also created and added to the pool to replace it.
44
+
45
+ The unhealthy worker (with stalled jobs) continues to run until it has *only* stalled jobs remaining. When this happens, the worker is terminated, and all its stalled jobs are retried.
46
+
47
+ ### Stall handling
48
+
49
+ The client (in the main thread) sends periodic heartbeats to Redis for each queue it’s processing. If heartbeats from a client stop, a Lua script in Redis removes this client and returns all its active jobs into the waiting state with their stall count property incremented.
50
+
51
+ When a job is dequeued, if its stall count exceeds the configured maximum, it is immediately considered permanently failed and its handler is not invoked.
52
+
53
+ The response of the heartbeat Lua function indicates whether the client had been removed due to an earlier stall; if it receives this response, the client terminates all its worker threads immediately and re-initializes the pool and queues.
54
+
55
+ ## API
56
+
57
+ ### `client(redisConnection, workerCount)`
58
+ Returns a Queasy client.
59
+ - `redisConnection`: a node-redis connection object.
60
+ - `workerCount`: number; Size of the worker pool. If 0, or if called in a queasy worker thread, no pool is created. Defaults to the number of CPUs.
61
+
62
+
63
+ ### `client.queue(name)`
64
+
65
+ Returns a queue object for interacting with this named queue at the defined Redis server.
66
+ - name is a string queue name. Redis data structures related to a queue will be placed on the same node in a Redis cluster.
67
+
68
+ ### `queue.dispatch(data, options)`
69
+
70
+ Adds a job to the queue. `data` may be any JSON value, which will be passed unchanged to the workers. Options may include:
71
+ - `id`: alphanumeric string; if not provided, a unique random string is generated
72
+ - `runAt`: number; wall clock timestamp before which this job must not be run; default: 0
73
+
74
+ The following options take effect if an `id` is provided, and it matches that of a job already in the queue.
75
+ - `updateData`: boolean; whether to replace the data of any waiting job with the same ID; default: true
76
+ - `updateRunAt`: boolean | 'ifLater' | 'ifEarlier'; default: true
77
+ - `updateRetryStrategy`: boolean; whether to replace `maxRetries`, `maxStalls`, `minBackoff` and `maxBackoff`
78
+ - `resetCounts`: boolean; Whether to reset the internal failure and stall counts to 0; default: same as updateData
79
+
80
+ Returns a promise that resolves to the job ID when the job has been added to Redis.
81
+
82
+ ### `queue.cancel(id)`
83
+
84
+ Removes the job with the given ID if it exists in the waiting state, no-op otherwise.
85
+
86
+ Returns a promise that resolves to a boolean (true if a job with this ID existed and has been removed).
87
+
88
+ ### `queue.listen(handler, options)`
89
+ Attaches handlers to a queue to process jobs that are added to it.
90
+ - `handler`: The path to a JavaScript module that exports the task handler
91
+
92
+ The following options control retry behavior:
93
+ - `maxRetries`: number; default: 10
94
+ - `maxStalls`: number; default: 3
95
+ - `minBackoff`: number; in milliseconds; default: 2,000
96
+ - `maxBackoff`: number; default: 300,000
97
+ - `size`: number; default: 10
98
+ - `timeout`: number; in milliseconds; default: 60,000
99
+
100
+ Additional options affect failure handling:
101
+ - `failHandler`: The path to a JavaScript module that exports the handler for failure jobs
102
+ - `failRetryOptions`: Retry options (as above) for the failure jobs
103
+
104
+ ## Handlers
105
+
106
+ Every handler module must have a named export `handle`, a function that is called with each job.
107
+
108
+ ### Task handlers
109
+
110
+ It receives two arguments:
111
+ - `data`, the JSON value passed to dispatch
112
+ - `job`, a Job object contains the job attributes except data
113
+
114
+ This function may throw (or return a Promise that rejects) to indicate job failure. If the thrown error is an
115
+ instance of `PermanentError`, or if `maxRetries` has been reached, the job is not retried. Otherwise, the job
116
+ is queued to be retried with `maxRetries` incremented.
117
+
118
+ If the thrown error has a property `retryAt`, the job’s `runAt` is set to this value; otherwise, it’s set using
119
+ the exponential backoff algorithm.
120
+
121
+ If it returns any value apart from a Promise that rejects, the job is considered to have completed successfully.
122
+
123
+ ### Failure handlers
124
+
125
+ This function receives three arguments:
126
+ - `data`, the JSON value passed to dispatch
127
+ - `job`
128
+ - `error`, a JSON object with a copy of the enumerable properties of the error thrown by the final call to handle, or an instance of `StallError` if the final call to handle didn’t return or throw.
129
+
130
+ If this function throws an error (or returns a Promise that rejects), it is retried using exponential backoff.
package/biome.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
3
+ "assist": { "actions": { "source": { "organizeImports": "on" } } },
4
+ "linter": {
5
+ "enabled": true,
6
+ "rules": {
7
+ "recommended": true,
8
+ "complexity": {
9
+ "noForEach": "off"
10
+ },
11
+ "style": {
12
+ "useNodejsImportProtocol": "error"
13
+ }
14
+ }
15
+ },
16
+ "formatter": {
17
+ "enabled": true,
18
+ "indentStyle": "space",
19
+ "indentWidth": 4,
20
+ "lineWidth": 100
21
+ },
22
+ "javascript": {
23
+ "formatter": {
24
+ "quoteStyle": "single",
25
+ "trailingCommas": "es5"
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,70 @@
1
+ ## Implementation
2
+
3
+ ### Data structures
4
+
5
+ Two sorted sets: `{queue}:waiting` and `{queue}:active`
6
+ Two hash families: `{queue}:waiting_job:{id}` and `{queue}:active_job:{id}`
7
+
8
+ Hash fields: `data`, `max_retries`, `max_stalls`, `min_backoff`, `max_backoff`, `retry_count`, `stall_count`, and update flags (`update_data`, `update_run_at`, `update_retry_strategy`, `reset_counts`)
9
+
10
+ **Waiting set**: members are job `id`, scores are `run_at` (or `-run_at` for blocked jobs)
11
+ **Active set**: members are `{id}:{worker_id}`, scores are heartbeat deadlines
12
+
13
+ **Blocked jobs**: jobs with an active job sharing the same `id` (score is negative to prevent dequeuing)
14
+
15
+ ### Adding a job (dispatch)
16
+
17
+ - HSET/HSETNX fields in `waiting_job:{id}` based on update flags
18
+ - Check if `active_job:{id}` EXISTS to determine if blocked
19
+ - Set score to `-run_at` if blocked, else `run_at`
20
+ - If blocked, save update flags in waiting hash for later application
21
+ - ZADD to waiting set with appropriate flags (NX, GT, LT, or none) based on `update_run_at`
22
+
23
+ ### Canceling a job (cancel)
24
+
25
+ - ZREM from waiting set
26
+ - DEL `waiting_job:{id}`
27
+
28
+ ### Dequeuing jobs (dequeue)
29
+
30
+ - ZRANGEBYSCORE on waiting set for positive scores ≤ now
31
+ - For each job: ZREM from waiting set, RENAME `waiting_job:{id}` to `active_job:{id}`
32
+ - ZADD `{id}:{worker_id}` to active set with score = now + 10000ms
33
+
34
+ ### Heartbeat (bump)
35
+
36
+ - ZADD `{id}:{worker_id}` to active set with score = now + 10000ms and XX flag (update only if exists)
37
+
38
+ ### Job completion (finish)
39
+
40
+ - ZREM `{id}:{worker_id}` from active set
41
+ - DEL `active_job:{id}`
42
+ - If job exists in waiting set with negative score, flip score to positive and re-add with ZADD
43
+
44
+ ### Retriable failure (retry)
45
+
46
+ - ZREM `{id}:{worker_id}` from active set
47
+ - Increment `retry_count` in `active_job:{id}`
48
+ - If `retry_count >= max_retries`, call fail; otherwise call do_retry
49
+
50
+ ### Retry logic (do_retry)
51
+
52
+ - If blocked job exists in waiting: get its data, DEL waiting hash, RENAME active to waiting, ZADD with next_run_at, re-dispatch blocked job
53
+ - Otherwise: RENAME active to waiting, ZADD with next_run_at
54
+
55
+ ### Permanent failure (fail)
56
+
57
+ - Check if `{queue}-fail:waiting` EXISTS
58
+ - If yes: generate new ID, create failure job with data `[original_id, job_data, error]`, dispatch to fail queue
59
+ - Call finish to clean up
60
+
61
+ ### Stalled jobs (sweep)
62
+
63
+ - ZRANGEBYSCORE on active set for scores ≤ now (limit 100)
64
+ - For each stalled job: parse `{id}:{worker_id}`, call handle_stall
65
+
66
+ ### Stall handling (handle_stall)
67
+
68
+ - ZREM from active set
69
+ - Increment `stall_count` in `active_job:{id}`
70
+ - If `stall_count >= max_stalls`, call fail; otherwise call do_retry
@@ -0,0 +1,19 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ redis:
5
+ image: redis:7-alpine
6
+ container_name: queasy-redis
7
+ ports:
8
+ - '6379:6379'
9
+ command: redis-server --save 60 1 --loglevel warning
10
+ volumes:
11
+ - redis-data:/data
12
+ healthcheck:
13
+ test: ['CMD', 'redis-cli', 'ping']
14
+ interval: 5s
15
+ timeout: 3s
16
+ retries: 5
17
+
18
+ volumes:
19
+ redis-data:
package/jsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "checkJs": true,
4
+ "strict": true,
5
+ "target": "ES2022",
6
+ "module": "ESNext",
7
+ "moduleResolution": "node",
8
+ "allowSyntheticDefaultImports": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "noEmit": true,
14
+ "lib": ["ES2022"]
15
+ },
16
+ "include": ["src/**/*.js", "test/**/*.js"]
17
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "queasy",
3
+ "version": "0.1.0",
4
+ "description": "A simple Redis-backed queue library for Node.js",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node --test",
9
+ "test:coverage": "node --test --experimental-test-coverage",
10
+ "test:watch": "node --test --watch",
11
+ "lint": "biome check .",
12
+ "lint:fix": "biome check --write .",
13
+ "format": "biome format --write .",
14
+ "typecheck": "tsc --noEmit -p jsconfig.json",
15
+ "docker:up": "docker compose up -d",
16
+ "docker:down": "docker compose down",
17
+ "docker:logs": "docker compose logs -f"
18
+ },
19
+ "keywords": [
20
+ "queue",
21
+ "redis",
22
+ "job",
23
+ "task",
24
+ "worker"
25
+ ],
26
+ "author": "",
27
+ "license": "ISC",
28
+ "peerDependencies": {
29
+ "redis": "^5.10.0"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "^2.3.14",
33
+ "@types/node": "^25.2.0",
34
+ "redis": "^5.10.0",
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }