Skip to content

PostgreSQL

PostgresBackend gives you everything the Redis lease strategy does: one winner per run, backups, no overlapping runs, catch-up and hand-over on stop(). It needs PostgreSQL 9.5 or newer.

import { PostgresBackend, Scheduler } from 'anarchon';
const scheduler = new Scheduler({
backend: new PostgresBackend({ connection: process.env.DATABASE_URL }),
});

connection accepts a connection string or a client you already have.

// A connection string. anarchon opens a small pool with `pg` and closes it on stop().
new PostgresBackend({ connection: process.env.DATABASE_URL }); // postgres://; user and password go in the URL
// Nothing: postgres://localhost:5432/postgres, with the PG* environment variables filling the gaps
new PostgresBackend();
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
new PostgresBackend({ connection: pool }); // stop() does not end the pool
You pass Library
A Pool pg (node-postgres)
A connected Client pg
A sql instance postgres (postgres.js)
{ all, transaction } Anything else: your own adapter
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL);
new PostgresBackend({ connection: sql });

A pool is the better choice in a service. A single Client can only run one statement at a time, so anarchon queues its own statements on it.

Option Default Meaning
connection postgres://localhost:5432/postgres Connection string, or your own client
table 'anarchon_runs' Table name. May include a schema: 'jobs.cron_runs'

They are created on start() if they don’t exist. Two instances starting together is fine.

Table One row per Holds
<table> run attempt, token, status, runner, lease end
<table>_attempts attempt who, why, when it was claimed, how it ended (the history)
<table>_jobs job the row that makes claims of one job take turns
<table>_meta setting the store format, so a newer anarchon’s data is never misread

If your application’s database user can’t create tables, create them once with a privileged user by running start() in a migration step, then grant SELECT, INSERT, UPDATE, DELETE on the four tables.

To keep them out of your main schema:

new PostgresBackend({ connection: pool, table: 'jobs.cron_runs' }); // the schema must exist

A run is a row with the primary key (job, slot). A claim is one short transaction:

sequenceDiagram
  participant A as Instance A
  participant B as Instance B
  participant P as PostgreSQL
  A->>P: BEGIN
  A->>P: upsert the job's row in runs_jobs (locks it)
  B->>P: BEGIN
  B->>P: upsert the job's row in runs_jobs
  Note over B,P: B waits: A holds the row lock
  A->>P: SELECT the run (job, slot): no row
  A->>P: INSERT the run: attempt 1, token a1, lease end
  A->>P: COMMIT
  P-->>A: won
  Note over B,P: the lock is free, B continues
  B->>P: SELECT the run (job, slot): running, lease alive
  B->>P: COMMIT
  P-->>B: running, so B becomes a backup

The first statement of every claim writes the job’s row in <table>_jobs. PostgreSQL locks that row until the transaction commits, so claims of one job happen one after another. The second instance reads the run only after the first has committed, so it always sees the winner’s row.

Claims of different jobs don’t wait for each other.

  • A claim that can win is a transaction (a few statements).
  • A heartbeat, a hand-over and “finished” are single UPDATE ... WHERE token = ? statements.
  • A backup checking on a live run is a single SELECT. It opens a transaction only when the lease has run out.

With three instances and the default 5-second heartbeat, a running job costs about one small UPDATE every 5 seconds and one SELECT per backup every 5 to 10 seconds.

Lease ends are computed with the database’s clock (clock_timestamp()), not the instances’ clocks. A clock difference between two instances can’t make a live lease look expired.

Each claim is a single transaction, and anarchon keeps no session state between statements: no advisory locks, no LISTEN, no session variables. That is what transaction-mode poolers such as PgBouncer require. anarchon’s own tests run against a direct connection, so try it in your setup before relying on it.

History lives in <table>_attempts, trimmed to about historySize attempts per job and to historyTtlMs if you set it. Because it is an ordinary table, you can query it yourself:

SELECT job, slot, attempt, instance_id, reason, claimed_at
FROM anarchon_runs_attempts
WHERE job = 'sync'
ORDER BY slot DESC, attempt DESC
LIMIT 20;

Treat the table layout as internal: it can change between versions. scheduler.history() is the stable way to read it.