Worker
The worker is a second container from the same image (ri-worker in Docker Compose; pnpm start:worker in a checkout) with its own entrypoint and no port. The web process only enqueues background jobs; the worker claims and settles them.
Its jobs are the credential verifications behind the library. When a credential is registered, issued or re-verified, the web process records the request and queues a job. The worker fetches the credential, checks its digest and signature, its status and validity window, and its schema conformance, extracts the details the library shows, and writes the result to the record's verification envelope. A scheduled reconciliation sweep settles jobs a crashed worker abandoned, so a record cannot stay pending indefinitely. Without a running worker, registrations and re-verifications stay pending.
Worker Boot
The worker's wrapper passes --process-role=worker as the first argument to the shared entrypoint. The shared entrypoint runs the worker preflight before database URL construction, migrations and the worker process. The worker does not require settings only the web process reads (RI_APP_URL among them); an environment that omits them still starts a worker. A boot failure is one line on stderr, Worker boot failed: <message> [<code>], with the cause chain beneath it, and exit code 1. An invalid LOG_REDACT_PATHS fails the same way with the logger's own message naming the path.
Reading the queue
The application uses two pg-boss queues: library.verify-generation carries library verification work, and library.reconcile-pending-runs carries the worker's scheduled reconciliation sweep. A growing verification backlog means registrations or re-verifications are arriving faster than the worker can finish them, or jobs are retrying or waiting for unavailable dependencies. A pending generation that remains past the complete retry ladder is selected by the reconciliation sweep and settled as VERIFICATION_UNAVAILABLE with retryable: true; re-verify the record to create the next generation.
Worker settings
The worker reads these settings from its environment. Unset and blank values use the documented defaults where shown.
| Variable | Default | Bounds | Reader | Validation behaviour |
|---|---|---|---|---|
WORKER_JOB_TIMEOUT_SECONDS | 300 seconds | Integer from 30 seconds to 86400 seconds | readWorkerJobTimeoutSeconds | Unset or blank uses the default. A non-integer, fractional, or out-of-range value fails worker configuration before the queue starts. |
LIBRARY_RECONCILE_PENDING_RUNS_CRON | */10 * * * * | Any expression accepted by cron-parser with UTC and non-strict parsing | readReconcilePendingRunsCron | Unset or blank uses the default. A value the parser rejects fails worker configuration and names the variable. |
LIBRARY_RECONCILE_PENDING_RUNS_BATCH_SIZE | 500 | Positive integer no greater than 10000 | readReconcilePendingRunsBatchSize | Unset or blank uses the default. A non-positive, fractional, or over-limit value fails worker configuration before the queue starts. |
WORKER_HEARTBEAT_PATH | /tmp/worker-heartbeat | A writable file path shared by the worker and its healthcheck | heartbeat.ts and docker-worker-healthcheck.sh | No application-level parsing is applied. The worker removes and atomically writes the path; a path that cannot be used leaves the healthcheck without a valid heartbeat. |
WORKER_HEARTBEAT_MAX_AGE_SECONDS | 30 seconds | No application-defined ceiling; the healthcheck expects a numeric age in seconds | docker-worker-healthcheck.sh | The shell healthcheck rejects a non-numeric comparison and reports unhealthy when the heartbeat is missing, future-dated, or older than the configured limit. |
OTEL_WORKER_SERVICE_NAME | reference-implementation-worker in Compose | A non-blank service name; whitespace is trimmed | Compose maps it to the worker process's OTEL_SERVICE_NAME, read by resolveServiceName | Blank or unset uses the worker default. The process emits the resolved value as service.name. |
DATA_ENCRYPTION_KEY | None | 64-character hexadecimal application encryption key | resolveDataEncryptionKey and validateConfiguredEncryptionKey | Required for the worker. Boot fails when it is absent, has the invalid key format, is the development placeholder outside local development, or cannot validate against existing encrypted data. |
SCHEMA_CACHE_TTL_MS | 3600000 ms | Non-negative finite number | readSchemaCacheTtlMs in schema-loader.ts | Unset or blank uses one hour. An invalid or negative value logs a warning and falls back to one hour. |
CONTEXT_CACHE_TTL_MS | 3600000 ms | Non-negative finite number | readContextCacheTtlMs in context-cache.ts | Unset or blank uses one hour. An invalid or negative value logs a warning and falls back to one hour. |
CACHE_MAX_ENTRIES | 1000 per cache | Positive integer | readCacheMaxEntries | Unset or blank uses the default. An invalid value throws when a document cache is constructed; the web process also checks it at Node boot. |
RI_HTTP_USER_AGENT | Built-in uncefact-untp-utils value | Non-blank Latin-1 text without control characters when set | The guarded fetch's document resolver | Unset or blank uses the built-in value. The web process validates a configured override at boot; the worker's guarded fetch uses the same value and an invalid header value cannot be sent. |
The worker needs outbound HTTPS access to the UNTP and VC Data Model artefact hosts: untp.unece.org, vocabulary.uncefact.org, test.uncefact.org, www.w3.org, and w3c.github.io. It honours the same SCHEMA_CACHE_TTL_MS, CONTEXT_CACHE_TTL_MS, CACHE_MAX_ENTRIES, and RI_HTTP_USER_AGENT settings as the web process. When an enabled bundled-artefact fallback has a matching copy and a host is unreachable, that bundled copy stands in for the remote artefact.
The Compose worker block passes these settings through only when they are set; the application defaults and validation remain the source of truth.
LIBRARY_STORED_COPY_READ_TIMEOUT_MS is no longer read. If it remains set, the application ignores it. Use WORKER_JOB_TIMEOUT_SECONDS for the shared job expiry and working budget.
The verification job uses its remaining budget for a second provider call with the validity window skipped when the first call stops at that window, so proof and status can still be established.
Stopping
Three numbers govern a stop, each with its own job. On SIGTERM or SIGINT the worker stops taking jobs and gives a running one 30 seconds to finish (the drain); a job still running then is failed by the queue and retried later. The whole shutdown (drain, queue release, database disconnect, telemetry flush with 5 seconds to reach the collector) must finish inside a 45-second process deadline, after which the worker exits non-zero. The container's grace period, 60 seconds, sits above that deadline so the runtime never kills the process before it has exited on its own terms: stop_grace_period: 60s in Compose (already set on ri-worker), terminationGracePeriodSeconds: 60 in Kubernetes, docker stop -t 60 by hand. So a job that needs 40 seconds is interrupted by the drain even though it fits the container's grace period. The worker exits non-zero if the queue release or the database disconnect fails or the deadline passes; a second signal exits at once; a telemetry flush that fails, which is the normal case when no collector is running, is logged and does not change the exit code. After an abrupt kill the interrupted job is not handed over at once: it stays claimed until its WORKER_JOB_TIMEOUT_SECONDS attempt expiry, five minutes by default, and the queue's maintenance sweep notices, then waits out the retry backoff (30 seconds as the base, growing and randomised on later attempts), so the next attempt comes minutes later on the same job id, provided the job has attempts left (four retries). A job that exhausts them settles its generation as a retryable failure on that final attempt, and a job that never reports at all is settled by the reconciliation sweep above. Either way the caller re-verifies the record to run the check again.
Health
The worker serves no HTTP, so it proves itself instead. Every 10 seconds it runs a probe through the job queue's own connection pool and checks that a consumer has fetched within the last 30 seconds or is inside a job it started within WORKER_JOB_TIMEOUT_SECONDS + 60 seconds (360 seconds by default; the queue keeps a consumer's job count after a settlement that threw, so an older count is not work); when that holds it publishes /tmp/worker-heartbeat (atomically, so a failed write never leaves an empty but fresh file). The container health check (docker-worker-healthcheck.sh, wired in the compose files) reads that file's age and reports unhealthy when it is missing, older than 30 seconds, or stamped in the future. That catches a wedged event loop, a queue pool that is down or exhausted, and a consumer that has stopped fetching, while an idle worker stays healthy. The worker does not exit on a failed probe: both of its database pools recover a lost connection on their own, and exiting would restart every worker on a shared outage without repairing it. Unhealthy is the signal. An orchestrator's liveness probe restarts on it; under Compose it is what docker compose ps shows, and restart: always still replaces a worker that exits. From the last successful beat, Compose reports unhealthy after roughly 50 to 60 seconds (the age limit plus three failed checks); count that, plus the 45-second shutdown deadline, into any orchestrator's timings. During a stop the last proof stays in place rather than being removed, so the health check keeps passing for as long as that proof is inside its age limit and then needs three consecutive failures before the container is unhealthy. With the numbers above that is usually enough to cover the 30-second drain, though not by a wide margin, and it depends on how old the proof was when the signal arrived and where the check's schedule falls; an orchestrator that restarts on liveness should carry the 60-second termination grace so a drain in progress is left to finish. Nothing yet raises an alert on a growing backlog; the queue tables hold that and the health check does not read them.
Running it elsewhere
The container must replace the image's entrypoint with /app/docker-worker-entrypoint.sh (it sets the flags that skip schema convergence and passes --process-role=worker as the first argument before the shared entrypoint reads them), replace the image's HTTP health check with /app/docker-worker-healthcheck.sh, and carry a restart policy. Bare Docker: docker run --entrypoint /app/docker-worker-entrypoint.sh --health-cmd /app/docker-worker-healthcheck.sh --health-interval 10s --health-timeout 5s --health-retries 3 --health-start-period 40s --restart always --stop-timeout 60 <image>. Kubernetes: command: ["/app/docker-worker-entrypoint.sh"], a livenessProbe that execs /app/docker-worker-healthcheck.sh (period 10 s, failure threshold 3, initial delay 40 s), terminationGracePeriodSeconds: 60, no Service. The heartbeat file lives under /tmp, which the image's nextjs user can write; a read-only root filesystem needs a writable mount there, and WORKER_HEARTBEAT_PATH moves the file for both the worker and the check. WORKER_HEARTBEAT_MAX_AGE_SECONDS raises the check's 30-second age limit for a deployment whose probe schedule needs more room. The worker's own 10-second beat is not configurable, so a limit set below three beats will report a healthy worker unhealthy.