gplanchat/durable-magento runs Durable workflows inside a Magento application:
Gplanchat_DurableModule in bin/magento module:status.
Read-only mirror. This repository is a subtree-split of gplanchat/durable-dev, published so Composer can require this package on its own. Issues and pull requests are disabled here — open them on the monorepo.
The tests are in the monorepo, not here. This split carries source only. What covers it is
tests/unit/DurableModule/in the monorepo, run by itsunitsuite. The monorepo root does not install this package:magento/frameworkcomes from a Magento repository the root does not reach, so its dependencies resolve in the Mage-OS bench (magento/) instead.Documentation: durable.rocks.
It declares workflow and activity classes to the runtime, assembles the engine for a Magento
process, ships the workers as bin/magento commands, and adds a read-only admin screen under
System > Durable processes > Process history.
v0.1.0-alpha8 is the first tagged release of this package, and it is the whole suite's version —
the monorepo tags once and every satellite receives the same tag.
⚠ The suite is on its beta line, and Composer's default stability will refuse it. A project on
minimum-stability: stable needs "minimum-stability": "beta" with "prefer-stable": true in its
own composer.json, which the two composer config lines below write. A @beta flag on the
require line is not enough: it reaches only the package it is written on, not gplanchat/durable
underneath.
- PHP 8.2+
- Magento 2.4.x or Mage-OS
gplanchat/durable— pulled in as a dependencygplanchat/durable-bridge-temporalfor anything that must outlive a process
What CI proves, and what it only resolves. Two different claims:
| Claim | Lines |
|---|---|
Booted: setup:installed, a workflow run in process, and the admin screen serving runs read from a Temporal cluster |
Mage-OS 2.2.0 on PHP 8.2 and 8.4 |
| Resolved: Composer accepts the module on that line, nothing is installed | Mage-OS 1.3.0 (PHP 8.2), 2.2.0 (8.2), 2.3.0 (8.4), 3.4.0 (8.3, 8.5) |
Magento Open Source and Adobe Commerce are not in CI: their packages come from
repo.magento.com, which needs credentials. Mage-OS 2.2.0 replaces magento/framework 103.0.8-p4,
Magento 2.4.8-p4's framework: the closest booted line to a Magento 2.4.8 shop, not proof of one.
Magento reaches in-memory, its own database backend and Temporal. The module declares
conflict on both SQL bridges, because Magento\Framework\App\ResourceConnection is neither
Doctrine DBAL nor Illuminate's connection: the database backend is an adapter of this module, built
on that connection layer (DUR056). An install with a bridge is refused by the dependency solver
rather than by a runtime check nobody reads.
app/etc/env.php selects the backend. Declare a journal connection under db/connection and name
it in resource/durable to select the database (bin/magento durable:setup creates the tables, and
bin/magento durable:worker drains their queues):
'db' => ['connection' => ['durable' => [/* host, dbname, username, password... */]]],
'resource' => ['durable' => ['connection' => 'durable']],A DSN selects Temporal instead:
'durable' => [
'temporal' => ['dsn' => 'temporal://temporal:7233?namespace=default&tls=0'],
],Declaring both throws BackendSelectionException the first time the module resolves its journal.
Without either the journal lives in the process that writes it, and dies with it, which suits a
console command, ruinous for anything served by PHP-FPM.
With transport=guzzle in the DSN, gRPC travels through Guzzle 7.14 or newer, which Magento
already ships. To hand it the application's client — its proxy, its TLS options — set the
guzzle argument of RuntimeFactory in your module's di.xml:
<type name="Gplanchat\DurableModule\Runtime\RuntimeFactory">
<arguments>
<argument name="guzzle" xsi:type="object">Vendor\Module\Http\TemporalGuzzleClient</argument>
<!-- or, for transport=http: a Gplanchat\Bridge\Temporal\Http\Psr18Http built in di.xml -->
<argument name="jsonGateway" xsi:type="object">Vendor\Module\Http\TemporalJsonGateway</argument>
</arguments>
</type>To encrypt payloads before they reach the cluster (DUR055), name your
Gplanchat\Bridge\Temporal\Codec\PayloadCodecInterface in the same place. Magento does not
autowire an optional argument, so the line is required. Your codec reads its key from env.php;
Durable reads none:
<argument name="codec" xsi:type="object">Vendor\Module\Temporal\PayloadCodec</argument>Make the module that sets the codec load after Durable's, in its etc/module.xml. Otherwise a
later di.xml can replace your arguments, and payloads would leave in clear:
<sequence>
<module name="Gplanchat_DurableModule"/>
</sequence>composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-magento gplanchat/durable-bridge-temporal
bin/magento module:enable Gplanchat_DurableModule
bin/magento setup:upgradeMagento's container has no equivalent of Symfony's tag autoconfiguration, so declaration is
explicit — two arrays in your own module's di.xml:
<type name="Gplanchat\DurableModule\Runtime\RuntimeFactory">
<arguments>
<argument name="workflowClasses" xsi:type="array">
<item name="place_order" xsi:type="string">Acme\Shop\Workflow\PlaceOrder</item>
</argument>
<argument name="activityHandlers" xsi:type="array">
<item name="order" xsi:type="object">Acme\Shop\Activity\OrderActivities</item>
</argument>
</arguments>
</type>The contract is not declared: the factory reads each handler's interfaces and keeps those carrying
#[AsActivityMethod]. One declaration fewer to get wrong, and the activity names stay the
attributes'.
A workflow class written for the Symfony bundle runs here unmodified — everything below the ports is the same component.
A module serves a Nexus contract the way it declares activities: the handler is listed in di.xml,
and it names the contract it serves with the attribute the Symfony bundle reads.
<argument name="nexusHandlers" xsi:type="array">
<item name="billing" xsi:type="object">Acme\Shop\Nexus\BillingHandler</item>
</argument>#[AsNexusServiceHandler(contract: BillingContract::class)]
final class BillingHandler implements BillingServed
{
public function verify(string $order, int $amount, string $currency): array { /* … */ }
}An operation the handler has no method for is fulfilled by a workflow that carries
#[FulfilsNexusOperation(BillingContract::class, 'charge')] and is listed in workflowClasses.
Magento discovers nothing on its own, so the class is still listed; the attribute spares you writing
the contract by hand. Serving needs a cluster: durable:worker --role=nexus refuses to start
without durable/temporal/dsn. The declarations are checked when that worker starts, not before:
a handler without the attribute, or one that does not match its contract, stops it there, naming
the class. The endpoint points at the DSN's nexus_task_queue, which defaults to the workflow task
queue.
bin/magento durable:worker --role=journal --time-limit=3600
bin/magento durable:worker --role=activity --time-limit=3600
bin/magento durable:worker --role=nexus --time-limit=3600 # only if the module serves NexusOne process, one role: these are distinct Temporal task queues, and their concurrency is tuned apart. An operator supervises them with whatever already supervises every other long-running Magento process.
With resource/durable in env.php, the command drains the database queues: no --role serves
resumes, timers and activities, --role=journal resumes and timers, --role=activity activities,
and --role=nexus is refused. A message is acknowledged once handled; a held execution, an early
resume, a lock wait timeout or a deadlock leaves it in the queue to be delivered again.
An observer that hands the execution to Temporal and returns:
use Gplanchat\Durable\ExecutionId;
$this->runtimeFactory->workflowClient()->startAsync(
PlaceOrder::class,
['orderId' => $order->getIncrementId()],
ExecutionId::fromString('order-' . $order->getIncrementId()),
);Starting it inline would kill it with the request, which is the very failure this integration exists to remove. And an observer that throws refuses a sale that already happened: a workflow that fails to start is an operational incident, not a reason to reject the customer — rejecting them would not give the money back either.
A standard Magento grid — paging, bookmarks, column controls, export, and a multi-select status filter whose options come from the status enum itself. Above it, the state of the backend and the outcome counters; selecting a run opens its detail.
Every Durable dashboard shows the same four panels, whichever host renders them. They are not a matter of chrome: a panel one surface has and another lacks is a question one application can answer about a run and another cannot, about the same run, recorded by the same backend.
- The state of the backend. Three states, and an empty list means something different under each: no readable backend is configured; a backend is configured and cannot be reached, named and dated so an operator knows what to restart; or a backend answers and its journal does not outlive the request that renders the page — where an empty list is the correct answer, not a failure.
- The runs, filterable by outcome and paged.
- Counters per outcome, over the set the list is paging through — and labelled as covering that set, never as a total over the application's history.
- A selected run's recorded history: one line per action, placed in time, with an interval spent waiting to be picked up told apart from one spent working; each event unfolds onto what the backend recorded with it.
Grouping into actions, measuring, telling a queue apart from work and wording a duration are decided
once, in gplanchat/durable beside the observation model. What each host decides is how to draw
it — scaling seconds to a column width is the only thing a surface owns, because a surface that
renders no markup has no column.
The chrome is Magento's, the panels are Durable's. The same run opened on the Sylius dashboard is grouped into the same actions, labelled with the same strings, and its waits are worded the same way — an operator moving between two applications of the same house has nothing to translate.
⚠ The grid reads a 200-run window and pages inside it. The grid pages by offset, the cluster by continuation cursor, and the two do not translate without state. Beyond the window the grid tells the truth about what it shows, but does not show everything. The filters filter the window, not the cluster, whose visibility query is a surface of its own.
Two processes, and the failure of forgetting one is not symmetric.
| Missing | What you see |
|---|---|
--role=journal |
Nothing advances at all. Executions start, their history fills, and no one answers their workflow tasks. |
--role=activity |
Worse, because it looks like it works. An execution advances up to its first activity and stops there — the order is charged, the stock is not, and you learn it from the customer. |
That second line is the failure this integration exists to remove, put back by hand. Supervise both, or supervise neither.
Alert on bin/magento durable:health. It exits non-zero when the cluster does not answer, or
when a role's queue has gone two minutes without a poll, and names the --role to start. Without a
DSN it exits zero: there is no worker to miss. With a DSN, the admin screen shows the same state
above the grid. Two minutes, because a live worker polls about once a minute and the server keeps
listing a stopped one for several.
The bounds are for the supervisor, not for you. --time-limit and --max-tasks make the
process end so that whatever restarts it can restart it. A worker without them is an immortal
process holding a week-old gRPC connection; a supervisor that never gets to do its job is a
supervisor you are not really running.
Concurrency is tuned per role. They are two distinct Temporal task queues on purpose: a slow activity must not delay the resume of a journal. Scale the activity role with your slowest activity in mind, the journal role with your execution count.
Retries are the server's business, and they need a worker. On Temporal an activity's retry policy is enforced by the cluster: the attempts are scheduled whether or not anything is listening, and they are consumed by the absence of an activity worker as surely as by a failing activity. A run whose activity "failed after 3 attempts" in seconds is the sign of a worker that was not there, not of code that is wrong three times over.
Magento's own queue settings are not part of this. retry_inprogress_after, the
messagequeue_* cron jobs, queue_lock — none of them carries anything of Durable's, because
nothing of Durable's rides MessageQueue. Tune them for your own consumers; they cannot break a
workflow here.
What "no DSN" looks like from the outside. The admin screen states that the backend is
in-memory and why it has nothing to show, rather than displaying an empty grid that looks like an
outage. If an operator reports "the screen is empty", check app/etc/env.php before checking the
cluster.
Nothing rides Magento's MessageQueue. On Temporal an activity is a Temporal command and a
resume is a workflow task; a topic here would be a second queue for an operator to supervise, for
nothing.
The database backend does not use the SQL bridges. It keeps its journal through
ResourceConnection on a dedicated db/connection/durable, selected by resource/durable; see
Three backends.
MIT. See LICENSE.