Skip to content

2 October 2026

9 changes:

  • Two workers refreshing one OAuth connection no longer kill it
  • A faked request says its method, URL, headers and body
  • QueueManager::addMany() queues a pass as one batch
  • A pass can tell when its batch is done
  • A failed queue task waits before it is retried
  • A queue worker pool that grows and shrinks with its backlog
  • An operator's stop is kept, and pools can be set from the database
  • The services screen shows pools, batches and the scaling decision, and its Stop stays stopped
  • A model's column cache tells two databases with the same table apart

Two workers refreshing one OAuth connection no longer kill it

ConnectionStore::refresh() took no lock, and did not read the row again before using it. Two workers reading channels on the same sign-in both refreshed the token. A provider that rotates refresh tokens (TikTok) accepted the first and answered the second invalid_grant. refresh() treats that as terminal, so the connection was marked dead and the user had to authorise again for something they had not done.

Fixed

  • refresh() takes a SharedLock on the connection (oauth:refresh:<id>, across servers) and reads the row again once it holds it. Tokens another process has just refreshed are returned without a request. A connection that died or was forgotten in the meantime raises a terminal error without contacting the provider.
  • A caller that waits 30 seconds without the lock gets a non-terminal OAuthClientException with error = 'temporarily_unavailable', and the connection is left alone.

A behaviour change; see the ConnectionStore::refresh() row in the Upgrade Guide.

Documentation

A faked request says its method, URL, headers and body

A callable fake receives the Client, but its method, URL, headers and body were private, so a test that a call went as PATCH and was signed Bot <token> read them through reflection. The URL pattern a fake is registered under cannot tell a POST from a PATCH.

Added

  • method(), url(), requestHeaders() and requestBody() on Pramnos\Http\Client. requestHeaders() is named so as not to clash with the headers() setter.

Fixed

  • A Content-Type or User-Agent set with header() is sent once. The transport now builds its header list from requestHeaders(), where a hand-set header replaces the automatic one of the same name. Before, both went out.

See HTTP Client Guide — Testing without the network.

QueueManager::addMany() queues a pass as one batch

A scheduled pass ("for each due channel, collect it") could only reach the worker pool through an enqueue-per-item loop each application wrote itself, around an addTask(unique: true) that checked for a duplicate and then inserted, so two producers could both insert.

Added

  • QueueManager::addMany($taskType, $payloads, priority:, maxAttempts:, unique:, batch:) queues one task per payload in one transaction and returns a QueueBatch: id, name, taskIds, skipped and queued().
  • unique is atomic. It runs under a SharedLock on the task type and counts a duplicate only while it is pending or processing.
  • queueitems.batchid and batchname, indexed with status, by a new queue migration.
  • SharedLock::acquireWithin($seconds), which waits for a lock instead of giving up at once. ConnectionStore::refresh() now uses it too.

See Queue Guide — Spreading a pass over the worker pool.

A pass can tell when its batch is done

withoutOverlapping() guards the run that queues a pass, which ends in a second, while the units run for as long as they take. Nothing could say whether the last pass's units were done, so a slow pass could have a second copy queued on top of it.

Added

  • QueueManager::batchStatus($batchId) returns a QueueBatchStatus with counts for pending, processing, completed, warning and failed, plus total(), finished() and toArray().
  • QueueManager::batchInProgress($name) is true while any task of a batch by that name is pending or processing.

See Queue Guide — Knowing when a pass is done.

A failed queue task waits before it is retried

A task that failed with attempts left went straight back to pending, so the next claim, often by the same worker a moment later, ran it again under the conditions that had just failed it. A unit queued while a deploy landed was claimed by a worker still on the old code, and its retry went to the same worker.

Changed

  • markTaskAsFailed() sets queueitems.availableat to 30 seconds per attempt ahead, at most ten minutes. Every claim path (both SKIP LOCKED shapes and the fallback) skips a task whose time has not come. Override retryDelaySeconds() to change the curve.
  • retryTask() clears the delay, so an operator's retry is available at once.
  • A new queue migration adds availableat. Until it has run, retries are immediate and nothing fails.

A behaviour change; see the Queue retries row in the Upgrade Guide.

A queue worker pool that grows and shrinks with its backlog

BurstPolicy decided how many workers a queue should have, and nothing called it. Every application wrote a fixed number of queue:process workers into buildDesiredProcesses(), so a pass that queued two hundred units was worked through by however many had been written down.

Added

  • DaemonOrchestrator::queuePool($name, $config) returns one queue:process worker per slot. Each cycle the count follows BurstPolicy::target() of the pool's pending tasks and the load per core, one step at a time, with a cooldown.
  • poolDecisions() returns each pool's last explain() line. A change of size is logged to the daemons log with it.
  • The seams are poolBacklog(), loadPerCore() and runningInPool(). An unreadable backlog holds the pool, and a restart keeps the size it finds.

See Workers and Daemons Guide — A pool that follows the backlog.

An operator's stop is kept, and pools can be set from the database

Stopping a worker meant writing its stop file. The worker exited, and the supervisor, which still had it on its list, started it again on the next cycle. A decision that has to last cannot live in a sentinel file.

Added

  • pramnos.worker_pools and pramnos.stopped_services, created by a core migration.
  • Pramnos\Console\DaemonControls, which reads and writes both tables with validation: pools(), savePool(), setPoolEnabled(), deletePool(), stoppedServices(), stopService() and startService().
  • The supervisor reads them every cycle.
  • It applies a pool row's limits over the code's queuePool(); a null limit means "as declared".
  • It runs no workers for a stopped pool.
  • It supervises screen-only pools.
  • It leaves stopped services off its list.
  • It writes each pool's reading to var/daemon_orchestrator_pools.json.
  • When the tables are missing or the database cannot answer, it runs what the code declares.

See Workers and Daemons Guide — What an operator decides.

The services screen shows pools, batches and the scaling decision, and its Stop stays stopped

/admin/Services listed processes and nothing else. The numbers a scaling decision is made from (backlog, load, the decision itself) were not on it. Two of its three buttons did not do what they said: Stop made a worker exit, and the supervisor started it again a cycle later. Restart removed the stop file, which did nothing to a running worker.

Added

  • Worker pools:
  • each pool's source (code or screen), task types, workers, backlog, load per core and BurstPolicy decision;
  • a waiting for supervisor badge after a change;
  • Stop / Start, Edit limits (blank keeps the declared value, which is shown as the placeholder), Reset or Remove, and an Add form.
  • Batches of the last 24 hours, with pending, processing, done and failed; QueueManager::recentBatches() reads them.
  • Restart all, for a deploy the supervisor cannot see.
  • The page refreshes its numbers every ten seconds from Services/status, without a reload. status gained pools, batches and stopped_by_operator.
  • Every change is recorded in the auth log with who made it.
  • All three scaffold themes (bootstrap, tailwind, plain-css).

Fixed

  • Stop keeps the service stopped until Start, through pramnos.stopped_services.
  • Restart restarts a running service by writing its stop file.

A behaviour change; see the /admin/Services Stop and Restart row in the Upgrade Guide.

A model's column cache tells two databases with the same table apart

Model::$columnCache was meant to be keyed by Database::columnCacheKey(), as the Migration Guide says, and was for joined tables. A model's own table was still keyed by its bare name. A process with connections to two databases that have the same table read the columns of whichever was introspected first for both. Because a save builds its column list from that cache, a column only the second table had was left out of every save, without an error.

The collision needs two databases of the same driver, such as two MySQL databases on one server, or a tenant per database. A PostgreSQL table name carries its schema, so it never shared a key with a MySQL table.

Fixed

  • A model's own table is cached under columnCacheKey(), in the save path, the field list, the field types and the search's numeric-column check.
  • _getTableFields() reads under the key it writes. It read under the bare name and wrote under the connection's key, so it never hit the cache. Every call introspected the table again and emptied the entry before filling it.

Code that seeds Model::$columnCache in its own tests uses columnCacheKey(), as the One column-cache key per table row of the Upgrade Guide already says.