Review funkt

This commit is contained in:
2026-08-09 21:56:00 +00:00
parent f522ae9953
commit fe9dfa4519
11366 changed files with 2335077 additions and 97 deletions

View File

@@ -0,0 +1,348 @@
-- BullMQ PostgreSQL backend — schema (types, sequences, tables, indexes).
--
-- Consolidated initial schema. All BullMQ objects live in a connection-level
-- schema (namespace); the operation functions live in 0002_functions.sql.
-- BullMQ PostgreSQL backend — initial schema (schema version 1).
--
-- This file is the *portable source of truth* for the schema. It uses only
-- standard SQL / PL-pgSQL so it can be shared verbatim with the future Elixir
-- and Python ports (which call the very same tables and functions).
--
-- All objects are created inside the backend's configured *schema* (the
-- connection-level namespace, default `bullmq`). The migration runner has
-- already created the schema and set `search_path` to it, so the unqualified
-- names below resolve into that schema. Unlike Redis — where a per-queue key
-- `prefix` namespaces every key — SQL uses the schema as the single namespace
-- for the whole connection, so there is no per-row/per-queue prefix.
--
-- The migration ledger table (`migration`) is bootstrapped by the
-- migration runner itself, so it is intentionally not created here.
--
-- v1 only establishes the foundation that is independent of the job-storage
-- model: per-queue metadata. Subsequent migrations add the job tables,
-- indexes and the atomic operation functions.
-- Per-queue metadata. Mirrors the Redis `<prefix>:<queue>:meta` hash: a small
-- key/value store keyed by (queue, field). Used for the queue version, global
-- concurrency, global rate limit, paused flag, etc.
CREATE TABLE IF NOT EXISTS meta (
queue text NOT NULL,
field text NOT NULL,
value text,
PRIMARY KEY (queue, field)
);
-- BullMQ PostgreSQL backend — core job schema (schema version 2).
--
-- Portable source of truth (standard SQL / PL-pgSQL) shared verbatim with the
-- future Elixir and Python ports. This migration establishes the *storage
-- model* (tables + indexes) for every BullMQ feature: FIFO/LIFO, priority,
-- delayed jobs, concurrency, locks/stalled detection, flows (parent/child
-- dependencies), deduplication, rate limiting, repeatable job schedulers, the
-- event stream and metrics. The atomic *operation* functions (addJob,
-- moveToActive, …) are layered on top in subsequent migrations.
--
-- ──────────────────────────────────────────────────────────────────────────
-- Design overview
-- ──────────────────────────────────────────────────────────────────────────
-- * Namespace = the connection's PostgreSQL *schema* (default `bullmq`), not a
-- per-queue key prefix. In Redis a `prefix` namespaces every key because the
-- keyspace is shared; in SQL the schema already isolates BullMQ from the
-- user's other tables, and a different schema (or database) gives a fully
-- independent BullMQ namespace. So there is no per-row/per-queue prefix — the
-- `queue` column alone discriminates within the schema.
--
-- * Single `job` table keyed by (queue, id). The Redis adapter spreads a
-- job's lifecycle across many keys (wait list, prioritized zset, delayed zset,
-- active list, completed/failed zsets, locks, …); here a single `state`
-- column plus a handful of promoted, indexable columns replaces all of them.
-- State transitions are plain `UPDATE`s; claiming uses `FOR UPDATE SKIP
-- LOCKED` so concurrent workers never block each other.
--
-- * Time is stored as `bigint` epoch-milliseconds (suffix `_ms`) to match
-- BullMQ's millisecond API exactly and avoid timezone math on hot paths.
--
-- * Partial indexes are state-scoped: each hot path (claim next ready job,
-- promote due delayed jobs, find stalled active jobs, clean finished jobs)
-- has a small index that only contains the rows in the relevant state.
--
-- * "paused" and "prioritized" are NOT physical states. Pausing is an O(1)
-- queue-level flag in `meta`; a prioritized job is simply a waiting job
-- with `priority > 0`. This avoids the bulk row rewrites Redis performs on
-- pause and removes the need for a separate prioritized structure — full
-- `ORDER BY (priority, seq)` does the job.
-- ──────────────────────────────────────────────────────────────────────────
-- Physical job states. (Pause = meta flag; prioritized = waiting + priority>0.)
CREATE TYPE job_state AS ENUM (
'waiting',
'active',
'completed',
'failed',
'delayed',
'waiting-children'
);
-- Status of a parent→child dependency (flows).
CREATE TYPE dep_status AS ENUM (
'pending', -- child not finished yet
'processed', -- child completed; `value` holds its return value
'ignored', -- child failed but parent was told to ignore it; `value` = reason
'failed' -- child failed and the failure is recorded against the parent
);
-- Global monotonic ordering for FIFO. A single sequence gives a total order
-- across all queues; FIFO within a queue is preserved because `seq` increases
-- with insertion. LIFO jobs are stored with a *negative* `seq` (from the same
-- sequence, negated) so they sort ahead of all FIFO jobs and, among themselves,
-- most-recently-added first — reproducing "last in, first out" with one column.
CREATE SEQUENCE job_seq;
-- Global monotonic id for the event stream (the Postgres analogue of a Redis
-- stream entry id). Consumers page forward with `id > cursor`.
CREATE SEQUENCE event_seq;
-- ──────────────────────────────────────────────────────────────────────────
-- Jobs
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE job (
queue text NOT NULL,
id text NOT NULL, -- custom id, or numeric counter as text
seq bigint NOT NULL, -- FIFO order (negative = LIFO); see sequence above
name text NOT NULL,
state job_state NOT NULL,
-- Payload / options. BullMQ serializes these as JSON strings; stored as jsonb
-- (always valid JSON) so they are queryable and compact.
data jsonb NOT NULL DEFAULT '{}'::jsonb,
opts jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Promoted, frequently-read scalars (also live implicitly in `opts`, but are
-- broken out so they can be indexed / updated cheaply).
priority integer NOT NULL DEFAULT 0, -- 0 = no priority (FIFO); >0 = prioritized
delay_ms bigint NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 1,
attempts_made integer NOT NULL DEFAULT 0,
attempts_started integer NOT NULL DEFAULT 0,
-- Progress / results.
progress jsonb, -- number or object
return_value jsonb, -- BullMQ `returnvalue`
failed_reason text,
stacktrace jsonb, -- array of frames
deferred_failure text,
processed_by text, -- worker id that processed it
-- Timestamps (epoch ms).
added_at_ms bigint NOT NULL, -- BullMQ `timestamp`
process_at_ms bigint, -- when a delayed job becomes ready
processed_at_ms bigint, -- BullMQ `processedOn`
finished_at_ms bigint, -- BullMQ `finishedOn`
-- Locking / stalled detection. An active job is "locked" until
-- `locked_until_ms`; once it elapses the job is considered stalled.
lock_token text,
locked_until_ms bigint,
stalled_count integer NOT NULL DEFAULT 0,
-- Deduplication / scheduler linkage.
dedup_id text, -- BullMQ deduplicationId / debounceId
scheduler_id text, -- id of the scheduler that produced this job (BullMQ repeatJobKey)
-- Flow (parent/child) linkage. A child stores its parent's coordinates; a
-- parent tracks how many children are still unfinished.
parent_queue text,
parent_id text,
parent_key text, -- denormalized "<queue>:<id>" (Redis-compatible "<prefix>:<queue>:<id>")
pending_deps integer NOT NULL DEFAULT 0,
-- Two-phase stalled mark/sweep: set on the first stalled pass, reclaimed on
-- the next (see move_stalled_jobs_to_wait in 0002_functions.sql).
stalled_marked boolean NOT NULL DEFAULT false,
PRIMARY KEY (queue, id)
);
-- Claim next ready job: waiting jobs ordered by (priority, seq). Covers both the
-- "wait" (priority = 0) and "prioritized" (priority > 0) views, and the FIFO/LIFO
-- ordering via the signed `seq`. Used with FOR UPDATE SKIP LOCKED.
CREATE INDEX job_ready_idx
ON job (queue, priority, seq)
WHERE state = 'waiting';
-- Promote due delayed jobs (process_at_ms <= now) and find the next wake-up.
CREATE INDEX job_delayed_idx
ON job (queue, process_at_ms)
WHERE state = 'delayed';
-- Active jobs: stalled scan by lock expiry, and O(1)-ish concurrency counting
-- (COUNT over the partial index for a queue).
CREATE INDEX job_active_idx
ON job (queue, locked_until_ms)
WHERE state = 'active';
-- Finished jobs: range listing and age/count-based retention + cleaning.
CREATE INDEX job_finished_idx
ON job (queue, state, finished_at_ms)
WHERE state IN ('completed', 'failed');
-- Parents blocked on children.
CREATE INDEX job_waiting_children_idx
ON job (queue, seq)
WHERE state = 'waiting-children';
-- Reverse lookup from a child to its parent (e.g. orphan/repair scans).
CREATE INDEX job_parent_idx
ON job (parent_queue, parent_id)
WHERE parent_id IS NOT NULL;
-- Locate the job(s) produced by a given scheduler.
CREATE INDEX job_scheduler_idx
ON job (queue, scheduler_id)
WHERE scheduler_id IS NOT NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- Job logs (append-only, per-job ordered lines)
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE job_log (
queue text NOT NULL,
job_id text NOT NULL,
idx bigint NOT NULL, -- per-job ordinal (0-based); range reads + trimming
row text NOT NULL,
PRIMARY KEY (queue, job_id, idx),
FOREIGN KEY (queue, job_id)
REFERENCES job (queue, id) ON DELETE CASCADE
);
-- ──────────────────────────────────────────────────────────────────────────
-- Flow dependencies (parent → child)
-- ──────────────────────────────────────────────────────────────────────────
-- Owned by the parent. Reproduces BullMQ's processed/ignored/failed/unprocessed
-- child sets with a single `status` column, and stores each processed child's
-- return value (or failure reason) in `value`. `pending_deps` on the parent job
-- is the count of rows here still in 'pending'.
CREATE TABLE job_dependency (
parent_queue text NOT NULL,
parent_id text NOT NULL,
child_queue text NOT NULL,
child_id text NOT NULL,
child_key text NOT NULL, -- denormalized "<queue>:<id>" (Redis-compatible "<prefix>:<queue>:<id>")
status dep_status NOT NULL DEFAULT 'pending',
value jsonb,
PRIMARY KEY (parent_queue, parent_id, child_key),
FOREIGN KEY (parent_queue, parent_id)
REFERENCES job (queue, id) ON DELETE CASCADE
);
-- Paginate a parent's children by category (processed / pending / …).
CREATE INDEX dep_parent_status_idx
ON job_dependency (parent_queue, parent_id, status);
-- Resolve dependencies from the child side (when a child finishes, or when a
-- child dependency is explicitly removed).
CREATE INDEX dep_child_idx
ON job_dependency (child_queue, child_id);
-- ──────────────────────────────────────────────────────────────────────────
-- Event stream (QueueEvents)
-- ──────────────────────────────────────────────────────────────────────────
-- The Postgres analogue of the Redis events stream. `id` is globally monotonic;
-- consumers page forward with `id > cursor`. Blocking reads are delivered via
-- LISTEN/NOTIFY (the operation layer NOTIFYs on insert); trimming deletes the
-- lowest ids beyond the configured max length.
CREATE TABLE event (
queue text NOT NULL,
id bigint NOT NULL DEFAULT nextval('event_seq'),
event text NOT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at_ms bigint NOT NULL,
PRIMARY KEY (queue, id)
);
CREATE TABLE metrics (
queue text NOT NULL,
kind text NOT NULL, -- 'completed' | 'failed'
count bigint NOT NULL DEFAULT 0, -- cumulative finished jobs
prev_ts bigint, -- ts of the last data point
prev_count bigint NOT NULL DEFAULT 0, -- count at the last data point
data bigint[] NOT NULL DEFAULT '{}',-- per-minute deltas, newest first
PRIMARY KEY (queue, kind)
);
-- ──────────────────────────────────────────────────────────────────────────
-- Rate limiting (token window per queue)
-- ──────────────────────────────────────────────────────────────────────────
-- One row per queue holding the current limiter window: `points` counts the
-- jobs consumed in the window, which ends at `expire_at_ms`. Mirrors the single
-- Redis `<prefix>:<queue>:limiter` counter key (with its PTTL). The global
-- limiter is the only mode the open-source backend supports (per-group rate
-- limiting was removed in BullMQ 3.0).
CREATE TABLE rate_limit (
queue text NOT NULL,
points bigint NOT NULL DEFAULT 0,
expire_at_ms bigint NOT NULL,
PRIMARY KEY (queue)
);
-- ──────────────────────────────────────────────────────────────────────────
-- Deduplication keys
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE dedup (
queue text NOT NULL,
dedup_id text NOT NULL,
job_id text NOT NULL,
expire_at_ms bigint, -- NULL = no expiry; otherwise lazily reclaimed
PRIMARY KEY (queue, dedup_id)
);
-- Expired dedup keys are reclaimed lazily by operation functions in
-- 0002_functions.sql (for example deduplicate_job and
-- dedup_finalize). This index keeps those lookups/deletes efficient.
CREATE INDEX dedup_expire_idx
ON dedup (queue, expire_at_ms)
WHERE expire_at_ms IS NOT NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- Job schedulers (repeatable jobs)
--
-- A scheduler is a job factory: it stores a template (data/opts) plus a repeat
-- spec (cron `pattern` or fixed `every` ms) and, on each upsert, produces the
-- next delayed job `repeat:<schedulerId>:<nextMillis>`. For cron the caller
-- computes nextMillis (JS cron-parser); for `every` the backend computes it,
-- honouring `offset_ms` (the phase offset from `start_date`).
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE scheduler (
queue text NOT NULL,
scheduler_id text NOT NULL,
name text,
next_run_ms bigint, -- next iteration's due time
pattern text, -- cron expression (mutually exclusive with every_ms)
every_ms bigint, -- fixed interval in ms
tz text, -- timezone for cron evaluation
start_date_ms bigint,
end_date_ms bigint,
limit_count integer, -- max number of iterations (NULL = unlimited)
iteration_count integer NOT NULL DEFAULT 0,
template_data jsonb, -- payload template reused for every iteration
template_opts jsonb, -- options template reused for every iteration
producer_id text,
offset_ms bigint, -- 'every' phase offset from start_date
PRIMARY KEY (queue, scheduler_id)
);
-- Find schedulers whose next iteration is due, and order schedulers by next run.
CREATE INDEX scheduler_next_idx
ON scheduler (queue, next_run_ms);
-- ── keepLastIfActive proto-next storage ──────────────────────────────────
-- When a job is deduplicated while its winner is *active* and keepLastIfActive
-- is set, the new job's payload is stashed here (Redis `dn:<id>` hash) and the
-- dedup key is persisted (no expiry). When the active winner finishes, the
-- stashed payload is turned into a real job (the new winner). At most one
-- proto-next exists per id; a later add while active overwrites it.
CREATE TABLE dedup_next (
queue text NOT NULL,
dedup_id text NOT NULL,
payload jsonb NOT NULL, -- { name, data, opts, jobId }
PRIMARY KEY (queue, dedup_id)
);