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 aSharedLockon 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
OAuthClientExceptionwitherror = '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()andrequestBody()onPramnos\Http\Client.requestHeaders()is named so as not to clash with theheaders()setter.
Fixed¶
- A
Content-TypeorUser-Agentset withheader()is sent once. The transport now builds its header list fromrequestHeaders(), 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 aQueueBatch:id,name,taskIds,skippedandqueued().uniqueis atomic. It runs under aSharedLockon the task type and counts a duplicate only while it ispendingorprocessing.queueitems.batchidandbatchname, indexed withstatus, by a newqueuemigration.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 aQueueBatchStatuswith counts forpending,processing,completed,warningandfailed, plustotal(),finished()andtoArray().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()setsqueueitems.availableatto 30 seconds per attempt ahead, at most ten minutes. Every claim path (bothSKIP LOCKEDshapes and the fallback) skips a task whose time has not come. OverrideretryDelaySeconds()to change the curve.retryTask()clears the delay, so an operator's retry is available at once.- A new
queuemigration addsavailableat. 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 onequeue:processworker per slot. Each cycle the count followsBurstPolicy::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 lastexplain()line. A change of size is logged to thedaemonslog with it.- The seams are
poolBacklog(),loadPerCore()andrunningInPool(). 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_poolsandpramnos.stopped_services, created by acoremigration.Pramnos\Console\DaemonControls, which reads and writes both tables with validation:pools(),savePool(),setPoolEnabled(),deletePool(),stoppedServices(),stopService()andstartService().- 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
BurstPolicydecision; - 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.statusgainedpools,batchesandstopped_by_operator. - Every change is recorded in the
authlog 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.