feat(deploy): one worker container runs every background process

The stack was nine long-running containers: the web server, the
database, the Mercure hub, and six of plMail's own image, one per
background process. It is three now: php, worker and database, with
secrets-init still running once and exiting.

The worker runs `app:work`, a small supervisor keeping seven processes
alive the way Docker's restart policy kept seven containers alive: the
IMAP supervisor, one consumer per queue, the scheduler and the hub. A
clean exit (the hourly recycle, the restart signal) comes straight back;
a crash backs off from one second to thirty. Output passes through a
whole line at a time, so two processes' JSON records never interleave,
and SIGTERM reaches every child at once with 25s to finish. Each queue
keeps a process of its own, which is the part the split was for: a
consumer inside a long handler cannot take a send. And each process
keeps its container's old name as APP_CONTAINER_NAME, so heartbeats,
/healthz and the log filters are unchanged.

The hub is the official Mercure 1.x binary, copied into the image and
run as one of those processes. Not FrankenPHP's built-in hub, which is
0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31.
The worker answers to `mercure` on the network, so the Caddyfile proxy
and MERCURE_URL reach it where they reached the hub container, and older
compose files keep working with this image unchanged. It is configured
from the application's own settings, including MERCURE_COOKIE_NAME, so a
plain-HTTP install names the cookie once for both halves. A 5s shutdown
grace stops it waiting on open subscriptions, which made every stop take
the whole 25s.

The dev override loses its per-worker blocks, one of which (worker-bulk)
had been missing, so that queue ran the published image's code in
development. The demo overlay runs app:work --without=imap-supervisor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-09-24 16:20:31 +02:00
1 parent 57db16b6f4
commit a8364e34d2
31 files changed
+1018 -620

No files matched your search

+6 -4
View File
@@ -59,8 +59,9 @@ That first boot takes a few minutes. The dev stack bind-mounts the source tree o
`composer install` once to fill it, under a lock so the five services sharing the mount cannot race.
Later boots skip it.
Migrations run automatically via the entrypoint. The `imap-supervisor`, `messenger-worker` and
`scheduler` services start with the stack and restart on failure.
Migrations run automatically via the entrypoint. The worker container starts with the stack and
runs everything that is not the web server (`app:work`): the IMAP supervisor, one consumer per
queue, the scheduler and the Mercure hub, each its own process, each restarted when it stops.
`.github/workflows/docker.yml` builds the published image for both `linux/amd64` and `linux/arm64`,
each on a native runner, then merges the two into one manifest. A change to the `Dockerfile` must
@@ -811,6 +812,7 @@ php bin/mirror-wiki.php --check
| `app:reset --full [--rotate-secrets]` | Back to first-run state: every table, every user, the stored files. `--rotate-secrets` also discards the generated secrets and requires restarting the whole stack — see "Secrets and the encryption key" |
| `app:secrets:init` | Generate the per-install secrets that need PHP, and verify the encryption key against stored credentials |
| `app:upgrade:run [--status]` | Run the one-time repairs this installation has not run yet, and record that it did. Scheduled every ten minutes, so normally nobody types it — what it guards is the gap between an update landing and a repair happening, and asking costs one indexed lookup per task. Run it by hand after restoring an old backup, or with `--status` to read the ledger when a task has used up its three attempts and you want to see what it says |
| `app:work [--only=…] [--without=…]` | Run every background process in one container, one process each: the Mercure hub, the IMAP supervisor, the four queue consumers and the scheduler. It is the worker container's command. `--only` and `--without` take process names, comma-separated, for an installation that gives a busy queue a container of its own, or the demo, which runs without the IMAP supervisor |
| `app:updates:check` | Ask the update channel (Admin → Updates) whether a newer build is published, and tell the administrators the first time it is. Scheduled hourly at :37, so normally nobody types it; run it to check now. Exits 0 when the registry cannot be reached, because an installation behind a firewall is not a fault: the page records why and keeps the last answer |
| `app:db:migrate` | Run pending migrations under a lock, so several containers booting together cannot collide. This is what the entrypoint calls; run it by hand only when a boot was interrupted |
| `app:ai:embed-mailbox --email=… \| --all` | Queue a pass that embeds an existing mailbox for semantic search. Needed once after switching the feature on, and again after changing the search model. Runs on the maintenance worker a chunk at a time, takes hours on a large mailbox, and is safe to interrupt and safe to repeat — it skips whatever is already embedded under the current model, so changing the model is how a re-embed is asked for. This is the whole-mailbox job: it claims a state row, resumes where it stopped and can be paused from Admin → AI |
@@ -823,8 +825,8 @@ php bin/mirror-wiki.php --check
These run on a schedule already — see `App\Infrastructure\Scheduler\MaintenanceSchedule`
for the cadences (polling sync every 15 min, push renewal and monitoring pruning
nightly, the blob sweep weekly). They are dispatched by the `scheduler` service in
compose, which consumes the `scheduler_default` transport; without that container
nightly, the blob sweep weekly). They are dispatched by the `scheduler` process in
the worker container, which consumes the `scheduler_default` transport; without it
running, none of them fire. `php bin/console debug:scheduler` shows the next run of
each.
+21
View File
@@ -3,6 +3,23 @@
# Versions
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# The Mercure hub, taken as a binary rather than run as an image. plMail runs it
# as one of the background processes in the worker container (app:work), which
# answers to the name `mercure` on the stack's network: the web container
# proxies /.well-known/mercure there and the application publishes there, as
# they always did to a container of that name.
#
# Not the hub FrankenPHP has built in. That one is a release behind (0.24 in
# FrankenPHP 1.12), and plMail speaks the 1.0 protocol, whose RFC 9068 tokens a
# 0.x hub refuses. That exact mismatch, the other way round, stopped live
# updates in v0.2.31.
#
# `v1`, not an exact version: the hub keeps itself current within the protocol
# plMail speaks, the way the floating `dunglas/mercure` tag was meant to, but it
# now moves with plMail's own releases rather than whenever a host last pulled.
# A static Go binary, so an Alpine build runs on this Debian image as it is.
FROM docker.io/dunglas/mercure:v1 AS mercure_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target
@@ -78,6 +95,10 @@ COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-en
# service runs it on its own, before Postgres and Mercure start.
COPY --link --chmod=755 frankenphp/generate-secrets.sh /usr/local/bin/generate-secrets
COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile
# The hub and its own Caddyfile, verbatim: the image's default config (the one
# that requires a subscriber JWT), not the dev one. See mercure_upstream above.
COPY --link --from=mercure_upstream /usr/bin/caddy /usr/local/bin/mercure
COPY --link --from=mercure_upstream /etc/caddy/Caddyfile /etc/mercure/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
+22 -40
View File
@@ -55,45 +55,27 @@ services:
environment:
<<: *demo-env
# Delivers the scripted mail the Receive button asks for, and the auto-reply
# to anything a visitor sends. Without this worker the button appears to do
# nothing: the job is queued and never handled.
worker-ingest:
# Every background process but one, and each is needed:
#
# - worker-ingest delivers the scripted mail the Receive button asks for,
# and the auto-reply to anything a visitor sends. Without it the button
# appears to do nothing: the job is queued and never handled.
# - worker-export: nothing outbound leaves a demo (DemoMailSender swallows
# it), but a send still travels through this queue, so without it Send
# sits pending.
# - worker-bulk: a demo's mailboxes are small, so a bulk action is quick,
# but without the consumer its indicator would sit at nothing for ever.
# - the scheduler runs app:demo:reap every ten minutes. Without it expired
# visitors are never deleted, and the database grows by a user, an
# account, ten threads and ten contacts for everybody who followed the
# link, with nothing anywhere saying so.
# - the Mercure hub is what refreshes a visitor's inbox when mail arrives.
#
# The IMAP supervisor is the one left out. It holds an IDLE connection per
# mailbox, and a demo has no mailbox to hold one to: its accounts point at
# documentation domains that resolve nowhere, so it would spend the whole
# time failing to connect and backing off.
worker:
command: php bin/console app:work --without=imap-supervisor
environment:
<<: *demo-env
# Nothing outbound actually leaves on a demo — DemoMailSender swallows it —
# but the queue is still the one a send travels through, so the consumer has
# to be here for Send to complete rather than sit pending.
worker-export:
environment:
<<: *demo-env
worker-maintenance:
environment:
<<: *demo-env
# The demo's mailboxes are small, so a bulk action there is quick — but it
# still travels through this queue, and without the consumer the indicator
# would sit at nothing for ever.
worker-bulk:
environment:
<<: *demo-env
# Runs app:demo:reap every ten minutes. Drop this service and expired
# visitors are never deleted — the database then grows by one user, one
# account, ten threads and ten contacts for every person who followed the
# link, with nothing anywhere saying so.
scheduler:
environment:
<<: *demo-env
# Holds an IMAP IDLE connection per mailbox, and a demo has no mailbox to
# hold one to: its accounts point at documentation domains that resolve
# nowhere, so this would spend the whole time failing to connect and backing
# off. Parked behind a profile nothing activates, which is how the compose
# spec says "not in this configuration" — there is no way to delete a service
# an earlier file defined.
imap-supervisor:
profiles:
- imap-not-used-on-a-demo
+14 -56
View File
@@ -1,5 +1,5 @@
# Every app service builds the same dev stage, so they share one image — one
# build instead of six.
# build, whichever of them starts it.
#
# The `image` line is the load-bearing part. compose.yaml names these services
# `ghcr.io/karatektus/pl_mail:latest`, and compose tags what it builds with the
@@ -46,14 +46,20 @@ services:
- host.docker.internal:host-gateway
tty: true
imap-supervisor:
# Everything that is not the web server (app:work), from the dev image with
# the source mounted. A service the base file defines and this one does not
# still starts, from the published image and without the source: not a
# missing container but one running yesterday's code. There used to be one
# block per worker here, and worker-bulk's had been forgotten; one block has
# nothing left to forget.
worker:
<<: *dev-build
volumes:
- ./:/app
# Durable mail data must be visible to BOTH the web container (which
# serves downloads) and the workers (which write during sync). php
# overlays /app/var with the private app_var volume to keep cache/logs
# off the bind mount, which otherwise hides these two directories from
# off the bind mount, which otherwise hides these directories from
# everything else.
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
@@ -61,52 +67,6 @@ services:
environment:
APP_ENV: ${APP_ENV:-dev}
# One block per worker in compose.yaml. A service the base file defines but
# this one does not still starts — from the published image, without the
# source bind-mounted — so a missing block here is not a missing container
# but a container running yesterday's code.
worker-export:
<<: *dev-build
volumes:
- ./:/app
# Durable mail data must be visible to BOTH the web container (which
# serves downloads) and the workers (which write during sync). php
# overlays /app/var with the private app_var volume to keep cache/logs
# off the bind mount, which otherwise hides these two directories from
# everything else.
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
worker-ingest:
<<: *dev-build
volumes:
- ./:/app
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
worker-maintenance:
<<: *dev-build
volumes:
- ./:/app
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
scheduler:
<<: *dev-build
volumes:
- ./:/app
environment:
APP_ENV: ${APP_ENV:-dev}
database:
ports:
- "5432:5432"
@@ -132,18 +92,16 @@ services:
# The image healthcheck probes the FrankenPHP admin endpoint on :2019, but
# this service replaces the server with the Tailwind watcher, so nothing
# ever listens there and the container is reported unhealthy while working
# fine. Same reason imap-supervisor, messenger-worker and scheduler disable
# it in compose.yaml.
# fine. Same reason the worker disables it in compose.yaml.
healthcheck:
disable: true
depends_on:
- php
###> symfony/mercure-bundle ###
mercure:
ports:
- "80"
###< symfony/mercure-bundle ###
# No `mercure` block: the hub is a process in the worker now, reachable
# through php at /.well-known/mercure like everything else. The Mercure
# bundle's recipe put one here to publish the hub's port; brought back, it
# would define a service with no image and compose would refuse the file.
volumes:
app_var:
app_attachments:
+42 -190
View File
@@ -39,6 +39,10 @@ x-app-env: &app-env
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0}
MERCURE_URL: ${MERCURE_URL:-http://mercure/.well-known/mercure}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://localhost/.well-known/mercure}
# Blank on HTTPS, which is every real install. Serving plain HTTP, set it to a
# name without the `__Secure-` prefix (a browser drops such a cookie over
# HTTP). The app sets the cookie and the hub reads it, and both read this.
MERCURE_COOKIE_NAME: ${MERCURE_COOKIE_NAME:-}
# The state that must outlive a container and be shared between them.
#
@@ -64,8 +68,8 @@ x-app-storage: &app-storage
services:
# Mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and
# MERCURE_JWT_SECRET into app_secrets, then exits. Runs before everything
# else because Postgres and Mercure read their secrets when their containers
# are created — they cannot wait for the app to hand them one.
# else because Postgres reads its password when its container is created: it
# cannot wait for the app to hand it one.
#
# Idempotent: on every later start it finds the file and does nothing.
secrets-init:
@@ -114,122 +118,47 @@ services:
database:
condition: service_healthy
imap-supervisor:
# ── Everything that is not the web server ─────────────────────────────────
# One container, seven processes (app:work): the IMAP supervisor, one
# consumer per queue, the scheduler, and the Mercure hub. They used to be
# seven containers of this same image and the hub's own; see
# App\Service\System\Work\BackgroundProcesses for what each is for.
#
# Still one PROCESS per queue, which is the part that matters: a consumer
# already inside a long handler cannot pick up anything else, so pressing
# Send must never queue behind a Gmail batch. Only the containers merged.
#
# Answers to `mercure` on the network, because it runs the hub: the web
# container proxies /.well-known/mercure there and MERCURE_URL publishes
# there, exactly as they did to a hub container of that name.
worker:
image: *app-image
restart: unless-stopped
command: php bin/console app:imap:supervise
command: php bin/console app:work
# The IMAP supervisor's children are this container's grandchildren; if
# the supervisor dies hard, something has to reap them. Docker's own init.
init: true
# Longer than app:work's own 25s: it signals every process at once and
# gives them that long to finish the message in hand.
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: imap-supervisor
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# ── Workers, one per queue ────────────────────────────────────────────────
# Three processes rather than three transports on one, because a worker
# already inside a long handler cannot pick up anything else however the
# queues are prioritised. That was the whole problem: pressing Send behind a
# Gmail batch waited for the batch.
#
# Anything leaving plMail, and the only queue somebody is watching. Kept on
# its own process so a send is never behind a sync.
worker-export:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-export
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Mail arriving and the work that immediately follows it.
worker-ingest:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-ingest
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Bulk and deliberate: backfills, rule runs over existing mail, admin sweeps.
#
# Also drains `async`, the pre-split queue, so envelopes written before the
# upgrade still have a consumer. Nothing routes there any more — drop it from
# this command and from messenger.yaml once a release has passed.
worker-maintenance:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-maintenance
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# A whole-view mark-read or archive. On its own process for the reason the
# send queue is: somebody pressed a button and is watching an indicator, and
# this must not wait behind a backfill on `maintenance`. Drop this service and
# the indicator fills to nothing — the job is queued and never handled.
worker-bulk:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-bulk
volumes: *app-storage
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Fires the recurring jobs in App\Infrastructure\Scheduler\MaintenanceSchedule.
# Separate from messenger-worker so a long prune cannot stall the async queue.
scheduler:
image: *app-image
restart: unless-stopped
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: scheduler
volumes: *app-storage
# Each process overrides this with its own name; this one is the
# supervisor's.
APP_CONTAINER_NAME: worker
networks:
default:
aliases: [mercure]
volumes:
# The hub's state, in the volumes the hub container used, so an upgrade
# keeps its event history.
- mercure_data:/data
- mercure_config:/config
# The x-app-storage list, spelled out for the reason php spells it out.
- app_secrets:/app/var/secrets
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
healthcheck:
disable: true
depends_on:
@@ -265,83 +194,6 @@ services:
- "-c"
- "pg_stat_statements.track=top"
mercure:
image: dunglas/mercure
# PULLED ON EVERY `up`, which is the only thing that makes "unpinned"
# actually mean "keeps itself current".
#
# Without this the tag floats and the image does not: compose reuses
# whatever is already on the box, so a hub can sit years behind the tag it
# claims to be while `docker compose up -d` reports success. v0.2.31 is what
# that costs — plMail moved to the Mercure 1.0 protocol, every install whose
# hub had not been pulled was left speaking 1.0 to a 0.x hub, and live
# updates stopped with nothing in the application's own logs to say why.
#
# The cost of the check is one registry round trip per `up` that answers
# "same digest" almost every time. The cost of not doing it is the paragraph
# above.
pull_policy: always
restart: unless-stopped
environment:
SERVER_NAME: ':80'
# Left empty on purpose: the entrypoint below fills them from the
# generated file. The hub image has no _FILE support, so sourcing it in a
# shell before exec'ing Caddy is the way in. Setting MERCURE_JWT_SECRET
# yourself still wins — the generator never overwrites it, so both sides
# read the same value.
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-}
# The two halves of RFC 9068: the issuer this hub accepts, and the
# audience it demands. Both are the literal from services.yaml —
# `mercure.identifier` — which is where the reasoning lives. In short: a
# hub is configured at container start, and an installation's own URL is
# not always known by then, so these cannot be derived from it.
#
# resource_identifier has to be PINNED whatever value is chosen. Left
# alone the hub works the audience out from each request, and plMail
# arrives at it by two different roads — the browser subscribes at the
# public URL, the application publishes to the internal one — so a hub
# deriving its own answer would refuse every publish.
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure
entrypoint:
- /bin/sh
- -c
- |
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /run/plmail/generated.env ]; then
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /run/plmail/generated.env)"
fi
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
exec "$$@"
- --
# The image's default Caddyfile, NOT dev.Caddyfile. The dev one turns on
# `anonymous`, which accepts any subscriber without a JWT — and since
# frankenphp/Caddyfile proxies /.well-known/mercure* straight through, that
# let anyone reaching this instance subscribe to mail/user/<any id> and
# watch that account's sync activity. It also enables `demo`, `subscriptions`
# and `cors_origins *`. The default config requires the subscriber JWT the
# app now mints, and needs no CORS because the browser reaches the hub
# same-origin through our own Caddy.
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
depends_on:
secrets-init:
condition: service_completed_successfully
# NO `test:` HERE ON PURPOSE — the image's own HEALTHCHECK is used.
#
# We used to spell one out, aimed at "/healthz", and the hub moved its
# readiness probe out from under it: "/healthz" answers 404 now, `curl
# -f` reads a 404 as failure, and this container reported itself unhealthy
# for ever while serving every subscription put to it. The image is
# deliberately on a floating tag so it keeps up to date by itself, which
# means anything we hard-code about its internals is a thing waiting to
# rot. Its own check — wget against 127.0.0.1:2019/mercure/health/ready,
# 60s grace, 5 retries — moves when the image moves, which is the whole
# point of not pinning it.
volumes:
- mercure_data:/data
- mercure_config:/config
- app_secrets:/run/plmail:ro
# Push for Android, without Google.
#
# Optional and off by default: `docker compose --profile push up -d`. Nobody
+2 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/configuration.md sha1:5ed07d4ee0630df517792929aa3da26f85c1953f -->
<!-- translated-from: install/configuration.md sha1:960f0f7ec8f30fc6aba96e312303042e5f0dcc32 -->
# Konfigurationsreferenz
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
@@ -62,6 +62,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
| `MERCURE_URL` | Die Hub-Adresse, an die die **Anwendung** innerhalb des Docker-Netzes veröffentlicht. | `http://mercure/.well-known/mercure` | Ja | Falsch, und nichts wird veröffentlicht: keine Live-Aktualisierungen, kein sichtbarer Fehler auf der Seite. |
| `MERCURE_PUBLIC_URL` | Die Hub-Adresse, die der **Browser** abonniert. | `https://localhost/.well-known/mercure`, sowohl in `.env` als auch in `compose.yaml` | Ja | Falsch, und der Browser öffnet einen Stream dorthin, wo er nicht hinkommt — Maillisten hören auf, sich selbst zu aktualisieren, während der Rest der Anwendung funktioniert. Wird nur dann aus `APP_PUBLIC_URL` abgeleitet, wenn sie nicht gesetzt oder leer ist, was die Standard-`compose.yaml` verhindert. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `MERCURE_JWT_SECRET` | Signiert die Publisher- und Subscriber-JWTs. | leer — 32 zufällige Bytes, hexadezimal, beim ersten Start erzeugt | Ja, aber erzeugt | Anwendung und Hub müssen denselben Wert halten. Weichen sie voneinander ab, weist der Hub jeden Subscriber ab — aus Sicht des Browsers lautlos. |
| `MERCURE_COOKIE_NAME` | Der Name des Cookies, mit dem der Browser abonnieren darf. Die Anwendung setzt es und der Hub liest es, beide aus dieser einen Variablen. | leer — `__Secure-mercure_access_token` | Nur ohne TLS | Eine Installation, die schlichtes HTTP ausliefert und nirgends TLS vorgeschaltet hat, braucht einen Namen ohne das Präfix `__Secure-`, etwa `mercure_access_token`: Ein Browser verwirft ein `__Secure-`-Cookie, das nicht über HTTPS kam, und Live-Aktualisierungen starten nie. |
| `GOOGLE_OAUTH_CLIENT_ID` | Google-OAuth-Client. | leer | Für Gmail | Ohne sie kann "Mit Google anmelden" nicht starten. Ein unter **Administration → Integrationen** gespeicherter Wert gewinnt gegen diesen. Siehe [Google](../providers/google.md). |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Client-Secret des Google-OAuth-Clients. | leer | Für Gmail | Wie oben. |
| `GMAIL_PUBSUB_TOPIC` | Vollständiger Topic-Name, in den Gmail Watch-Benachrichtigungen veröffentlicht. | `projects/your-project-id/topics/gmail-push` — ein Platzhalter, kein funktionierender Wert | Für sofortige Gmail-Zustellung | Der Platzhalter ist kein Topic, das dir gehört, also scheitert jeder `watch`-Aufruf. Die Projekt-ID ist kleingeschrieben und weicht oft vom Anzeigenamen ab. |
+49 -22
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/docker.md sha1:89c280cbbe9bb45d384b184c1dd64fb5d1dd053c -->
<!-- translated-from: install/docker.md sha1:73ae0d9a9b349c20c36dcfbc031c792cf1631649 -->
# Installation mit Docker Compose
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was `docker compose up` tatsächlich tut,
@@ -21,7 +21,9 @@ einige Details — siehe [Plattform-Hinweise](platforms.md).
Über die Dimensionierung lohnt es sich nachzudenken, eine feste Regel gibt es nicht. PHPs
`memory_limit` steht im Image auf `2G`, jeder der vier Messenger-Worker ist durch
`--memory-limit=256M` begrenzt und startet sich bei `--time-limit=3600` neu, und Postgres, der
Mercure-Hub und der IMAP-Supervisor wollen jeweils ihren eigenen Anteil obendrauf. Der erste
Mercure-Hub und der IMAP-Supervisor wollen jeweils ihren eigenen Anteil obendrauf. Die Worker, der
Hub und der IMAP-Supervisor teilen sich einen Container, ein Speicherlimit darauf gilt also für alle
zusammen. Der erste
Abgleich eines großen Postfachs ist die anstrengendste Phase überhaupt.
Das genügt für IMAP-Postfächer. Gmail und Outlook brauchen zusätzlich OAuth-Zugangsdaten — siehe
@@ -68,16 +70,16 @@ die du kennen solltest:
1. **`secrets-init` läuft und beendet sich.** Es erzeugt `APP_SECRET`, `APP_ENCRYPTION_KEY`,
`POSTGRES_PASSWORD` und `MERCURE_JWT_SECRET` in `var/secrets/generated.env` auf dem gemeinsamen
Volume `app_secrets`, dazu eine nackte Datei `postgres_password`. Es läuft vor allem anderen,
weil Postgres und Mercure ihre Geheimnisse beim Anlegen des Containers lesen und nicht darauf
warten können, dass die Anwendung sie ihnen reicht. Bei jedem späteren Start findet es die Datei
weil Postgres sein Passwort beim Anlegen des Containers liest und nicht darauf warten kann, dass
die Anwendung es ihm reicht. Bei jedem späteren Start findet es die Datei
vor und tut nichts.
2. **`database` startet** und liest sein Passwort über `POSTGRES_PASSWORD_FILE`. Jeder
Anwendungsdienst wartet auf dessen Healthcheck, der eine Startphase von 60 Sekunden zugesteht.
3. **Jeder Anwendungscontainer führt denselben Entrypoint aus.** Er lädt die erzeugten Geheimnisse,
setzt `DATABASE_URL` aus `POSTGRES_PASSWORD` zusammen — es sei denn, du hast eine DSN mit einem
eigenen Passwort mitgegeben —, wartet bis zu 60 Versuche lang auf die Datenbank und führt dann
`app:db:migrate` aus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die sechs
gemeinsam startenden Container nicht kollidieren.
`app:db:migrate` aus: Doctrines Migrate unter einem Postgres-Advisory-Lock, damit die gemeinsam
startenden Container nicht kollidieren.
4. **`app:secrets:init` läuft**, nach den Migrationen. Es prüft, ob der geltende
Verschlüsselungsschlüssel die bereits gespeicherten Zugangsdaten entschlüsseln kann, und erzeugt
anschließend ein VAPID-Schlüsselpaar sowie das JMAP-JWT-Schlüsselpaar, falls diese fehlen.
@@ -131,28 +133,53 @@ rufe die Einrichtungsseite über die Adresse auf, die du tatsächlich verwenden
| Dienst | Was darin läuft | Warum er ein eigener Container ist |
|---|---|---|
| `secrets-init` | `generate-secrets`, dann Ende | Postgres und Mercure brauchen ihre Geheimnisse beim Anlegen des Containers, bevor die Anwendung existiert |
| `php` | FrankenPHP, das die Anwendung ausliefert | Der einzige Anwendungsdienst mit HTTP-Server und damit der einzige, dessen Image-Healthcheck aktiv bleibt — die übrigen schalten ihn ab und melden Lebendigkeit stattdessen über Heartbeats |
| `secrets-init` | `generate-secrets`, dann Ende | Postgres braucht sein Passwort beim Anlegen des Containers, bevor die Anwendung existiert |
| `php` | FrankenPHP, das die Anwendung ausliefert | Der einzige Anwendungsdienst mit HTTP-Server und damit der einzige, dessen Image-Healthcheck aktiv bleibt — der Worker schaltet ihn ab und meldet Lebendigkeit stattdessen über Heartbeats |
| `worker` | `app:work`: jeder Hintergrundprozess, jeweils als eigener Prozess | Alles, was nicht der Webserver ist. Im Netz antwortet er auf den Namen `mercure`, weil er den Hub betreibt |
| `database` | `postgres:18-alpine` mit vorgeladenem `pg_stat_statements` | — |
| `mercure` | Der Mercure-Hub | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
| `ntfy` | ntfy, unter dem Profil `push` | Optional. Android-Push ohne Google — starte ihn mit `docker compose --profile push up -d` |
Die Prozesse des Workers. Jeder wird wieder gestartet, wenn er endet: sofort, wenn er sauber
endete (ein Consumer, der an seinem Zeit- oder Speicherlimit neu anfängt), nach einer Wartezeit von
bis zu dreißig Sekunden, wenn er abgestürzt ist.
| Prozess | Was darin läuft | Wofür er da ist |
|---|---|---|
| `mercure` | Der Mercure-Hub, 1.x | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
| `imap-supervisor` | `app:imap:supervise` | Startet und überwacht je einen `app:imap:idle`-Prozess pro IDLE-fähigem Postfach, damit gewöhnliche IMAP-Mail in dem Moment ankommt, in dem sie eintrifft |
| `worker-export` | `messenger:consume export` | Alles, was plMail verlässt, und die einzige Warteschlange, auf die jemand wartet. In einem eigenen Prozess, damit ein Versand nie hinter einem Abgleich steht |
| `worker-ingest` | `messenger:consume ingest` | Eingehende Mail und die Arbeit, die unmittelbar darauf folgt |
| `worker-maintenance` | `messenger:consume maintenance async` | Nachträgliche Verarbeitungen, Regelläufe über vorhandene Mail, administrative Durchläufe. Leert außerdem die stillgelegte Warteschlange `async` |
| `worker-bulk` | `messenger:consume bulk` | Aktionen über eine ganze Ansicht: alles Ungelesene als gelesen markieren, alles archivieren. Eigener Prozess, weil jemand dabei auf eine Fortschrittsanzeige schaut und das nicht hinter einer nachträglichen Verarbeitung warten darf |
| `scheduler` | `messenger:consume scheduler_default` | Löst alles Wiederkehrende aus. **Ohne diesen Container plant sich nichts von selbst** |
| `ntfy` | ntfy, unter dem Profil `push` | Optional. Android-Push ohne Google — starte ihn mit `docker compose --profile push up -d` |
| `scheduler` | `messenger:consume scheduler_default` | Löst alles Wiederkehrende aus. **Ohne diesen Prozess plant sich nichts von selbst** |
Vier Prozesse statt vier Transports in einem Worker, weil ein Worker, der bereits in einem langen
Handler steckt, nichts anderes mehr annehmen kann, wie auch immer die Warteschlangen priorisiert
sind. Genau das war das ursprüngliche Problem: Ein Klick auf Senden wartete hinter einem
Gmail-Batch.
Vier Warteschlangen-Prozesse statt vier Transports in einem, weil ein Worker, der bereits in einem
langen Handler steckt, nichts anderes mehr annehmen kann, wie auch immer die Warteschlangen
priorisiert sind. Genau das war das ursprüngliche Problem: Ein Klick auf Senden wartete hinter
einem Gmail-Batch. Früher waren sie je ein Container, jetzt sind sie je ein Prozess, und das war
der Teil, auf den es ankam. Jeder behält seinen alten Namen als `APP_CONTAINER_NAME`, nach dem sich
die Heartbeats, `/healthz` und die Protokollansicht im Administrationsbereich richten, sie lesen
sich also genau wie vorher.
**Der typische Fehlerfall ist, den Dienst `scheduler` wegzulassen.** Ohne ihn wird überhaupt nichts
Wiederkehrendes ausgelöst — kein Abgleich per Polling, kein Aufwachen zurückgestellter
Konversationen, kein Kalenderabgleich, keine Erinnerungen, kein Aufräumen — und nirgends erscheint
ein Fehler, weil nichts fehlgeschlagen ist. `php bin/console debug:scheduler` listet auf, was laufen
sollte.
Der Hub ist das offizielle Mercure-1.x-Programm, ins plMail-Image kopiert statt aus einem eigenen
Image betrieben. Es ist nicht der in FrankenPHP eingebaute Hub, denn der ist eine 0.x-Version, und
plMail spricht das 1.0-Protokoll.
**Wieder aufteilen** geht mit `app:work --only=…` und `--without=…`. Ein Container mit
`app:work --only=worker-bulk` neben einem mit `app:work --without=worker-bulk` gibt der
Bulk-Warteschlange einen eigenen Container und ein eigenes Speicherlimit. Nur ein Container darf
`mercure` betreiben, und das ist der, der den Netzwerk-Alias `mercure` braucht.
**Umsteigen von einem Container pro Prozess** muss nicht sofort sein: Das Image betreibt jeden
dieser Dienste weiterhin wie bisher, und der separate Hub aus `dunglas/mercure` funktioniert auch
weiterhin. Ersetze die Compose-Datei durch die aktuelle, wenn es dir passt. Der Hub behält seinen
Verlauf, weil der Worker dasselbe Volume `mercure_data` einhängt.
**Der typische Fehlerfall ist, den Prozess `scheduler` wegzulassen**, mit `--only` oder
`--without`. Ohne ihn wird überhaupt nichts Wiederkehrendes ausgelöst — kein Abgleich per Polling,
kein Aufwachen zurückgestellter Konversationen, kein Kalenderabgleich, keine Erinnerungen, kein
Aufräumen — und nirgends erscheint ein Fehler, weil nichts fehlgeschlagen ist.
`php bin/console debug:scheduler` listet auf, was laufen sollte.
## Speicher, und was die Standarddatei nicht dauerhaft ablegt
@@ -160,11 +187,11 @@ Die Compose-Datei deklariert zehn benannte Volumes:
| Volume | Enthält |
|---|---|
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** App-Dienst eingehängt, in `database` und `mercure` nur lesend |
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** App-Dienst eingehängt, in `database` nur lesend |
| `app_attachments`, `app_raw`, `app_uploads` | Anhänge, Rohnachrichten und zwischengelagerte JMAP-Uploads. Von jedem App-Dienst eingehängt, denn die Worker schreiben sie und der Web-Container liefert sie aus |
| `database_data` | Der PostgreSQL-Cluster |
| `caddy_data`, `caddy_config` | Caddys TLS-Material und Zustand |
| `mercure_data`, `mercure_config` | Zustand des Hubs |
| `mercure_data`, `mercure_config` | Zustand des Hubs, vom Worker eingehängt |
| `ntfy_data` | Zustand der Benachrichtigungs-Topics |
Die drei Blob-Volumes fehlten bis vor Kurzem, und der Fehler war auf lehrreiche Weise lautlos. Das
+3 -3
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/reverse-proxy.md sha1:97f1ea6185294b50988ab2e0a9dc93ff18ee8b74 -->
<!-- translated-from: install/reverse-proxy.md sha1:0c7b41ef1eee378b74f5810cd9189eab728c4a1a -->
# Hinter einem Reverse-Proxy
Alles, was von außerhalb des eigenen Netzes erreichbar ist, will ein echtes Zertifikat davor, und
@@ -109,8 +109,8 @@ Redirect-URI-Abweichung nennt die URI, und das `http://` darin ist der ganze Hin
## Mercures öffentliche URL
Der Hub wird auf der Origin der Anwendung selbst durchgereicht: `frankenphp/Caddyfile` leitet
`/.well-known/mercure*` an den `mercure`-Container, sodass der Browser den Hub auf derselben Origin
erreicht, das Subscriber-Cookie ein First-Party-Cookie ist und es nirgends eine CORS-Konfiguration
`/.well-known/mercure*` an `mercure` weiter, den Worker-Container, der den Hub betreibt und auf
diesen Namen antwortet. Der Browser erreicht den Hub so auf derselben Origin, das Subscriber-Cookie ein First-Party-Cookie ist und es nirgends eine CORS-Konfiguration
gibt.
Zwei Variablen, und sie zeigen in entgegengesetzte Richtungen:
+12 -8
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/troubleshooting.md sha1:376ffd5aba03bcdd113d715cfcca4ce64526fa1f -->
<!-- translated-from: install/troubleshooting.md sha1:978f8c024df0873bd3f40023340d6b2667177efa -->
# Fehlersuche
Was `/healthz` bedeutet, wie du eine hängende von einer leeren Warteschlange unterscheidest, wo die
@@ -54,9 +54,10 @@ HTTP-Status.
## Die Warteschlange
Vier Transports auf dem Doctrine-Transport, jeder mit einem eigenen Worker-Prozess:
Vier Transports auf dem Doctrine-Transport, jeder mit einem eigenen Consumer-Prozess im
Worker-Container:
| Warteschlange | Container | Was darauf liegt |
| Warteschlange | Prozess | Was darauf liegt |
|---|---|---|
| `export` | `worker-export` | Alles, was plMail verlässt — Versand, Flag-Pushes, Gmail-Label-Änderungen, Mail aus dem Notifier. Die einzige Warteschlange, auf die jemand wartet |
| `ingest` | `worker-ingest` | Eingehende Mail, Gmail- und Graph-Nachrichten-Batches, Kalenderabgleiche, Terminerkennung |
@@ -163,10 +164,13 @@ Dashboard mit toten Workern füllt, die nie wirklich tot waren.
```bash
docker compose logs -f php
docker compose logs -f worker-ingest
docker compose logs -f scheduler
docker compose logs -f worker
```
Alles, was nicht der Webserver ist, protokolliert über den Worker: die Warteschlangen-Consumer, der
Scheduler, der IMAP-Supervisor und der Hub. Jeder JSON-Eintrag nennt den Prozess, von dem er stammt,
ein Filter auf `worker-ingest` oder `scheduler` findet also genau einen davon.
Der Haupt-Handler ist `fingers_crossed` auf `error` mit einem Puffer von 50 Nachrichten, das heißt,
gewöhnliche Info-Zeilen werden verworfen — *bis* etwas fehlschlägt, und dann werden die 50
vorangegangenen Nachrichten mit ausgegeben. Deshalb kommt ein Fehler im Log meist mit seinem eigenen
@@ -250,6 +254,6 @@ Installation, deren unlesbare Daten wirklich entbehrlich sind, neu anfangen kann
des Verschlüsselungsschlüssels lässt es bewusst zu — siehe
[CONTRIBUTING](../../CONTRIBUTING.md#when-the-keys-disagree).
**Logs aus `docker compose logs php` sind nur die des Web-Containers.** Sechs Container laufen mit
demselben Image, und die interessante Zeile steht meist in dem Worker, dem die Warteschlange gehört,
auf der die Arbeit lag.
**Logs aus `docker compose logs php` sind nur die des Web-Containers.** Die interessante Zeile steht
meist in dem Worker-Prozess, dem die Warteschlange gehört, auf der die Arbeit lag, und die
protokollieren alle über `docker compose logs worker`.
+9 -8
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/upgrading.md sha1:5ff93f95aaf246cd1cfa30883c1381c2722131ea -->
<!-- translated-from: install/upgrading.md sha1:cd56ca54927898f35075d3948a2deb9257fc0a4a -->
# Aktualisieren
plMail migriert seine eigene Datenbank beim Start. Diese eine Entscheidung prägt alles auf dieser
@@ -10,16 +10,17 @@ Tag zu ziehen.
## Migrationen laufen automatisch, bei jedem Start
`frankenphp/docker-entrypoint.sh` wartet auf die Datenbank und führt dann in jedem
Anwendungscontainer `app:db:migrate` aus — im Webserver, im IMAP-Supervisor, in allen vier Workern.
Anwendungscontainer `app:db:migrate` aus — im Webserver und im Worker, und in jedem Container eines
älteren Aufbaus, der einen Prozess pro Container betrieb.
Dieser Befehl ist `doctrine:migrations:migrate --all-or-nothing --no-interaction` mit einer
Ergänzung: Er hält für den gesamten Lauf einen Postgres-Advisory-Lock.
Der Lock ist keine Zierde. Sechs Container starten im Abstand von Millisekunden gegen eine
Datenbank, alle sechs lesen das Migrationsregister, bevor einer von ihnen hineingeschrieben hat, und
alle sechs entscheiden, dass dieselbe Migration aussteht. Ohne den Lock gewinnt einer, die übrigen
blockieren an seiner Tabellensperre, werden bei seinem Commit freigegeben und sterben an einem
Schema, das sich bereits bewegt hat — `SQLSTATE[42701]: Duplicate column`. Unter `set -e` sind das
fünf Dienste, die nie starten. Mit dem Lock warten die Verlierer, lesen ein bereits aktuelles
Der Lock ist keine Zierde. Die Anwendungscontainer starten im Abstand von Millisekunden gegen eine
Datenbank, alle lesen das Migrationsregister, bevor einer von ihnen hineingeschrieben hat, und alle
entscheiden, dass dieselbe Migration aussteht. Ohne den Lock gewinnt einer, die übrigen blockieren
an seiner Tabellensperre, werden bei seinem Commit freigegeben und sterben an einem Schema, das
sich bereits bewegt hat — `SQLSTATE[42701]: Duplicate column`. Unter `set -e` sind das Dienste, die
nie starten. Es waren fünf, solange jeder Prozess seinen eigenen Container hatte. Mit dem Lock warten die Verlierer, lesen ein bereits aktuelles
Register und beenden sich, ohne etwas gefunden zu haben.
Daraus folgen drei Dinge, und sie sind der Grund für diese Seite:
+5 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/architecture.md sha1:d211816996a11ea7432dcd0b9925abb7c42dfa45 -->
<!-- translated-from: internals/architecture.md sha1:ad354438d897072ff5d4a852b98d222881661e2b -->
# Architektur
Die Schichten, was wo liegt, und die Regeln, die das so halten. Diese Seite beschreibt die
@@ -178,7 +178,7 @@ Workers, damit ein verzögerter Retry nie auf einen Neustart warten muss.
Getrennte Transports allein genügen nicht. Ein Worker, der bereits in einem langen Handler
steckt, kann eine Sendung nicht aufnehmen, wie hoch sie auch priorisiert ist — also hat jeder
Transport seinen eigenen Prozess: `worker-export`, `worker-ingest`, `worker-maintenance` und
`worker-bulk` in `compose.yaml`. Ein fünfter Transport, `async`, wird ohne Routing
`worker-bulk`, die `app:work` nebeneinander im Worker-Container betreibt. Ein fünfter Transport, `async`, wird ohne Routing
weitergeführt, damit Envelopes, die vor der Aufteilung eingereiht wurden, noch irgendwo landen
können; der Maintenance-Worker leert ihn.
@@ -221,7 +221,7 @@ Referenz auf einen Manager, den es nicht mehr gibt.
`App\Infrastructure\Scheduler\MaintenanceSchedule` ist die eine Stelle, an der wiederkehrende
Arbeit deklariert wird. Konsumiert wird sie von `messenger:consume scheduler_default` — dem
Dienst `scheduler` in `compose.yaml` — und **sonst läuft davon nichts**, was genau der Zustand
Prozess `scheduler` im Worker-Container — und **sonst läuft davon nichts**, was genau der Zustand
war, in dem das Projekt vorher steckte: Logs und verwaiste Blobs wuchsen ohne Grenze.
| Cron | Befehl | Warum diese Taktung |
@@ -282,8 +282,8 @@ einem HTTP-Request herauszuholen, bleibt damit stillschweigend darin.
`CalendarSubscriberTest` prüft das Routing; eine neue Message bekommt weder das eine noch das
andere geschenkt.
**Ein neuer Befehl in `MaintenanceSchedule` tut nichts, solange der Dienst `scheduler` nicht
läuft.** Das ist ein eigener Container, der `scheduler_default` konsumiert. `php bin/console
**Ein neuer Befehl in `MaintenanceSchedule` tut nichts, solange der Prozess `scheduler` nicht
läuft.** Er ist einer der Prozesse des Worker-Containers und konsumiert `scheduler_default`. `php bin/console
debug:scheduler` zeigt den nächsten Lauf jedes Eintrags und ist der schnellste Weg
herauszufinden, dass die Antwort „nie" lautet.
+7 -6
View File
@@ -1,4 +1,4 @@
<!-- translated-from: providers/imap-smtp.md sha1:9c6e34825ac15a5d17b1754a85fb81a4335d4c27 -->
<!-- translated-from: providers/imap-smtp.md sha1:a63a352201b6840dd517647e669c8fd3ac19fec8 -->
# IMAP und SMTP
@@ -56,8 +56,8 @@ Protokolls selbst zu sagen „melde dich, wenn sich etwas ändert", statt alle p
nachzufragen. Eine Nachricht, die auf dem Server eintrifft, erreicht plMail in Sekunden.
Es gibt nichts einzuschalten. Wenn plMail die Ordner eines Kontos entdeckt, markiert es die, die der
Server als Posteingang und als Spam-Ordner kennzeichnet, als IDLE-fähig, und der Dienst
`imap-supervisor` betreibt eine Verbindung pro solchem Ordner und startet abgebrochene mit einem
Server als Posteingang und als Spam-Ordner kennzeichnet, als IDLE-fähig, und der IMAP-Supervisor
(einer der Prozesse des Worker-Containers) betreibt eine Verbindung pro solchem Ordner und startet abgebrochene mit einem
kurzen Backoff neu. Andere Ordner werden synchronisiert, aber nicht beobachtet, und das ist eine
bewusste Sparsamkeit: Eine IDLE-Verbindung ist ein gehaltener TCP-Socket, und dreißig davon pro Konto
zu halten, um von Mail zu erfahren, die in einen Archivordner wandert, kostet mehr, als es wert ist.
@@ -67,8 +67,9 @@ dafür, dass Mail sofort eintrifft; der Durchlauf sorgt dafür, dass sie überha
Verbindung von einer Firewall gekappt wurde oder ein Anbieter beschlossen hat, IDLE eine Weile nicht
mehr zu beantworten.
Wenn Mail nicht von selbst eintrifft, ist das Erste zu prüfen, ob der Container `imap-supervisor`
läuft. Ohne ihn hält nichts eine IDLE-Verbindung, und Mail trifft nur im Viertelstundentakt ein.
Wenn Mail nicht von selbst eintrifft, ist das Erste zu prüfen, ob der Worker-Container läuft und
ob das Feld „Prozesse“ im Administrationsbereich den IMAP-Supervisor schlagen zeigt. Ohne ihn hält
nichts eine IDLE-Verbindung, und Mail trifft nur im Viertelstundentakt ein.
## App-Passwörter bei den großen Anbietern
@@ -151,7 +152,7 @@ Viertelstunden-Durchlauf ein. Eine Nachricht, die vier Minuten nachdem eine Rege
in einen Unterordner einsortiert hat in plMail auftaucht, ist genau das und funktioniert wie
vorgesehen.
**Ohne den Dienst `imap-supervisor` hält nichts eine IDLE-Verbindung.** Mail trifft weiterhin ein,
**Ohne den IMAP-Supervisor hält nichts eine IDLE-Verbindung.** Mail trifft weiterhin ein,
nach Zeitplan, was das zu einem sehr leisen Fehlschlag macht — das Symptom ist „Mail ist immer ein
paar Minuten spät" und nichts, was kaputt aussieht.
+1
View File
@@ -58,6 +58,7 @@ These are the variables in `.env`, in the order they appear there.
| `MERCURE_URL` | The hub address the **app** publishes to, inside the Docker network. | `http://mercure/.well-known/mercure` | Yes | Wrong and nothing publishes: no live updates, no visible error on the page. |
| `MERCURE_PUBLIC_URL` | The hub address the **browser** subscribes to. | `https://localhost/.well-known/mercure` in both `.env` and `compose.yaml` | Yes | Wrong and the browser opens a stream to somewhere it cannot reach — mail lists stop updating by themselves while the rest of the app works. Derived from `APP_PUBLIC_URL` only when this is unset or empty, which the stock `compose.yaml` prevents. See [Behind a reverse proxy](reverse-proxy.md). |
| `MERCURE_JWT_SECRET` | Signs the publisher and subscriber JWTs. | blank — 32 random bytes, hex, generated on first start | Yes, but generated | The app and the hub must hold the same value. They disagree and the hub rejects every subscriber, silently, from the browser's point of view. |
| `MERCURE_COOKIE_NAME` | The name of the cookie that lets the browser subscribe. The app sets it and the hub reads it, both from this one variable. | blank — `__Secure-mercure_access_token` | Only without TLS | An install serving plain HTTP, with no TLS anywhere in front, needs a name without the `__Secure-` prefix, such as `mercure_access_token`: a browser drops a `__Secure-` cookie that did not arrive over HTTPS, and live updates never start. |
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client. | blank | For Gmail | Without it, "Sign in with Google" cannot start. A value stored under **Admin → Integrations** wins over this one. See [Google](../providers/google.md). |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret. | blank | For Gmail | As above. |
| `GMAIL_PUBSUB_TOPIC` | Full topic name Gmail publishes watch notifications to. | `projects/your-project-id/topics/gmail-push` — a placeholder, not a working value | For instant Gmail | The placeholder is not a topic you own, so every `watch` call fails. The project id is lowercase and often differs from the display name. |
+2 -2
View File
@@ -78,8 +78,8 @@ works; writing `${APP_DEMO_MODE:-1}` into a compose file does not, and fails in
stack comes up healthy and simply is not a demo. So the overlay hardcodes it, and
`ComposeEnvironmentTest` keeps it that way.
It also parks `imap-supervisor`, which would otherwise spend the whole time failing to connect to
mailboxes a demo does not have.
It also leaves the IMAP supervisor out of the worker (`app:work --without=imap-supervisor`), which
would otherwise spend the whole time failing to connect to mailboxes a demo does not have.
The install still needs a first user before anything is reachable — `/install` or `app:setup`, as
on any other deployment. That user is yours, not a visitor's: the reaper only ever deletes
+43 -17
View File
@@ -18,7 +18,8 @@ emulation. Which machine you are on changes a few details — see [Platform note
Sizing is worth a thought rather than a rule. PHP's `memory_limit` is `2G` in the image, each of the
four Messenger workers is capped by `--memory-limit=256M` and recycles on `--time-limit=3600`, and
Postgres, the Mercure hub and the IMAP supervisor each want their own share on top. A first sync of
Postgres, the Mercure hub and the IMAP supervisor each want their own share on top. The workers, the
hub and the IMAP supervisor share one container, so a memory limit on it covers all of them. A first sync of
a large mailbox is the busiest this ever gets.
That is enough for IMAP mailboxes. Gmail and Outlook additionally need OAuth credentials — see
@@ -63,14 +64,14 @@ knowing about:
1. **`secrets-init` runs and exits.** It mints `APP_SECRET`, `APP_ENCRYPTION_KEY`,
`POSTGRES_PASSWORD` and `MERCURE_JWT_SECRET` into `var/secrets/generated.env` on the shared
`app_secrets` volume, plus a bare `postgres_password` file. It runs before everything else
because Postgres and Mercure read their secrets when their containers are created and cannot
wait for the app to hand them one. On every later start it finds the file and does nothing.
because Postgres reads its password when its container is created and cannot wait for the app
to hand it one. On every later start it finds the file and does nothing.
2. **`database` starts** and reads its password through `POSTGRES_PASSWORD_FILE`. Every app service
waits on its healthcheck, which allows a 60-second start period.
3. **Each app container runs the same entrypoint.** It loads the generated secrets, assembles
`DATABASE_URL` from `POSTGRES_PASSWORD` unless you supplied a DSN with a password of your own,
waits up to 60 attempts for the database, then runs `app:db:migrate` — Doctrine's migrate under a
Postgres advisory lock, so the six containers booting together cannot collide.
Postgres advisory lock, so the containers booting together cannot collide.
4. **`app:secrets:init` runs**, after migrations. It verifies that the encryption key in force can
decrypt the credentials already stored, then generates a VAPID keypair and the JMAP JWT keypair
if they are missing.
@@ -117,26 +118,51 @@ from the environment, or reach the setup page through the address you actually i
| Service | What it runs | Why it is its own container |
|---|---|---|
| `secrets-init` | `generate-secrets`, then exits | Postgres and Mercure need their secrets at container-create time, before the app exists |
| `php` | FrankenPHP serving the app | The only app service with an HTTP server, and therefore the only one whose image healthcheck is left enabled — the others disable it and report liveness through heartbeats instead |
| `secrets-init` | `generate-secrets`, then exits | Postgres needs its password at container-create time, before the app exists |
| `php` | FrankenPHP serving the app | The only app service with an HTTP server, and therefore the only one whose image healthcheck is left enabled — the worker disables it and reports liveness through heartbeats instead |
| `worker` | `app:work`: every background process, one process each | Everything that is not the web server. It answers to `mercure` on the network, because it runs the hub |
| `database` | `postgres:18-alpine` with `pg_stat_statements` preloaded | — |
| `mercure` | The Mercure hub | Live updates — the mail list refreshing by itself |
| `ntfy` | ntfy, under the `push` profile | Optional. Android push without Google — start it with `docker compose --profile push up -d` |
The worker's processes. Each is started again when it stops: at once when it stopped cleanly (a
consumer recycling at its time or memory limit), after a backoff of up to thirty seconds when it
crashed.
| Process | What it runs | What it is for |
|---|---|---|
| `mercure` | The Mercure hub, 1.x | Live updates — the mail list refreshing by itself |
| `imap-supervisor` | `app:imap:supervise` | Spawns and watches one `app:imap:idle` process per IDLE-enabled mailbox, so standard IMAP mail arrives the moment it lands |
| `worker-export` | `messenger:consume export` | Anything leaving plMail, and the only queue somebody is watching. On its own process so a send is never behind a sync |
| `worker-ingest` | `messenger:consume ingest` | Mail arriving and the work that immediately follows it |
| `worker-maintenance` | `messenger:consume maintenance async` | Backfills, rule runs over existing mail, admin sweeps. Also drains the retired `async` queue |
| `worker-bulk` | `messenger:consume bulk` | Whole-view actions: mark every unread read, archive the lot. On its own process because somebody is watching an indicator for it and it must not wait behind a backfill |
| `scheduler` | `messenger:consume scheduler_default` | Fires everything recurring. **Nothing schedules itself without this container** |
| `ntfy` | ntfy, under the `push` profile | Optional. Android push without Google — start it with `docker compose --profile push up -d` |
| `scheduler` | `messenger:consume scheduler_default` | Fires everything recurring. **Nothing schedules itself without this process** |
Four processes rather than four transports on one worker, because a worker already inside a long
Four queue processes rather than four transports on one, because a worker already inside a long
handler cannot pick up anything else however the queues are prioritised. That was the original
problem: pressing Send behind a Gmail batch waited for the batch.
problem: pressing Send behind a Gmail batch waited for the batch. They used to be one container
each; they are one process each now, which is the part that mattered. Each keeps its old name as
`APP_CONTAINER_NAME`, which the heartbeats, `/healthz` and the admin log viewer go by, so they read
exactly as they did.
**The failure mode is dropping the `scheduler` service.** Without it nothing recurring fires at all
— no polling sync, no snooze wake, no calendar sync, no reminders, no pruning — and there is no
error anywhere, because nothing failed. `php bin/console debug:scheduler` lists what should be
running.
The hub is the official Mercure 1.x binary, copied into the plMail image rather than run from its
own. It is not the hub FrankenPHP has built in, which is a 0.x release, and plMail speaks the 1.0
protocol.
**Splitting it up again** is `app:work --only=…` and `--without=…`. A container running
`app:work --only=worker-bulk` beside one running `app:work --without=worker-bulk` gives the bulk
queue a container, and a memory limit, of its own. Only one container may run `mercure`, and it is
the one that needs the `mercure` network alias.
**Upgrading from one container per process** needs nothing at once: the image still runs each of
those services as it did, and the separate `dunglas/mercure` hub still works. Replace the compose
file with the current one when convenient. The hub keeps its history, because the worker mounts the
same `mercure_data` volume.
**The failure mode is leaving the `scheduler` process out** with `--only` or `--without`. Without
it nothing recurring fires at all — no polling sync, no snooze wake, no calendar sync, no
reminders, no pruning — and there is no error anywhere, because nothing failed.
`php bin/console debug:scheduler` lists what should be running.
## Storage, and what the stock file does not persist
@@ -144,11 +170,11 @@ The compose file declares ten named volumes:
| Volume | Holds |
|---|---|
| `app_secrets` | `generated.env`, `postgres_password`, the JWT keypair. Mounted by **every** app service, and read-only into `database` and `mercure` |
| `app_secrets` | `generated.env`, `postgres_password`, the JWT keypair. Mounted by **every** app service, and read-only into `database` |
| `app_attachments`, `app_raw`, `app_uploads` | Attachments, raw message sources, and staged JMAP uploads. Mounted by every app service, because the workers write them and the web container serves them |
| `database_data` | The PostgreSQL cluster |
| `caddy_data`, `caddy_config` | Caddy's TLS material and state |
| `mercure_data`, `mercure_config` | Hub state |
| `mercure_data`, `mercure_config` | Hub state, mounted by the worker |
| `ntfy_data` | Notification topic state |
The three blob volumes were missing until recently, and the failure was silent in an instructive
+2 -1
View File
@@ -101,7 +101,8 @@ and the `http://` in it is the entire clue.
## Mercure's public URL
The hub is proxied on the app's own origin: `frankenphp/Caddyfile` routes `/.well-known/mercure*`
to the `mercure` container, so the browser reaches the hub same-origin, the subscriber cookie is a
to `mercure`, the worker container, which runs the hub and answers to that name. The browser reaches
the hub same-origin, the subscriber cookie is a
first-party cookie, and no CORS configuration exists anywhere.
Two variables, and they point in opposite directions:
+11 -6
View File
@@ -49,9 +49,10 @@ answer 200. Watch the `checks` object, not just the HTTP status.
## The queue
Four transports on the Doctrine transport, each with its own worker process:
Four transports on the Doctrine transport, each with its own consumer process in the worker
container:
| Queue | Container | What is on it |
| Queue | Process | What is on it |
|---|---|---|
| `export` | `worker-export` | Anything leaving plMail — sends, flag pushes, Gmail label changes, mail from the notifier. The only queue somebody is waiting on |
| `ingest` | `worker-ingest` | Mail arriving, Gmail and Graph message batches, calendar syncs, event extraction |
@@ -151,10 +152,13 @@ really dead.
```bash
docker compose logs -f php
docker compose logs -f worker-ingest
docker compose logs -f scheduler
docker compose logs -f worker
```
Everything that is not the web server logs through the worker: the queue consumers, the scheduler,
the IMAP supervisor and the hub. Each JSON record names the process it came from, so a filter on
`worker-ingest` or `scheduler` finds one of them.
The main handler is `fingers_crossed` at `error` with a 50-message buffer, which means routine info
lines are discarded *until* something errors — and then the 50 preceding messages are flushed with
it. That is why an error in the log usually arrives with its own context attached, and why you will
@@ -232,5 +236,6 @@ every user, the stored files. It exists so an install whose unreadable data is g
can start over, and the encryption-key probe deliberately allows it to run — see
[CONTRIBUTING](../../CONTRIBUTING.md#when-the-keys-disagree).
**Logs from `docker compose logs php` are only the web container's.** Six containers run the same
image, and the interesting line is usually in whichever worker owns the queue the work was on.
**Logs from `docker compose logs php` are only the web container's.** The interesting line is
usually in whichever worker process owns the queue the work was on, and those all log through
`docker compose logs worker`.
+6 -4
View File
@@ -8,15 +8,17 @@ rather than pulling the previous tag.
## Migrations run automatically, on every boot
`frankenphp/docker-entrypoint.sh` waits for the database and then runs `app:db:migrate` in every
app container — the web server, the IMAP supervisor, all four workers. That command is
app container — the web server and the worker, and each container of an older layout that ran
one process per container. That command is
`doctrine:migrations:migrate --all-or-nothing --no-interaction` with one addition: it holds a
Postgres advisory lock for the whole run.
The lock is not decoration. Six containers start within milliseconds of each other against one
database, all six read the migration ledger before any of them has written to it, and all six
The lock is not decoration. The app containers start within milliseconds of each other against one
database, all of them read the migration ledger before any has written to it, and all of them
decide the same migration is pending. Without the lock one wins, the rest block on its table lock,
are released when it commits, and die on a schema that has already moved —
`SQLSTATE[42701]: Duplicate column`. Under `set -e` that is five services that never start. With
`SQLSTATE[42701]: Duplicate column`. Under `set -e` those are services that never start. It was
five of them when every process had a container of its own. With
it, the losers wait, read a ledger that is already current, and exit having found nothing to do.
Three consequences follow, and they are the reason this page exists:
+6 -5
View File
@@ -163,7 +163,8 @@ delayed retry is never left waiting on a restart.
Separate transports are not enough on their own. A worker already inside a long handler
cannot pick up a send however it is prioritised, so each transport has its own process:
`worker-export`, `worker-ingest`, `worker-maintenance` and `worker-bulk` in `compose.yaml`. A
`worker-export`, `worker-ingest`, `worker-maintenance` and `worker-bulk`, which `app:work` runs side by side
in the worker container. A
fifth transport, `async`, is kept routing nothing, so envelopes queued before the split still
have somewhere to land; the maintenance worker drains it.
@@ -201,8 +202,8 @@ envelopes, so a serialised entity is a reference to a manager that no longer exi
## The scheduler
`App\Infrastructure\Scheduler\MaintenanceSchedule` is the one place recurring work is
declared. It is consumed by `messenger:consume scheduler_default` — the `scheduler` service
in `compose.yaml` — and **nothing runs these otherwise**, which is the state the project was
declared. It is consumed by `messenger:consume scheduler_default` — the `scheduler` process
in the worker container — and **nothing runs these otherwise**, which is the state the project was
in before, with logs and orphaned blobs growing without bound.
| Cron | Command | Why that cadence |
@@ -260,8 +261,8 @@ request silently stays in it. `config/packages/messenger.yaml` names this for
`RegisterCalendarPushMessage` and `CalendarSubscriberTest` asserts the routing; a new message
gets neither for free.
**A new command in `MaintenanceSchedule` does nothing until the `scheduler` service is
running.** It is a separate container consuming `scheduler_default`. `php bin/console
**A new command in `MaintenanceSchedule` does nothing until the `scheduler` process is
running.** It is one of the worker container's processes, consuming `scheduler_default`. `php bin/console
debug:scheduler` shows the next run of each, and is the fastest way to find out that the
answer is "never".
+6 -6
View File
@@ -51,8 +51,8 @@ plMail holds an **IMAP IDLE** connection to each mailbox, which is the protocol'
server reaches plMail in seconds.
There is nothing to switch on. When plMail discovers an account's folders it marks the ones the
server flags as the inbox and the junk folder as IDLE-enabled, and the `imap-supervisor` service
runs one connection per such folder, restarting any that drop with a short backoff. Other folders
server flags as the inbox and the junk folder as IDLE-enabled, and the IMAP supervisor (one of the
worker container's processes) runs one connection per such folder, restarting any that drop with a short backoff. Other folders
are synced but not watched, which is a deliberate economy: an IDLE connection is a held TCP socket,
and holding thirty of them per account to learn about mail moving into an archive folder costs more
than it is worth.
@@ -61,9 +61,9 @@ Behind that, a scheduled sweep syncs every account every fifteen minutes regardl
arrive immediately; the sweep is what makes it arrive at all when a connection has been dropped by a
firewall or a provider has decided to stop answering IDLE for a while.
If mail is not arriving on its own, the thing to check first is that the `imap-supervisor` container
is running. Without it, nothing holds an IDLE connection and mail arrives on the fifteen-minute
schedule only.
If mail is not arriving on its own, the thing to check first is that the worker container is
running, and that the admin dashboard's Processes panel shows the IMAP supervisor beating. Without
it, nothing holds an IDLE connection and mail arrives on the fifteen-minute schedule only.
## App passwords at the big providers
@@ -142,7 +142,7 @@ to look when one mailbox has quietly stopped.
fifteen-minute sweep. A message that appears in plMail four minutes after a rule filed it into a
subfolder on the server is this, working as designed.
**Without the `imap-supervisor` service, nothing holds an IDLE connection.** Mail still arrives, on
**Without the IMAP supervisor, nothing holds an IDLE connection.** Mail still arrives, on
the schedule, which makes this a very quiet failure — the symptom is "mail is always a few minutes
late" rather than anything that looks broken.
@@ -49,7 +49,7 @@ final class GenerateVapidKeysCommand extends Command
$io->section('Container deployment — set as environment variables instead');
$io->writeln('There is no .env.local to edit in a container image. Pass these as env');
$io->writeln('vars on the php, messenger-worker and imap-supervisor services; a real');
$io->writeln('vars on the php and worker services; a real');
$io->writeln('environment variable overrides the .env.local.php that "composer');
$io->writeln('dump-env prod" bakes into the image. On TrueNAS they live in the');
$io->writeln('x-config block of truenas.compose.yaml.');
+5 -4
View File
@@ -18,11 +18,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* `doctrine:migrations:migrate`, but only one container at a time.
*
* A plMail stack starts four containers from the same image — php,
* imap-supervisor, messenger-worker and scheduler — and every one of them runs
* A plMail stack starts more than one container from the same image — php
* and the worker, and more where an installation splits its queues or still
* runs the older one-container-per-process layout — and every one of them runs
* `frankenphp/docker-entrypoint.sh`, which migrates on boot. They come up
* within milliseconds of each other against one database, all four read the
* ledger before any of them has written to it, and all four decide the same
* within milliseconds of each other against one database, all of them read the
* ledger before any has written to it, and all of them decide the same
* migration is pending. One wins; the rest block on the table lock it holds,
* are released when it commits, and then fail on a schema that has already
* moved:
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Command\System;
use App\Domain\DTO\System\BackgroundProcess;
use App\Service\System\Work\BackgroundProcesses;
use App\Service\System\Work\ProcessSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* The worker container: every background process plMail runs, in one place.
*
* The queue consumers, the scheduler, the IMAP supervisor and the Mercure hub
* used to be seven containers of one image. They are now the children of this
* command, still one process each (see BackgroundProcesses for why that part
* cannot change) and kept alive the way Docker kept the containers alive (see
* ProcessSupervisor).
*
* --only and --without pick a subset, for the two cases that want one: an
* installation that splits its busiest queue into a container of its own, and
* the demo, which has no IMAP server for the supervisor to reach.
*
* Run it under an init (compose's `init: true`). The IMAP supervisor's own
* children are this command's grandchildren, and if the supervisor dies hard,
* something has to reap them.
*/
#[AsCommand(
name: 'app:work',
description: 'Run every background process in one container: the queue workers, the scheduler, the IMAP supervisor and the Mercure hub.',
)]
final class WorkCommand extends Command implements SignalableCommandInterface
{
/** Below the compose files' stop_grace_period of 30s, so Docker does not kill the lot first. */
private const int GRACE_SECONDS = 25;
private bool $stopping = false;
public function __construct(
private readonly BackgroundProcesses $processes,
private readonly ProcessSupervisor $supervisor,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('only', null, InputOption::VALUE_REQUIRED, 'Run only these, comma-separated (e.g. worker-bulk)')
->addOption('without', null, InputOption::VALUE_REQUIRED, 'Run everything but these, comma-separated (e.g. imap-supervisor)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$chosen = $this->choose((string) $input->getOption('only'), (string) $input->getOption('without'));
} catch (\InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::INVALID;
}
$io->writeln(sprintf('app:work: running %s', implode(', ', array_map(
static fn (BackgroundProcess $process): string => $process->name,
$chosen,
))));
$this->supervisor->run($chosen, fn (): bool => $this->stopping, self::GRACE_SECONDS);
$io->writeln('app:work: stopped');
return Command::SUCCESS;
}
/** @return list<int> */
public function getSubscribedSignals(): array
{
return [SIGTERM, SIGINT];
}
/**
* Stop, but not here: `false` lets execute() finish, and it is execute()
* that passes the signal on to every child and waits for them.
*/
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->stopping = true;
return false;
}
/**
* @return list<BackgroundProcess>
*
* @throws \InvalidArgumentException naming what is not a process, and what is
*/
private function choose(string $only, string $without): array
{
$known = $this->processes->names();
$only = $this->names($only);
$skip = $this->names($without);
$wrong = array_diff([...$only, ...$skip], $known);
if ([] !== $wrong) {
throw new \InvalidArgumentException(sprintf('No such process: %s. There are: %s.', implode(', ', $wrong), implode(', ', $known)));
}
$chosen = array_values(array_filter(
$this->processes->all(),
static fn (BackgroundProcess $process): bool => ([] === $only || in_array($process->name, $only, true))
&& false === in_array($process->name, $skip, true),
));
if ([] === $chosen) {
throw new \InvalidArgumentException('That leaves nothing to run.');
}
return $chosen;
}
/** @return list<string> */
private function names(string $list): array
{
return array_values(array_filter(array_map(trim(...), explode(',', $list)), static fn (string $name): bool => '' !== $name));
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\System;
/**
* One long-running process the worker container keeps alive: what it is called
* and what to run.
*/
final readonly class BackgroundProcess
{
/**
* @param string $name what it answers to everywhere: the heartbeat key, the
* log entries' container name, `app:work --only`
* @param list<string> $command argv, run from the project directory
* @param array<string, string> $env set on top of the container's own environment
*/
public function __construct(
public string $name,
public array $command,
public array $env = [],
) {
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Service\System\Work;
use App\Domain\DTO\System\BackgroundProcess;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Everything plMail runs besides the web server, as the worker container runs
* it (app:work).
*
* ── Processes, not containers ───────────────────────────────────────────────
* Each queue still gets a process of its own, for the reason it always did: a
* consumer already inside a long handler cannot pick up anything else, however
* the queues are prioritised, so a send would wait behind a sync. What changed
* is only where the processes live. They used to be seven containers of one
* image; now they are seven children of one supervisor.
*
* The names are the containers' old ones, deliberately. Each child gets its
* name as APP_CONTAINER_NAME, which is what the heartbeats are keyed by and
* what the admin log viewer shows, so the admin dashboard, /healthz and the log
* filters read exactly as before.
*
* ── The hub ─────────────────────────────────────────────────────────────────
* The Mercure hub is the one child that is not PHP: the 1.x hub binary the
* image copies in (see the Dockerfile's mercure_upstream). It is configured
* here the way the separate hub container used to be configured in compose:
* plain HTTP on :80, the generated JWT secret as both keys, and the issuer and
* audience pinned to `mercure.identifier` (config/services.yaml says why they
* cannot be derived from the install's own address). The container answers to
* `mercure` on the network, so the web container's proxy and MERCURE_URL reach
* it where they always reached the hub.
*/
final readonly class BackgroundProcesses
{
public const string HUB = 'mercure';
/** The consumers' recycling, as compose ran them: hourly, or at 256 MB. */
private const array CONSUMER_LIMITS = ['--time-limit=3600', '--memory-limit=256M'];
public function __construct(
#[Autowire('%mercure.identifier%')]
private string $hubIdentifier,
// The subscriber cookie's name, as the application sets it. The hub has
// to look for the same one: `__Secure-…` by default, and whatever
// MERCURE_COOKIE_NAME says on an install serving plain HTTP, where a
// browser drops a `__Secure-` cookie. One setting now, where the hub
// container needed its own copy of it.
#[Autowire('%mercure.cookie_name%')]
private string $cookieName = '__Secure-mercure_access_token',
#[Autowire('%env(default::MERCURE_JWT_SECRET)%')]
private ?string $hubSecret = null,
#[Autowire('%env(default::MERCURE_TRUSTED_ISSUERS)%')]
private ?string $trustedIssuers = null,
#[Autowire('%env(default::MERCURE_EXTRA_DIRECTIVES)%')]
private ?string $extraDirectives = null,
) {
}
/** @return list<BackgroundProcess> in start order: the hub first, so publishing has somewhere to go */
public function all(): array
{
return [
$this->hub(),
new BackgroundProcess('imap-supervisor', ['php', 'bin/console', 'app:imap:supervise']),
$this->consumer('worker-export', 'export'),
$this->consumer('worker-ingest', 'ingest'),
// Also drains `async`, the pre-split queue; see messenger.yaml.
$this->consumer('worker-maintenance', 'maintenance', 'async'),
$this->consumer('worker-bulk', 'bulk'),
$this->consumer('scheduler', 'scheduler_default'),
];
}
/** @return list<string> */
public function names(): array
{
return array_map(static fn (BackgroundProcess $process): string => $process->name, $this->all());
}
private function consumer(string $name, string ...$transports): BackgroundProcess
{
return new BackgroundProcess($name, ['php', 'bin/console', 'messenger:consume', ...$transports, ...self::CONSUMER_LIMITS]);
}
private function hub(): BackgroundProcess
{
$secret = (string) $this->hubSecret;
return new BackgroundProcess(self::HUB, ['mercure', 'run', '--config', '/etc/mercure/Caddyfile', '--adapter', 'caddyfile'], [
// Plain HTTP inside the stack; TLS, where there is any, is the web
// container's or the reverse proxy's. Set here rather than
// inherited: SERVER_NAME in this image is the WEB server's, and a
// hub that picked up a real hostname would go asking for a
// certificate.
'SERVER_NAME' => ':80',
'MERCURE_PUBLISHER_JWT_KEY' => $secret,
'MERCURE_SUBSCRIBER_JWT_KEY' => $secret,
'MERCURE_TRUSTED_ISSUERS' => $this->orDefault($this->trustedIssuers, $this->hubIdentifier),
// The audience pinned rather than derived from each request: plMail
// reaches the hub by two roads (the browser at the public URL, the
// application at the internal one), and a hub working the audience
// out per request would refuse every publish.
'MERCURE_EXTRA_DIRECTIVES' => $this->orDefault(
$this->extraDirectives,
sprintf("resource_identifier %s\ncookie_name %s", $this->hubIdentifier, $this->cookieName),
),
// Five seconds to close its connections when told to stop, then it
// closes them. Caddy's own default is to wait for every one, and a
// live-update subscription is a connection that never ends by
// itself: the worker took the whole 25-second grace period to stop,
// every time, and then killed the hub anyway. Browsers reconnect.
'GLOBAL_OPTIONS' => 'grace_period 5s',
// The hub's Caddyfile shares these names with FrankenPHP's. Emptied
// so nothing meant for the web server configures the hub.
'CADDY_EXTRA_CONFIG' => '',
'CADDY_SERVER_EXTRA_DIRECTIVES' => '',
]);
}
private function orDefault(?string $value, string $default): string
{
$value = trim((string) $value);
return '' === $value ? $default : $value;
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Service\System\Work;
use App\Domain\DTO\System\BackgroundProcess;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
/**
* Keeps a set of processes running: what Docker's `restart: unless-stopped`
* did for them when each was a container of its own.
*
* A child that exits is started again. One that exited cleanly (a consumer at
* its hourly --time-limit, or told to by the restart signal) comes back at once;
* one that failed backs off, from one second doubling to thirty, so a process
* that cannot start (the database is down) does not spin. A child that has run
* two minutes has its backoff forgotten, the rule app:imap:supervise uses for
* its own children.
*
* ── Output ──────────────────────────────────────────────────────────────────
* Every child's output is passed through as it is, a whole line at a time. In
* production each line is a JSON log record, and two children writing at once
* must not interleave inside one: that is a line nobody can parse. Nothing is
* prefixed either, for the same reason; each child already names itself in its
* records through APP_CONTAINER_NAME.
*
* ── Stopping ────────────────────────────────────────────────────────────────
* SIGTERM to every child at once, then up to the grace period for them to
* finish what they are doing (a consumer finishes its message, the hub closes
* its connections), then SIGKILL for whatever is left. The container's
* stop_grace_period has to be longer than this, or Docker kills the lot first.
*/
final class ProcessSupervisor
{
/** @var array<string, array{definition: BackgroundProcess, process: ?Process, startedAt: float, notBefore: float, backoff: float, restarts: int}> */
private array $children = [];
/** @var array<string, string> partial lines, by child and stream */
private array $partial = [];
/** @var \Closure(string, string): void */
private \Closure $write;
/**
* @param (\Closure(string, string): void)|null $write where a child's line goes: (stream, line).
* stdout and stderr of this process by default.
*/
public function __construct(
private readonly LoggerInterface $logger,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
private readonly float $minBackoff = 1.0,
private readonly float $maxBackoff = 30.0,
private readonly float $forgiveAfter = 120.0,
?\Closure $write = null,
) {
$this->write = $write ?? static function (string $stream, string $line): void {
fwrite(Process::ERR === $stream ? STDERR : STDOUT, $line . "\n");
};
}
/**
* Start every process, keep them running until $shouldStop says so, then
* stop them all.
*
* @param list<BackgroundProcess> $processes
* @param \Closure(): bool $shouldStop
*/
public function run(array $processes, \Closure $shouldStop, int $graceSeconds = 25): void
{
$this->start($processes);
while (false === $shouldStop()) {
$this->tick();
usleep(200_000);
}
$this->stop($graceSeconds);
}
/** @param list<BackgroundProcess> $processes */
public function start(array $processes): void
{
foreach ($processes as $definition) {
$this->children[$definition->name] = [
'definition' => $definition,
'process' => null,
'startedAt' => 0.0,
'notBefore' => 0.0,
'backoff' => $this->minBackoff,
'restarts' => -1,
];
$this->launch($definition->name);
}
}
/**
* One pass: pass on what every child has written, notice the ones that
* have exited, and start again the ones whose wait is over.
*/
public function tick(): void
{
$now = microtime(true);
foreach ($this->children as $name => $child) {
$process = $child['process'];
if (null === $process) {
if ($now >= $child['notBefore']) {
$this->launch($name);
}
continue;
}
// isRunning() is also what reads the pipes, and so what calls the
// output callback: every child is asked every tick for that alone.
if (true === $process->isRunning()) {
if ($now - $child['startedAt'] >= $this->forgiveAfter) {
$this->children[$name]['backoff'] = $this->minBackoff;
}
// The callback has had it; do not keep a copy in memory for as
// long as the child lives, which for the hub is weeks.
$process->clearOutput();
$process->clearErrorOutput();
continue;
}
$this->flush($name);
$this->exited($name, $process->getExitCode(), $now);
}
}
public function stop(int $graceSeconds): void
{
foreach ($this->children as $child) {
if (true === $child['process']?->isRunning()) {
$child['process']->signal(SIGTERM);
}
}
$deadline = microtime(true) + $graceSeconds;
do {
$running = 0;
foreach ($this->children as $child) {
if (true === $child['process']?->isRunning()) {
++$running;
}
}
if (0 === $running) {
break;
}
usleep(100_000);
} while (microtime(true) < $deadline);
foreach ($this->children as $name => $child) {
if (true === $child['process']?->isRunning()) {
$this->logger->warning('app:work: {process} did not stop in time and was killed', ['process' => $name]);
$child['process']->stop(0);
}
$this->flush($name);
}
}
/**
* What is running now, by name: its pid and how often it has been restarted.
*
* @return array<string, array{pid: ?int, restarts: int}>
*/
public function status(): array
{
$status = [];
foreach ($this->children as $name => $child) {
$status[$name] = [
'pid' => true === $child['process']?->isRunning() ? $child['process']->getPid() : null,
'restarts' => max(0, $child['restarts']),
];
}
return $status;
}
private function launch(string $name): void
{
$definition = $this->children[$name]['definition'];
$process = new Process(
$definition->command,
$this->projectDir,
['APP_CONTAINER_NAME' => $definition->name] + $definition->env,
);
$process->setTimeout(null);
try {
$process->start(function (string $type, string $buffer) use ($name): void {
$this->receive($name, $type, $buffer);
});
} catch (\Throwable $e) {
// A binary that is not there, a descriptor limit: counted as a
// failed start, backed off like one.
$this->logger->error('app:work: {process} could not be started: {error}', ['process' => $name, 'error' => $e->getMessage()]);
$this->exited($name, null, microtime(true));
return;
}
$this->children[$name]['process'] = $process;
$this->children[$name]['startedAt'] = microtime(true);
++$this->children[$name]['restarts'];
}
private function exited(string $name, ?int $code, float $now): void
{
$child = $this->children[$name];
// Ran long enough to have been healthy: whatever ended it, it is not
// the crash loop the backoff is for.
$backoff = null !== $child['process'] && $now - $child['startedAt'] >= $this->forgiveAfter
? $this->minBackoff
: $child['backoff'];
// 0 is a consumer at its time or memory limit, or asked to restart:
// the recycling Docker used to do, and it comes straight back.
$delay = 0 === $code ? $this->minBackoff : $backoff;
if (0 === $code) {
$this->logger->info('app:work: {process} exited cleanly, starting it again', ['process' => $name]);
} else {
$this->logger->warning('app:work: {process} exited with code {code}, starting it again in {delay}s', [
'process' => $name,
'code' => $code,
'delay' => $delay,
]);
}
$this->children[$name]['process'] = null;
$this->children[$name]['notBefore'] = $now + $delay;
$this->children[$name]['backoff'] = 0 === $code ? $this->minBackoff : min($backoff * 2, $this->maxBackoff);
}
private function receive(string $name, string $type, string $buffer): void
{
$key = $name . '|' . $type;
$text = ($this->partial[$key] ?? '') . $buffer;
$cut = strrpos($text, "\n");
if (false === $cut) {
$this->partial[$key] = $text;
return;
}
$this->partial[$key] = substr($text, $cut + 1);
foreach (explode("\n", substr($text, 0, $cut)) as $line) {
($this->write)($type, $line);
}
}
/** What a child wrote without a final newline, once it can write no more. */
private function flush(string $name): void
{
foreach ([Process::OUT, Process::ERR] as $type) {
$key = $name . '|' . $type;
if ('' !== ($this->partial[$key] ?? '')) {
($this->write)($type, $this->partial[$key]);
}
unset($this->partial[$key]);
}
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\System\Work;
use App\Service\System\Work\BackgroundProcesses;
use PHPUnit\Framework\TestCase;
/**
* The worker container's processes keep the names the containers had, because
* the heartbeats, /healthz and the admin log filters all go by them; and the
* hub is configured the way the hub container was.
*/
final class BackgroundProcessesTest extends TestCase
{
private const string IDENTIFIER = 'https://plmail.invalid/.well-known/mercure';
public function testEveryProcessKeepsTheNameItsContainerHad(): void
{
self::assertSame(
['mercure', 'imap-supervisor', 'worker-export', 'worker-ingest', 'worker-maintenance', 'worker-bulk', 'scheduler'],
new BackgroundProcesses(self::IDENTIFIER)->names(),
);
}
public function testTheHubIsConfiguredAsItsContainerWas(): void
{
$hub = new BackgroundProcesses(self::IDENTIFIER, 'mercure_access_token', 'the-generated-secret')->all()[0];
self::assertSame(':80', $hub->env['SERVER_NAME'], 'never the web server\'s hostname: that would send the hub after a certificate');
self::assertSame('the-generated-secret', $hub->env['MERCURE_PUBLISHER_JWT_KEY']);
self::assertSame('the-generated-secret', $hub->env['MERCURE_SUBSCRIBER_JWT_KEY']);
self::assertSame(self::IDENTIFIER, $hub->env['MERCURE_TRUSTED_ISSUERS']);
self::assertSame(
"resource_identifier " . self::IDENTIFIER . "\ncookie_name mercure_access_token",
$hub->env['MERCURE_EXTRA_DIRECTIVES'],
'the audience pinned, and the cookie the application sets: one MERCURE_COOKIE_NAME for both halves',
);
$overridden = new BackgroundProcesses(self::IDENTIFIER, 'c', 's', 'https://issuer.example', 'cookie_name plmail')->all()[0];
self::assertSame('https://issuer.example', $overridden->env['MERCURE_TRUSTED_ISSUERS'], 'what an operator sets wins');
self::assertSame('cookie_name plmail', $overridden->env['MERCURE_EXTRA_DIRECTIVES']);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\System\Work;
use App\Domain\DTO\System\BackgroundProcess;
use App\Service\System\Work\ProcessSupervisor;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* What Docker's restart policy did for seven containers, done for seven
* processes: brought back when they stop, backed off when they crash, stopped
* together, and their output passed on whole.
*
* Real processes (`php -r`), because what is being tested is the handling of
* real exits, real pipes and real signals. Timings are shrunk to milliseconds.
*/
final class ProcessSupervisorTest extends TestCase
{
/** @var list<array{string, string}> */
private array $written = [];
public function testAProcessThatStopsCleanlyIsStartedAgainAtOnce(): void
{
$supervisor = $this->supervisor();
$supervisor->start([$this->php('recycles', 'echo "one\ntwo\n"; exit(0);')]);
$this->tickFor($supervisor, 0.6);
self::assertGreaterThanOrEqual(2, $supervisor->status()['recycles']['restarts'], 'a clean exit is the hourly recycle: straight back');
self::assertSame(['one', 'two'], array_slice(array_column($this->written, 1), 0, 2), 'whole lines, in order, nothing added');
}
/** A process that cannot start must not spin: each failure waits longer. */
public function testAProcessThatKeepsFailingBacksOff(): void
{
$supervisor = $this->supervisor();
$supervisor->start([$this->php('fails', 'exit(3);'), $this->php('recycles', 'exit(0);')]);
$this->tickFor($supervisor, 0.8);
$status = $supervisor->status();
self::assertGreaterThan(0, $status['fails']['restarts']);
self::assertLessThan($status['recycles']['restarts'], $status['fails']['restarts'], 'the failing one waits longer each time');
}
public function testStoppingSignalsEveryProcessAndWaitsForIt(): void
{
$supervisor = $this->supervisor();
$supervisor->start([
$this->php('polite', 'pcntl_async_signals(true); pcntl_signal(SIGTERM, function () { echo "finishing\n"; exit(0); }); while (true) { usleep(10000); }'),
]);
$this->tickFor($supervisor, 0.3);
$supervisor->stop(5);
self::assertNull($supervisor->status()['polite']['pid'], 'nothing left running');
self::assertContains('finishing', array_column($this->written, 1), 'it was asked, not killed');
}
private function supervisor(): ProcessSupervisor
{
return new ProcessSupervisor(
new NullLogger(),
sys_get_temp_dir(),
minBackoff: 0.02,
maxBackoff: 0.32,
forgiveAfter: 60.0,
write: function (string $stream, string $line): void {
$this->written[] = [$stream, $line];
},
);
}
private function php(string $name, string $code): BackgroundProcess
{
return new BackgroundProcess($name, [PHP_BINARY, '-r', $code]);
}
private function tickFor(ProcessSupervisor $supervisor, float $seconds): void
{
$until = microtime(true) + $seconds;
while (microtime(true) < $until) {
$supervisor->tick();
usleep(10_000);
}
}
}
+1 -1
View File
@@ -623,7 +623,7 @@ admin:
diese Seite von selbst zurück.
restart_app_title: 'Web-Container neu starten'
restart_confirm: 'Worker, Scheduler und IMAP-Supervisor neu starten? Laufende Arbeit wird zuerst abgeschlossen.'
restart_help: 'Beendet Worker, Scheduler und IMAP-Supervisor, damit ihre Container neu erstellt werden. Langlaufende Prozesse halten das Datenbank-Mapping über ihre gesamte Laufzeit im Speicher und brauchen das deshalb nach einer Migration.'
restart_help: 'Beendet Worker, Scheduler und IMAP-Supervisor, damit sie frisch neu starten. Langlaufende Prozesse halten das Datenbank-Mapping über ihre gesamte Laufzeit im Speicher und brauchen das deshalb nach einer Migration.'
restart_pending: 'Neustart vor %seconds%s angefordert — die Prozesse beenden ihre laufende Arbeit und erscheinen ein bis zwei Minuten später wieder hier.'
restart_title: 'Langlaufende Prozesse neu starten'
run_help: 'Wird für einen Worker eingereiht, nicht hier ausgeführt. Es sind dieselben Läufe, die der Scheduler automatisch startet.'
+1 -1
View File
@@ -616,7 +616,7 @@ admin:
downtime, and this page comes back on its own.
restart_app_title: 'Restart the web container'
restart_confirm: 'Restart the workers, scheduler and IMAP supervisor? In-flight work finishes first.'
restart_help: 'Stops the workers, scheduler and IMAP supervisor so their containers are recreated. Long-running processes cache database mappings for their whole lifetime, so they need this after a migration.'
restart_help: 'Stops the workers, scheduler and IMAP supervisor so they start again fresh. Long-running processes cache database mappings for their whole lifetime, so they need this after a migration.'
restart_pending: 'Restart requested %seconds%s ago — processes finish their current work, then reappear here within a minute or two.'
restart_title: 'Restart long-running processes'
run_help: 'Queued for a worker, not run here. These are the same sweeps the scheduler runs unattended.'
+29 -229
View File
@@ -132,8 +132,8 @@ x-app-env: &app-env
services:
# Mints whatever was left blank above into the storage directory, then exits. Runs
# before everything else: Postgres and Mercure read their secrets when their
# containers are created and cannot wait for the app to hand them one.
# before everything else: Postgres reads its password when its container is
# created and cannot wait for the app to hand it one.
# Idempotent — on every later start it finds the file and does nothing.
secrets-init:
image: *image
@@ -215,183 +215,45 @@ services:
database:
condition: service_healthy
imap-supervisor:
# Everything that is not the web server, in one container: the IMAP
# supervisor, one consumer per queue, the scheduler and the Mercure hub —
# seven processes, kept running by `app:work`. They used to be seven
# containers. Still one PROCESS per queue, so a send never waits behind a
# sync; only the containers merged.
#
# Answers to `mercure` on the network because it runs the hub, so the web
# container's proxy and MERCURE_URL reach it where they always reached the
# hub container.
worker:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console app:imap:supervise
command: php bin/console app:work
# Reaps the IMAP supervisor's children should it die hard.
init: true
# Longer than app:work's own 25s, which it gives every process to
# finish the message in hand.
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: imap-supervisor
APP_CONTAINER_NAME: worker
networks:
default:
aliases: [mercure]
volumes:
# One mount for everything plMail writes. APP_STORAGE_DIR and
# APP_SECRETS_* point inside it, so attachments, raw messages,
# uploads and the generated secrets all land here — and every
# service sees the same files, which the encryption key depends on.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# ── Workers, one per queue ────────────────────────────────────────────
# Three processes rather than three transports on one: a worker already
# inside a long handler cannot pick up anything else however the queues are
# prioritised, which is why pressing Send used to wait for a Gmail batch.
#
# Anything leaving plMail, and the only queue somebody is watching.
worker-export:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume export --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-export
volumes:
# One mount for everything plMail writes. APP_STORAGE_DIR and
# APP_SECRETS_* point inside it, so attachments, raw messages,
# uploads and the generated secrets all land here — and every
# service sees the same files, which the encryption key depends on.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
# Mail arriving and the work that immediately follows it.
worker-ingest:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume ingest --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-ingest
volumes:
# One mount for everything plMail writes. APP_STORAGE_DIR and
# APP_SECRETS_* point inside it, so attachments, raw messages,
# uploads and the generated secrets all land here — and every
# service sees the same files, which the encryption key depends on.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
# Bulk and deliberate. Also drains `async`, the pre-split queue, so
# envelopes written before the upgrade still have a consumer. Nothing routes
# there any more; drop it once a release has passed.
worker-maintenance:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume maintenance async --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-maintenance
volumes:
# One mount for everything plMail writes. APP_STORAGE_DIR and
# APP_SECRETS_* point inside it, so attachments, raw messages,
# uploads and the generated secrets all land here — and every
# service sees the same files, which the encryption key depends on.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# A whole-view mark-read or archive. On its own process for the reason the
# send queue is: somebody pressed a button and is watching an indicator,
# and this must not wait behind a backfill on `maintenance`. Drop this
# service and the indicator fills to nothing — the job is queued and never
# handled.
worker-bulk:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume bulk --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker-bulk
volumes:
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
healthcheck:
disable: true
depends_on:
secrets-init:
condition: service_completed_successfully
database:
condition: service_healthy
# Runs the scheduled jobs defined in
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
# renewal, monitoring pruning and the blob sweep. Drop this service and
# none of them ever fire.
scheduler:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
stop_grace_period: 15s
environment:
<<: *app-env
APP_CONTAINER_NAME: scheduler
volumes:
# app:prune:blobs unlinks files across all three stores, so it
# needs the same binds as the workers that write them.
# One mount for everything plMail writes, as on php.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
# The hub's state, in the volumes the hub container used.
- type: volume
source: mercure_data
target: /data
- type: volume
source: mercure_config
target: /config
healthcheck:
disable: true
depends_on:
@@ -434,68 +296,6 @@ services:
- "-c"
- "pg_stat_statements.track=top"
mercure:
image: dunglas/mercure
# PULLED ON EVERY `up`. Without this the tag floats and the image does
# not: compose reuses whatever is already on the box, so a hub can sit
# years behind the tag it claims to be while the update reports success.
# That is how an install ended up running a 0.x hub against an
# application that had moved to the 1.0 protocol.
pull_policy: always
restart: unless-stopped
environment:
SERVER_NAME: ':80'
# The hub image has no _FILE support, so the entrypoint below reads
# the generated secret in a shell before exec'ing Caddy. Filling in
# mercure_jwt above wins: the generator never overwrites it, so both
# sides still read the same value.
MERCURE_JWT_SECRET: *mercure_jwt
# The issuer this hub accepts and the audience it demands. The
# literal from config/services.yaml (`mercure.identifier`), NOT this
# install's own address — mercure_public_url above is blank on
# purpose here, because the setup screen stores the real one on
# first boot, and a hub cannot wait until then to be configured.
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure
entrypoint:
- /bin/sh
- -c
- |
if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /plmail/secrets/generated.env ]; then
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /plmail/secrets/generated.env)"
fi
export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET"
export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET"
exec "$$@"
- --
# The image's default Caddyfile, NOT dev.Caddyfile — this is a
# production stack and the dev one turns on `anonymous`, which accepts
# subscribers with no JWT at all. Since the app proxies
# /.well-known/mercure* publicly, that let anyone who could reach this
# instance subscribe to mail/user/<any id> and watch that account sync.
# The default config requires the subscriber JWT the app now mints.
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
depends_on:
secrets-init:
condition: service_completed_successfully
# NO healthcheck here, deliberately — the image's own is used. The one
# that used to be here asked for "/healthz", which the hub moved: it
# answers 404 now, `curl -f` reads that as failure, and the container
# reported itself unhealthy for ever while serving perfectly.
volumes:
- type: bind
source: *path_data
target: /plmail
bind:
create_host_path: true
read_only: true
- type: volume
source: mercure_data
target: /data
- type: volume
source: mercure_config
target: /config
volumes:
caddy_data:
caddy_config: