interlock.sh docs

Durable tasks and workers #

Raw markdown: /tasks.md

For humans #

Interlock Tasks lets an application submit work over HTTPS and lets a machine behind NAT run it without opening a port. Interlock remembers the task, leases one attempt to an eligible worker, records progress and logs, and keeps the canonical result. The always-open SSE connection makes claims fast; it is only a wake-up hint. A disconnected stream may add one polling interval and may never lose work.

Do this #

Mint two keys for the same project. They are deliberately different credentials:

interlock keys new "app server" --kind task-producer --project <project>
interlock keys new "garage worker" --kind task-worker --project <project>

Run either complete sample:

# Java worker, then Java submitter in another terminal
INTERLOCK_WORKER_KEY=il_... ./gradlew :tasks-java:run --args=worker
INTERLOCK_PRODUCER_KEY=il_... ./gradlew :tasks-java:run --args='submit Ada'

# Node 20+ worker, then Node submitter
INTERLOCK_WORKER_KEY=il_... node samples/tasks-node/worker.mjs
INTERLOCK_PRODUCER_KEY=il_... node samples/tasks-node/producer.mjs Ada

Or use the CLI from a connected project:

interlock tasks submit demo/hello --input '{"name":"Ada"}' --idempotency-key hello-ada
interlock tasks list
interlock tasks watch <task-id>
interlock tasks workers
interlock tasks doctor --spool /path/to/worker-spool

Open /tasks for the attention-first overview, /task-types for per-type capacity and history, and /workers for the fleet and one worker's registered handlers.

What will bite you #

Then read #

For robots #

The task row and ordered journal are truth. Treat SSE frames as notifications to re-read truth. Every worker mutation carries taskId, attemptId, fence, and leaseToken; a stale attempt gets 409 STALE_ATTEMPT and must stop reporting. Never reinterpret that refusal as an accepted duplicate.

Java #

HttpTaskProducer producer = new HttpTaskProducer(url, producerKey);
Wire.TaskView submitted = producer.submit(Wire.SubmitRequest.of(
    "demo/hello", 1, Map.of("name", "Ada")));
Wire.TaskView done = producer.await(submitted.id(), Duration.ofMinutes(2), Duration.ofSeconds(1));
try (HttpTaskTransport transport = new HttpTaskTransport(url, workerKey);
     TaskWorker worker = TaskWorker.builder(transport)
         .workerId("garage-mac")
         .maxConcurrent(2)
         .handle("demo/hello", 1, EffectSafety.PURE,
             (input, ctx) -> Map.of("message", "hello"))
         .start()) {
  worker.join();
}

The Java worker uses separate handler and housekeeping executors. Saturating handler capacity does not starve lease renewal. It writes a terminal completion to an atomic local spool before sending it and removes the record only after the control plane answers.

Node #

import { TaskProducer, TaskWorker } from 'interlock-node-sdk';

const producer = new TaskProducer({ serviceUrl, key: producerKey });
const task = await producer.submit('demo/hello', 1, { name: 'Ada' });
const done = await producer.await(task.id);
const worker = new TaskWorker({ serviceUrl, key: workerKey,
  workerId: 'garage-node', maxConcurrent: 2 });
worker.handle('demo/hello', 1, async (input, ctx) => {
  ctx.progress(50, 'working');
  return { message: `Hello, ${input.name}!` };
}, { effectSafety: 'pure' });
await worker.start();

The Node worker has the same correctness shape: HTTPS claims and reports, SSE hints plus polling, renewal independent of handler promises, cooperative cancellation, bounded concurrency, and an atomic disk completion spool.

Outcomes and retry #

Handler outcomeTask behavior
returnsattempt succeeds; current fenced completion becomes canonical
retryable failurere-queued only when task policy and declared effect safety both permit it
unknownoutcome-unknown; no automatic repeat of a possibly committed effect
other exceptionterminal failure

Submission is effectively once for a caller-provided idempotency key within one project. Reusing the key with the same canonical request returns the original task; changing the body returns IDEMPOTENCY_CONFLICT.

Operations #

  1. interlock tasks doctor proves the Tasks API is reachable and authorized and reports clock drift, queue depth, online workers and outbox debt. Pass --spool on a worker machine to prove its completion spool is readable and writable.
  2. interlock tasks workers distinguishes ready, full, draining and offline. Full means all slots are doing work; do not restart it as though it were offline.
  3. Drain a worker locally (TaskWorker.drain() or SIGTERM in the supplied runners). It renews existing attempts and claims none. Revoking a worker key is an authority action, not a graceful drain.
  4. If queued work has no matching worker, compare the literal type@version, pool and project. Interlock does not guess hardware capability or silently route across projects.

Verify #

These are the fast, reproducible gates:

./gradlew :interlock-java-sdk:test :tasks-java:compileJava
npm test --prefix interlock-node-sdk
node --check samples/tasks-node/worker.mjs
node --check samples/tasks-node/producer.mjs

For the control plane and browser surface:

./gradlew :interlock-java-api:test -Dinterlock.test.db=true
./run-web-test.sh

The browser run captures desktop and phone Tasks frames. Review them: no horizontal clipping, literal task types preserve case, status always has words in addition to color, and the next action for a no-worker task is visible above the metrics.

Operations evidence #

Tasks schema changes are versioned in Flyway migration V2__interlock_tasks.sql; Hibernate no longer has to invent this subsystem's production DDL. A consistent scheduler-only backup and a causal scratch restore use:

scripts/tasks-backup-restore.sh backup /secure/tasks-$(date +%F).sql
TASKS_RESTORE_ADMIN_USER=... TASKS_RESTORE_ADMIN_PASSWORD=... \
  scripts/tasks-backup-restore.sh verify /secure/tasks-$(date +%F).sql

The verifier restores into a purpose-named scratch database, checks orphan attempts/events and current-attempt references, reports counts and checksum, then removes only that scratch database. For repeatable latency and fairness measurements against one or several API nodes:

INTERLOCK_TASK_PRODUCER_KEY=... INTERLOCK_TASK_WORKER_KEY=... \
  node scripts/tasks-load.mjs --urls http://node-a:8090,http://node-b:8090 \
  --tasks 1000 --workers 16

That smoke reports enqueue→claim separately from enqueue→terminal. A production edge fleet gate will not label itself qualified without the requested stream tier, HTTPS, slow readers, a reconnect storm, and origin heap/file-descriptor snapshots:

INTERLOCK_TASK_PRODUCER_KEY=... INTERLOCK_TASK_WORKER_KEY=... INTERLOCK_METRICS_KEY=... \
  node scripts/tasks-load.mjs --gate 1000 --urls https://api.interlock.sh \
  --tasks 1000 --workers 1000 --slow-readers 10 --reconnect-streams 1000 \
  --metrics-url https://api.interlock.sh/api/metrics