[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

Job Locks Documentation

Overview

The Job Locks interface keeps a job to one instance at a time when the same code base runs as several processes. A lock is a single row in the job_locks table, keyed by a string you choose, holding the identifier of the worker that owns it and the moment its lease expires.

The entry point is runWithLock(lockKey, ttlMs, work): it tries to take the lease for the current process, runs work only if it got it, and releases the lease afterwards. Every process identifies itself with WORKER_ID, a UUID generated once when the module is loaded. Underneath, JobLockModel exposes the lease operations directly for jobs whose shape does not fit a single runWithLock call.

Because the lock is a database row, it needs no queue, scheduler or coordination service beyond the database the DMS already uses.

Key Features

  • Single-flight jobsrunWithLock returns true when it acquired the lock and ran your function, false when another worker held it and the work was skipped.
  • Database-backed leases — the lock lives in the job_locks table of the CMS core schema (cms-core), so no extra infrastructure is involved.
  • Time-bounded ownership — every lock carries an expiresAt; a holder that dies without releasing stops blocking other workers once the lease expires.
  • Per-process holder identityWORKER_ID names the current process as the holder, and release and refresh are checked against it.
  • Re-entrant acquisition — a holder that already owns a lock re-acquires it and pushes its expiry forward instead of failing.
  • Direct model accessJobLockModel exposes acquire, release, refresh and peek for jobs that manage their own lease.

Dependencies

This interface relies on the following AntelopeJS packages:

Quick Start

A cron fires on every instance, so wrap the work in a lock to keep it to one:

import { Logging } from "@antelopejs/interface-core/logging";
import cron, { type ScheduledTask } from "node-cron";
import { runWithLock } from "@antelopejs-private/cms/interfaces/job-locks";

const CRON_NAME = "cleanup-user-invites";
const CRON_SCHEDULE = "0 3 * * *";
const LOCK_TTL_MS = 30 * 60 * 1000; // longer than the job's worst case

async function runCleanupUserInvites(): Promise<void> {
  // delete expired invites
}

export function scheduleCleanupUserInvites(): ScheduledTask {
  return cron.schedule(CRON_SCHEDULE, () => {
    void runWithLock(
      `cron:${CRON_NAME}`,
      LOCK_TTL_MS,
      runCleanupUserInvites,
    ).catch((error: unknown) => {
      Logging.Error(`Cron '${CRON_NAME}' failed:`, error);
    });
  });
}

The instance that wins the lock runs runCleanupUserInvites; the others see runWithLock resolve to false and do nothing.

runWithLock

runWithLock acquires lockKey for the current worker, awaits work, and releases the lock in a finally block, so the lease is freed whether work resolves or rejects.

import { runWithLock } from "@antelopejs-private/cms/interfaces/job-locks";

const ran = await runWithLock("nightly-prune", 30_000, async () => {
  // runs on one instance at a time
});

if (!ran) {
  // another worker holds the lock right now
}

Signature

function runWithLock(
  lockKey: string,
  ttlMs: number,
  work: () => Promise<void>,
): Promise<boolean>;
ParameterDescription
lockKeyIdentifier of the lock. It is the primary key of the job_locks row, so two callers using the same string contend for the same lock.
ttlMsLease duration in milliseconds, counted from the moment of acquisition. Size it above the job's worst-case duration.
workThe function to run while holding the lock. It takes no arguments and returns a promise.

Return value and errors

runWithLock resolves to true when it acquired the lock and work completed, and to false when acquisition failed — in that case work is never called.

If work rejects, the lock is still released and the rejection propagates to the caller: runWithLock rejects rather than returning false. Callers that fire the job without awaiting it, such as the cron above, attach a .catch to log that rejection.

The release itself is best-effort: runWithLock swallows an error raised while deleting the row, since the lease expires on its own anyway.

Lease sizing

The lease is not renewed while work runs — runWithLock never calls refresh. The ttlMs you pass is the entire window during which other workers are kept out:

  • If work runs longer than ttlMs, another worker can acquire the same key and run the job concurrently.
  • If the holder crashes, the row is not deleted; it stops blocking other workers only once expiresAt passes. A restarted process gets a fresh WORKER_ID, so it cannot reclaim its previous lock through the re-entrant path.

A job that legitimately runs longer than any TTL you are comfortable with can hold its own lease and call JobLockModel.refresh periodically instead of using runWithLock. The "Refresh a lease" section below shows that shape.

Scope of a lock

runWithLock calls GetModel(JobLockModel) without an instance id, so it works against the default instance of the cms-core schema. A lock key is therefore shared by the whole deployment and is not scoped to a tenant. Encode any scoping you need — per tenant, per resource — into the key string itself.

WORKER_ID

WORKER_ID is a UUID generated once when the interface module is loaded, and used by runWithLock as the holder of every lock it takes.

import { Logging } from "@antelopejs/interface-core/logging";
import { WORKER_ID } from "@antelopejs-private/cms/interfaces/job-locks";

Logging.Info(`Worker ${WORKER_ID} starting up`);

Two processes running the same code base have different WORKER_IDs, and a process that restarts gets a new one. Nothing outside the process persists it, so it identifies a run of a process — not a machine, and not a deployment slot.

The JobLock Table

JobLock is the table backing the locks. It is registered under the name exported as jobLocksTableName ("job_locks"), in the CMS core schema.

import {
  JobLock,
  jobLocksTableName,
} from "@antelopejs-private/cms/interfaces/job-locks";
FieldTypeDescription
_idstringThe lock key. One row per lock, so the primary key is what makes acquisition mutually exclusive.
holderstringIdentifier of the current owner — the acquiring worker's WORKER_ID when the lock was taken through runWithLock.
acquiredAtDateWhen the current holder took, or last re-acquired, the lock.
expiresAtDateEnd of the lease. Indexed. Once it is in the past, any worker can acquire the lock.

A row is deleted on release, so an absent row means "free", and a row whose expiresAt has passed means "abandoned, free to take".

JobLockModel

JobLockModel extends BasicDataModel(JobLock, jobLocksTableName), so it carries the standard model surface from @antelopejs/interface-database-decorators plus the four lock operations below. Obtain it with GetModel:

import { GetModel } from "@antelopejs/interface-database-decorators";
import {
  JobLockModel,
  WORKER_ID,
} from "@antelopejs-private/cms/interfaces/job-locks";

const locks = GetModel(JobLockModel);
MethodReturnsDescription
acquire(lockKey, ttlMs, holder)Promise<boolean>Takes the lock for holder, or refreshes it if holder already owns it.
release(lockKey, holder)Promise<void>Deletes the lock if holder owns it.
refresh(lockKey, ttlMs, holder)Promise<boolean>Pushes expiresAt forward while the row still belongs to holder.
peek(lockKey)Promise<JobLock | undefined>Reads the lock row as it stands, whoever holds it.

Acquire a lock

const acquired = await locks.acquire("reindex", 60_000, WORKER_ID);

acquire first updates the row for lockKey under the condition that its expiresAt is in the past or its holder is already the caller, writing the new holder, a fresh acquiredAt, and an expiresAt of now plus ttlMs. If that update changed a row, it returns true.

If nothing was updated, it inserts a row with _id set to lockKey and returns true — the path taken when no row exists yet. If the insert throws, acquire returns false: the usual reason is that a row already exists, held by another worker under an unexpired lease.

The holder branch of the condition is what makes acquisition re-entrant: calling acquire again with the same holder succeeds and moves the expiry forward, rather than reporting the lock as taken.

Release a lock

await locks.release("reindex", WORKER_ID);

release deletes the row only if its holder matches. A worker whose lease expired and was taken over by someone else therefore deletes nothing and cannot cut the later holder's lease short. Releasing a lock you never held is a no-op.

Refresh a lease

refresh writes a new expiresAt of now plus ttlMs on the row, filtered on holder. It returns false only when the row is gone or belongs to someone else; a lease of yours that has already expired but that nobody has claimed still refreshes successfully.

Use it from a long job that manages its own lease — pair acquire and release by hand around the work, and renew on an interval shorter than ttlMs:

import { GetModel } from "@antelopejs/interface-database-decorators";
import {
  JobLockModel,
  WORKER_ID,
} from "@antelopejs-private/cms/interfaces/job-locks";

const LOCK_KEY = "reindex";
const TTL_MS = 60_000;
const RENEW_EVERY_MS = 20_000;

export async function reindexWithOwnLease(): Promise<boolean> {
  const locks = GetModel(JobLockModel);
  if (!(await locks.acquire(LOCK_KEY, TTL_MS, WORKER_ID))) {
    return false;
  }

  const renew = setInterval(() => {
    void locks.refresh(LOCK_KEY, TTL_MS, WORKER_ID);
  }, RENEW_EVERY_MS);

  try {
    // long-running work
  } finally {
    clearInterval(renew);
    await locks.release(LOCK_KEY, WORKER_ID);
  }
  return true;
}

A refresh that resolves to false tells you the lease is no longer yours; a job that must not run twice should stop its work at that point rather than carry on.

Inspect a lock

import { Logging } from "@antelopejs/interface-core/logging";

const lock = await locks.peek("reindex");
if (lock) {
  Logging.Info(`held by ${lock.holder} until ${lock.expiresAt.toISOString()}`);
}

peek returns the row whatever its state — held, or expired and not yet reclaimed — or undefined when no row exists. It acquires nothing and cleans up nothing, so treat it as diagnostics: a lock read as free may be taken by the time you act on it.

What the Lock Guarantees

State the guarantee precisely before you rely on it:

  • Exclusion rests on one row and one conditional write. Every contender writes the same primary key: first a filtered update that only matches an expired lock or the contender's own, then an insert that only succeeds if no row is there. The strength of the exclusion is exactly the atomicity the database gives that filtered update and that duplicate-key insert.
  • Re-entrancy is per process, not per call. acquire also matches a row whose holder is already the caller, and WORKER_ID is shared by everything running in one process. Two overlapping runWithLock calls on the same key inside the same process therefore both acquire and both run, and whichever finishes first deletes the row and frees the key while the other is still working. The exclusion holds between processes; serializing calls within one process is left to the caller.
  • The lease is a timeout, not a supervisor. Nothing observes whether the holder is alive. A holder that hangs keeps the lock until expiresAt, and a job that outlives its TTL can end up running twice at once, since runWithLock does not renew mid-run.
  • There is no queue and no retry. A worker that fails to acquire returns immediately with false. The work is skipped for that worker, not deferred, so the next attempt is whatever schedules the job again.
  • The holder check is the only ownership proof. release and refresh compare the stored holder against the value you pass. Nothing is handed to work that would let it prove to another service that it still owns the lease.
  • A failed insert reads as "taken". acquire returns false for any error raised by the insert, not only for a duplicate key, so a database problem during acquisition presents as the lock being held.

In short: the lock keeps a well-behaved job to one instance at a time and recovers on its own after a crash. It is not a fencing token, and it does not protect against a job that runs past its TTL.