diff --git a/.ai-usage-info.md b/.ai-usage-info.md index b2bf077..e13f06d 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -2,13 +2,83 @@ ## Sinn der Library -[hier einfügen] +PhoreMQ ist der Entwurf einer frameworkunabhängigen PHP-Library für **RabbitMQ**. +Die erste Umsetzung enthält genau einen `RabbitMQConnector` hinter +`ConnectorInterface`. Es gibt keine Adapterregistrierung, Brokerauswahl oder +Fallback-Logik. Öffentliche Konfiguration verwendet die generischen Begriffe +Namespace, Topic, Subscription und Nachrichtentyp. -## Beispiele +**Status: Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und +Autoloading stammen aus der Projektvorlage; PHP >=8.5 ist verbindlich. Docker Compose und das PHP- +Setup sind unabhängig von der geplanten PHP-Library verwendbar. -[hier beispiele in ./examples/ verlinken] +Vorgeschlagener Namespace: `Phore\MessageQueue`; keine implementierten +öffentlichen Klassen oder globalen Funktionen vorhanden. Der RabbitMQ-Adapter +verwendet AMQP 0-9-1, Quorum Queues, Publisher Confirms und Ack nach Handler-Erfolg. +Topologie-Abbildung und Grenzen sind im Proposal §§ 2–7 festgelegt. -## Globale Funktionen +Das zentrale Objekt wird einmal pro Prozess mit DSN oder Adapter und +`ConnectionOptions` erzeugt. `ConnectionFactory` bleibt ein einfacher +Konstruktor-Helfer. Alle Beispiele laden dieselbe Konfiguration über +`new PhoreMQ(...demoConnection())`; konkrete Verbindungsoptionen stehen in Beispiel 01. -[hier links auf beispiele von speziellen funktionen einfügen] +`publish($dto)` oder `publish($topic, $type, $payload)` sendet sofort. +`subscribe` registriert Events, `respond` Commands, `run` verarbeitet +Zustellungen. `publish(...)->await(timeoutSeconds: 5)` wartet optional auf eine +Antwort und wirft bei Ablauf `RequestTimeoutException`. `await` sendet nichts +noch einmal. Eigene freigegebene Remote-Exceptions sind möglich. +`subscribe($callback)` und `#[Subscribe]` übernehmen die Metadaten des ersten +DTO-Parameters; widersprüchliche Angaben werden früh abgelehnt. Optional prüft +und hydriert `phore/schema` die lokale Struktur. Sender und Empfänger müssen +nicht dieselbe PHP-Klasse verwenden. Metadaten, HMAC-Signierung, Middleware und +verifizierte Dateireferenzen bleiben außerhalb der fachlichen Payload. + +**An alle:** eigene Subscription pro Empfänger. **An einen:** mehrere Worker +verwenden dieselbe Subscription. RabbitMQ verteilt Zustellungen; wiederholte +Verarbeitung bleibt möglich und benötigt fachliche Idempotenz. +`check()` prüft Verbindung und nachrichtenspezifische Bereitschaft. `HealthState` +meldet Dienstprobleme und Wiederherstellung im gemeinsamen Statusformat. +Ein Broker-Ping allein beweist keinen bereiten Handler. + +## Beispiele und Setup + +- [Architekturentscheidung und API-Proposal](docs/proposals/2026-09-12-message-queue-api.md) +- [Installation, Docker-Start und dynamische Topologie](docs/setup.md) +- [Gemeinsame Konfiguration](config/message-queue.json) +- [Message Queue Basics 101](docs/message-queue-basics-101.md) +- [Verbindung und zentrale Konfiguration](examples/api-draft/01-connect.php) +- [Programmatisch senden und empfangen](examples/api-draft/02-programmatic.php) +- [SDK-Typen und Handler mit Attributen](examples/api-draft/03-attributes.php) +- [ZIP-Dateien per verifizierter Speicherreferenz](examples/api-draft/04-files-and-local.php) +- [RPC mit Ergebnissen, Warnings und Remote-Exceptions](examples/api-draft/05-rpc.php) +- [Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) +- [Broadcast und Antworten aller erwarteten Teilnehmer](examples/api-draft/07-broadcast-locking.php) +- [Processing-Queue: ein Worker und ein Ergebnis](examples/api-draft/08-processing-workers.php) +- [Systemcheck und Dienststatus](examples/api-draft/09-system-check.php) +- [Callback-Fehler, Retry und Fehlerablage](examples/api-draft/10-callback-errors.php) + +Projektregel: Ausführbare Beispiele und Setup-Skripte werden ausschließlich in PHP >=8.5 gepflegt; siehe [AGENTS.md](AGENTS.md). + +`autoCreate` gilt für fachliche Topologie nur beim Registrieren von Listenern +(`subscribe`/`respond`). `publish`/`request` legen keine fachlichen Ressourcen an +und werfen bei fehlendem Topic oder nicht routbarer Nachricht direkt +`QueueConfigurationMissingException` (`TOPIC_MISSING` / `NO_MATCHING_SUBSCRIPTION`). +Vorhandene Queues ohne aktive Worker dürfen Backlog sammeln; für Bereitschaft +bleibt `check()` zuständig. RPC-Rückkanäle sind eine getrennte interne Einrichtung. + +- [QueueOptions: Profile, Defaults, DTO-Attribute und Konflikte](examples/api-draft/11-queue-options.php) + +RPC-Beispiel 05 beschreibt getrennte Docker-Publisher/Worker, private Rückkanäle, +automatisches Entfernen nach Connection-Ende, Request-Zuordnung und persistente +Idempotenz bei Neustarts. QueueOptions ersetzen separate retryPolicy-Vorgaben; +Work/RPC verwenden standardmäßig vier Versuche mit jeweils zehn Sekunden Abstand. +Bestehende Queue-Contracts werden geprüft und bei Konflikt abgelehnt, nicht +zwischen Container-Versionen automatisch hin- und hergeschrieben. Alles bleibt +API-Entwurf; keine MQ-Runtime ist durch diese Beispiele implementiert. + +[Internes Docker-Deployment ohne Management-Plugin, optional SASL ANONYMOUS](deployment/rabbitmq/HOWTO.md): Compose-Netzwerk und Datenvolume werden automatisch angelegt; das How-to erklärt vHost, Ports und die noch fehlende Kompatibilität mit der vollständigen PhoreMQ-Topologieprüfung. + +Die internen Compose-Beispiele konfigurieren die Anmeldung direkt über `RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS` im offiziellen Image; zusätzliche `.conf`-Dateien sind nicht erforderlich. + +[Anwendungsbeispiele mit Ablauf- und Objektübersicht](examples/api-draft/README.md): Sender/Worker getrennt, RPC über `request($dto)->await()`, Sendekonfiguration einheitlich in `PublishOptions`. Antwortfrist: `replyTimeoutSeconds` beim Versand; lokales Warten: `timeoutSeconds` bei `await`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..999ad0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Projektregeln für phore/message-queue + +- Mindestversion ist PHP 8.5 (`>=8.5`), für Library-Code, Tests, Beispiele und ausführbare Setup-/Hilfsskripte. Keine Kompatibilitätsschichten für ältere PHP-Versionen vorsehen. +- Ausführbare Beispiele und Setup-/Hilfsskripte werden ausschließlich in PHP geschrieben. Dokumentationsbeispiele verwenden ebenfalls PHP. Deklarative JSON-/YAML-Konfiguration und Shell-Befehle zum Aufrufen von PHP, Composer und Docker bleiben zulässig. +- Zunächst wird ausschließlich RabbitMQ über einen Adapter hinter `ConnectorInterface` umgesetzt. Keine Adapterregistrierung, Brokerauswahl oder Fallback-Logik; öffentliche Begriffe bleiben Namespace, Topic, Subscription und Nachrichtentyp. +- `docs/setup.md`, `examples/` und die Deployment-Dateien werden gemeinsam mit den zugehörigen Änderungen im Repository gepflegt. Noch nicht implementierte PHP-APIs sind ausdrücklich als Entwurf zu kennzeichnen. +- Maßgeblicher Architekturentwurf: `docs/proposals/2026-09-12-message-queue-api.md`. Die Projektübersicht für Agenten steht in `.ai-usage-info.md`. diff --git a/README.md b/README.md index e2c1ff0..8c84042 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,64 @@ -# phore-project-template -Template Repository for phore library projects +# Phore Message Queue + +PhoreMQ ist der Entwurf einer frameworkunabhängigen PHP-Library für **RabbitMQ**. +Die erste Umsetzung enthält genau einen `RabbitMQConnector` hinter +`ConnectorInterface`. Es gibt keine Adapterregistrierung, Brokerauswahl oder +Fallback-Logik. Öffentliche Konfiguration verwendet die generischen Begriffe +Namespace, Topic, Subscription und Nachrichtentyp. + +**Status: Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und +Autoloading stammen aus der Projektvorlage; PHP >=8.5 ist verbindlich. Docker Compose und das PHP- +Setup sind unabhängig von der geplanten PHP-Library verwendbar. + +Vom Repository-Verzeichnis aus: + +```bash +docker compose -f deployment/rabbitmq/compose.yaml up -d --wait +php deployment/rabbitmq/setup.php +``` + +AMQP: `amqp://demo:demo@127.0.0.1:5672/demo`; Management: +`http://127.0.0.1:15672` mit `demo` / `demo`. Nur lokale Entwicklung. +Die PHP-Beispiele sind weiterhin API-Entwürfe. + +- [Architekturentscheidung und API-Proposal](docs/proposals/2026-09-12-message-queue-api.md) +- [Installation, Docker-Start und dynamische Topologie](docs/setup.md) +- [Gemeinsame Konfiguration](config/message-queue.json) +- [Message Queue Basics 101](docs/message-queue-basics-101.md) +- [Verbindung und zentrale Konfiguration](examples/api-draft/01-connect.php) +- [Programmatisch senden und empfangen](examples/api-draft/02-programmatic.php) +- [SDK-Typen und Handler mit Attributen](examples/api-draft/03-attributes.php) +- [ZIP-Dateien per verifizierter Speicherreferenz](examples/api-draft/04-files-and-local.php) +- [RPC mit Ergebnissen, Warnings und Remote-Exceptions](examples/api-draft/05-rpc.php) +- [Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) +- [Broadcast und Antworten aller erwarteten Teilnehmer](examples/api-draft/07-broadcast-locking.php) +- [Processing-Queue: ein Worker und ein Ergebnis](examples/api-draft/08-processing-workers.php) +- [Systemcheck und Dienststatus](examples/api-draft/09-system-check.php) +- [Callback-Fehler, Retry und Fehlerablage](examples/api-draft/10-callback-errors.php) + +Das zentrale Objekt wird einmal pro Prozess mit DSN oder Adapter und +`ConnectionOptions` erzeugt. `ConnectionFactory` bleibt ein einfacher +Konstruktor-Helfer. Alle Beispiele laden dieselbe Konfiguration über +`new PhoreMQ(...demoConnection())`; konkrete Verbindungsoptionen stehen in Beispiel 01. + +`publish($dto)` oder `publish($topic, $type, $payload)` sendet sofort. +`subscribe` registriert Events, `respond` Commands, `run` verarbeitet +Zustellungen. `publish(...)->await(timeoutSeconds: 5)` wartet optional auf eine +Antwort und wirft bei Ablauf `RequestTimeoutException`. `await` sendet nichts +noch einmal. Eigene freigegebene Remote-Exceptions sind möglich. + +`subscribe($callback)` und `#[Subscribe]` übernehmen die Metadaten des ersten +DTO-Parameters; widersprüchliche Angaben werden früh abgelehnt. Optional prüft +und hydriert `phore/schema` die lokale Struktur. Sender und Empfänger müssen +nicht dieselbe PHP-Klasse verwenden. Metadaten, HMAC-Signierung, Middleware und +verifizierte Dateireferenzen bleiben außerhalb der fachlichen Payload. + +**An alle:** eigene Subscription pro Empfänger. **An einen:** mehrere Worker +verwenden dieselbe Subscription. RabbitMQ verteilt Zustellungen; wiederholte +Verarbeitung bleibt möglich und benötigt fachliche Idempotenz. +`check()` prüft Verbindung und nachrichtenspezifische Bereitschaft. `HealthState` +meldet Dienstprobleme und Wiederherstellung im gemeinsamen Statusformat. +Ein Broker-Ping allein beweist keinen bereiten Handler. ## Git Submodules @@ -16,4 +75,16 @@ git submodule update --init --recursive git submodule update --remote --merge ``` +Projektregel: Ausführbare Beispiele und Setup-Skripte werden ausschließlich in PHP >=8.5 gepflegt; siehe [AGENTS.md](AGENTS.md). + +- [QueueOptions: Profile, Defaults, DTO-Attribute und Konflikte](examples/api-draft/11-queue-options.php) + +RPC-Beispiel 05 beschreibt getrennte Docker-Publisher/Worker, private Rückkanäle, +automatisches Entfernen nach Connection-Ende, Request-Zuordnung und persistente +Idempotenz bei Neustarts. QueueOptions ersetzen separate retryPolicy-Vorgaben; +Work/RPC verwenden standardmäßig vier Versuche mit jeweils zehn Sekunden Abstand. +Bestehende Queue-Contracts werden geprüft und bei Konflikt abgelehnt, nicht +zwischen Container-Versionen automatisch hin- und hergeschrieben. Alles bleibt +API-Entwurf; keine MQ-Runtime ist durch diese Beispiele implementiert. +[Anwendungsbeispiele mit Ablauf- und Objektübersicht](examples/api-draft/README.md): Sender/Worker getrennt, RPC über `request($dto)->await()`, Sendekonfiguration einheitlich in `PublishOptions`. Antwortfrist: `replyTimeoutSeconds` beim Versand; lokales Warten: `timeoutSeconds` bei `await`. diff --git a/composer.json b/composer.json index 2e808bc..658c814 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ] }, "require": { - "php" : ">=8.3", + "php" : ">=8.5", "ext-yaml": "*", "ext-json": "*" }, diff --git a/config/message-queue.json b/config/message-queue.json new file mode 100644 index 0000000..b122782 --- /dev/null +++ b/config/message-queue.json @@ -0,0 +1,36 @@ +{ + "connection": "amqp://demo:demo@127.0.0.1:5672/demo", + "options": { + "autoCreate": true, + "maxInFlight": 1, + "security": { + "mode": "unsigned" + }, + "rpc": { + "enabled": true, + "replyNamespace": "_phore.rpc" + }, + "managementUrl": "http://127.0.0.1:15672" + }, + "topics": [ + "users", + "telemetry" + ], + "subscriptions": [ + { + "topic": "users", + "name": "audit-users", + "type": "user.created.v1" + }, + { + "topic": "users", + "name": "billing-users", + "type": "user.created.v1" + }, + { + "topic": "telemetry", + "name": "audit-telemetry", + "type": null + } + ] +} diff --git a/deployment/rabbitmq/HOWTO.md b/deployment/rabbitmq/HOWTO.md new file mode 100644 index 0000000..805f939 --- /dev/null +++ b/deployment/rabbitmq/HOWTO.md @@ -0,0 +1,202 @@ +# RabbitMQ im internen Docker-Netz + +Dieses Beispiel startet RabbitMQ 4.3 ohne Management-Plugin und ohne Portfreigabe +am Host. Compose erstellt Netzwerk und Datenvolume; RabbitMQ initialisiert Benutzer, +Passwort und vHost beim ersten Start automatisch. Es braucht weder Webkonsole noch +manuelle rabbitmqctl-Befehle. Die bekannten Zugangsdaten sind Demo-Werte. + +## Start mit automatisch vorgegebenen Zugangsdaten + +Aus dem Repository-Verzeichnis: + +```bash +docker compose -p phore-internal -f deployment/rabbitmq/compose.internal.yaml up -d --wait +``` + +Dienste im selben Netzwerk verbinden sich über +`amqp://mq-demo:mq-demo-password@rabbitmq:5672/app`. +`rabbitmq` ist der Service-DNS-Name; feste IPs und localhost sind dafür falsch. +Der Healthcheck prüft den laufenden Broker, nicht die Bereitschaft fachlicher Handler. + +## Ohne Benutzername und Passwort im Client + +**Ja, mit RabbitMQ 4.3 und SASL ANONYMOUS.** Der Broker ordnet anonyme Clients +intern dem Benutzer aus anonymous_login_user/anonymous_login_pass zu. Es werden +also keine Client-Credentials übertragen, aber Berechtigungen gehören weiterhin +zu einer Broker-Identität. Alle anonymen Clients teilen diese Rechte; das ist keine +Identifikation einzelner Dienste. Diese Variante ist für das isolierte Testnetz. +[RabbitMQ Authentication](https://www.rabbitmq.com/docs/access-control#authentication-mechanisms) + +```bash +docker compose -p phore-anonymous -f deployment/rabbitmq/compose.internal.yaml -f deployment/rabbitmq/compose.anonymous.yaml up -d --wait +``` + +Die zweite Datei ersetzt den Wert von RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS. Sie aktiviert ANONYMOUS und +ordnet die Clients dem automatisch angelegten mq-demo-Benutzer zu. Der separate +Projektname gibt dieser Demo ein eigenes Netzwerk und Volume. Broker-Ziel für den +Client: Host rabbitmq, Port 5672, vHost app, Mechanismus ANONYMOUS; **keine** +Username-/Password-Felder. Eine URL ohne Credentials allein schaltet einen Client +nicht auf ANONYMOUS um: Viele Clients wählen sonst PLAIN mit Standardwerten. +Die PHP-AMQP-Clientbibliothek muss diesen Mechanismus ausdrücklich unterstützen. + +Die Einstellungen wurden zusätzlich am +[RabbitMQ-4.3.0-Mechanismus](https://github.com/rabbitmq/rabbitmq-server/blob/v4.3.0/deps/rabbit/src/rabbit_auth_mechanism_anonymous.erl) +und am [Konfigurationsschema](https://github.com/rabbitmq/rabbitmq-server/blob/v4.3.0/deps/rabbit/priv/schema/rabbit.schema) +abgeglichen. Kein guest-Remote-Login und kein leeres Passwort werden als Ersatz +für echte anonyme Anmeldung verwendet. + +## Einstellungen direkt als Compose-Parameter + +Das offizielle Image genügt. RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS reicht +Erlang-Anwendungsparameter an RabbitMQ weiter; eigene Variablen wie +RABBITMQ_ANONYMOUS_USER werden dafür nicht erfunden. +[Offizielles Image](https://hub.docker.com/_/rabbitmq), +[RabbitMQ Runtime-Parameter](https://www.rabbitmq.com/docs/runtime). + +```yaml +environment: + RABBITMQ_DEFAULT_USER: mq-demo + RABBITMQ_DEFAULT_PASS: mq-demo-password + RABBITMQ_DEFAULT_VHOST: app + RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: >- + -rabbit auth_mechanisms ['ANONYMOUS'] + -rabbit anonymous_login_user <<"mq-demo">> + -rabbit anonymous_login_pass <<"mq-demo-password">> +``` + +Dies ist der komplette Environment-Block für die anonyme Variante. Du kannst ihn +auch direkt in compose.internal.yaml einsetzen; dann brauchst du keine zweite +Compose-Datei. Netzwerk, Healthcheck und Datenvolume bleiben wie dort angegeben. +Der Standard-AMQP-Port ist bereits 5672, dafür ist kein zusätzlicher Parameter nötig. + +Die Syntax ist hier Erlang: ['ANONYMOUS'] ist eine Atom-Liste, <<"...">> ein Binary. +Anführungszeichen genau wie gezeigt übernehmen; keine zusätzliche Shell und keine +Backslash-Escapes ergänzen. `>-` verbindet YAML-Zeilen zu einem Parameterwert. +Die festen Demo-Werte enthalten keine Leerzeichen; beliebige dynamische Secrets +nicht ungeprüft in diese Argumentzeichenfolge interpolieren. +Nach Änderungen an environment den jeweiligen Startbefehl mit `up -d --wait` +erneut verwenden: ein bloßes `restart` übernimmt keine geänderte Container-Umgebung. +Die Einschränkungen zur Erstinitialisierung des Volumes gelten weiterhin. + +## In eine bestehende Compose-Anwendung übernehmen + +Übernimm den rabbitmq-Service, das mq-Netzwerk und das data-Volume aus +compose.internal.yaml in deine bestehende Datei. Alle Einstellungen stehen direkt +in Compose; zusätzliche .conf-Dateien oder Config-Mounts sind nicht erforderlich. +Ergänze bei den bestehenden Diensten: + +```yaml +services: + publisher: + # Vorhandene image/build/command-Angaben deines Dienstes bleiben hier stehen. + networks: [default, mq] + depends_on: + rabbitmq: + condition: service_healthy + subscriber: + # Vorhandene image/build/command-Angaben deines Workers bleiben hier stehen. + networks: [mq] + depends_on: + rabbitmq: + condition: service_healthy +networks: + default: {} + mq: + driver: bridge + internal: true +``` + +Dies ist ein Integrationsausschnitt, kein eigenständig startbares Publisher-Image. +Die MQ-Library und die fachlichen Container sind in diesem Repository noch nicht +implementiert. Übergib die oben genannte Verbindung über die vorhandene +Anwendungskonfiguration; Compose allein konfiguriert keine PHP-Library. +Benötigt der Worker externe APIs, erhält auch er zusätzlich ein geeignetes Netz. +internal isoliert das mq-Netz nach außen, ist aber keine Zugriffskontrolle zwischen +seinen Mitgliedern. Ein Dienst mit zwei Netzen kann selbst Verbindungen vermitteln. +[Docker Compose networks](https://docs.docker.com/reference/compose-file/networks/) + +Für zwei **getrennte Compose-Projekte** kann das zweite das vom ersten erzeugte +Netz nutzen, nachdem der Broker gestartet wurde: + +```yaml +networks: + mq: + external: true + name: phore-internal_mq +``` + +Für die anonyme Variante heißt es phore-anonymous_mq. Das externe Netz wird durch +das erste Projekt angelegt; external bedeutet „bereits vorhanden“, nicht +„öffentlich“. Beim externen Verweis kein zusätzliches internal/driver setzen. +Projektübergreifend gibt es kein depends_on: Clients brauchen Start-Retries. +Ohne manuelle Netzwerkerstellung ist die gemeinsame Compose-Datei am einfachsten. + +## Was bedeutet DEFAULT_VHOST? + +Ein vHost trennt Queues, Exchanges, Bindings und Berechtigungen logisch innerhalb +desselben Brokers. RABBITMQ_DEFAULT_VHOST=app legt beim ersten Start der leeren +Broker-Datenbank den vHost app an. Der initialisierte Default-Benutzer erhält dort +Zugriff. Ohne Anpassung heißt der RabbitMQ-Standard-vHost `/`. +[RabbitMQ Virtual Hosts](https://www.rabbitmq.com/docs/vhosts) + +Der Client wählt seinen vHost bei der Verbindung ausdrücklich: `/app` im DSN +bedeutet app; `/%2F` bedeutet den vHost mit Namen `/`. DEFAULT_VHOST leitet Clients +nicht automatisch um und erzeugt keine fachlichen Topics oder Subscriber. +Ein vHost ist weder Netzwerk noch eigener Port. Derselbe Port kann viele vHosts +bedienen. Alle zusammenarbeitenden Publisher und Subscriber müssen denselben wählen. + +## Welche Ports müssen freigegeben werden? + +| Verbindung | Einstellung in diesem Beispiel | +|---|---| +| Dienst → Broker im mq-Netz | rabbitmq:5672/TCP; kein ports oder expose nötig | +| Host oder Rechner im LAN → Broker | Keine Veröffentlichung, kein regulärer Zugang über einen Host-Port | +| Management-Webkonsole / HTTP-API | Nicht installiert, kein Listener auf 15672 | +| TLS-AMQP auf 5671 | Nicht konfiguriert; TLS benötigt eigene Zertifikats-/Listener-Konfiguration | +| Cluster-/CLI-Kommunikation 4369/25672 | Nicht veröffentlichen; ein Knoten, CLI per docker compose exec | + +ports würde einen Container-Port am Host veröffentlichen; expose dokumentiert +Container-Ports, ist aber keine Firewall und keine Voraussetzung für Kommunikation +im gemeinsamen Netz. „Lokales Netzwerk“ meint hier das Docker-Bridge-Netz auf +**einem** Docker-Host, nicht automatisch das LAN zwischen mehreren Hosts. +Soll später bewusst ein Hostzugang entstehen, wäre `127.0.0.1:5672:5672` ausschließlich +lokal. Für LAN-Zugriff wären Bind-Adresse, Firewall und Authentifizierung neu zu +konfigurieren; das anonyme Beispiel veröffentlicht absichtlich keinen Port. +[RabbitMQ Networking](https://www.rabbitmq.com/docs/networking) + +## Volume, Neustarts und Konfigurationsänderungen + +Das benannte Volume data wird unter /var/lib/rabbitmq eingebunden. Der feste +Hostname hält den RabbitMQ-Knotennamen bei Container-Neuerstellung stabil. +Dauerhafte Queues und persistente Nachrichten können dadurch Neustarts überstehen; +flüchtige RPC-Rückkanäle bleiben flüchtig. Ein einzelner Knoten bietet keine HA. + +```bash +docker compose -p phore-internal -f deployment/rabbitmq/compose.internal.yaml restart +docker compose -p phore-internal -f deployment/rabbitmq/compose.internal.yaml exec rabbitmq rabbitmqctl list_vhosts +docker compose -p phore-internal -f deployment/rabbitmq/compose.internal.yaml exec rabbitmq rabbitmqctl list_queues -p app name messages consumers +``` + +DEFAULT_USER/PASS/VHOST initialisieren ausschließlich eine leere Datenbank. Änderungen +an diesen Variablen migrieren ein bestehendes Volume nicht. Beim anonymen Modus +müssen die internen anonymous_login-Werte zum gespeicherten Benutzer passen. +`down` erhält das Volume, `down -v` löscht es mitsamt Nachrichten und Einstellungen. +Für das anonyme Projekt bei Verwaltungsbefehlen denselben Projektnamen und beide +Compose-Dateien wie beim Start verwenden. + +## Grenze zum aktuellen PhoreMQ-Entwurf + +RabbitMQ kann Topics/Queues/Bindings über AMQP ohne Management-Plugin anlegen. +Die geplante PhoreMQ-Library verlangt für ihre **vollständige** Topologieprüfung +jedoch derzeit eine Management-HTTP-API über managementUrl. Diese Broker-Demo +ist deshalb noch kein vollständig kompatibles PhoreMQ-End-to-End-Deployment: +ohne diese Prüfmöglichkeit ist TopologyVerificationException vorgesehen. +Auch ein expliziter ANONYMOUS-Clientmodus ist in der bisherigen PhoreMQ-API noch +nicht festgelegt. Dieses Deployment ändert diese Verträge nicht stillschweigend. + +Für den bisherigen Entwurf bleibt compose.yaml mit Management-Plugin verwendbar; +für rein interne Nutzung können dort die ports-Einträge entfallen, während die API +im Container-Netz erreichbar bleibt. Eine manuelle Bedienung der Konsole ist nicht +nötig. setup.php ist ein separates Host-Werkzeug für diese Management-Demo und wird +im neuen internen Beispiel nicht ausgeführt. Fachliche Topologie ist dadurch beim +Brokerstart noch nicht angelegt; das übernimmt später der autorisierte Listener. diff --git a/deployment/rabbitmq/compose.anonymous.yaml b/deployment/rabbitmq/compose.anonymous.yaml new file mode 100644 index 0000000..60ddb26 --- /dev/null +++ b/deployment/rabbitmq/compose.anonymous.yaml @@ -0,0 +1,11 @@ +# NUR zusammen mit compose.internal.yaml verwenden; HOWTO.md zeigt den Start. +# Ersetzt den gesamten Parameterwert aus der Basis-Compose, kein Config-Mount. +services: + rabbitmq: + environment: + RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: >- + -rabbit auth_mechanisms ['ANONYMOUS'] + -rabbit anonymous_login_user <<"mq-demo">> + -rabbit anonymous_login_pass <<"mq-demo-password">> + # AMQP-Client: SASL ANONYMOUS wählen, keinen Benutzer/Passwort übergeben. + # Interne Identität muss zum initialisierten Benutzer aus der Basis passen. diff --git a/deployment/rabbitmq/compose.internal.yaml b/deployment/rabbitmq/compose.internal.yaml new file mode 100644 index 0000000..8f2d382 --- /dev/null +++ b/deployment/rabbitmq/compose.internal.yaml @@ -0,0 +1,34 @@ +# Eigenständiges Beispiel: nicht mit compose.yaml (Management-Demo) kombinieren. +# Start aus dem Repo: docker compose -f deployment/rabbitmq/compose.internal.yaml up -d --wait +services: + rabbitmq: + image: rabbitmq:4.3 + hostname: phore-mq-internal + restart: unless-stopped + environment: + # Automatische Erstinitialisierung; bekannte Werte nur für diese interne Demo. + RABBITMQ_DEFAULT_USER: mq-demo + RABBITMQ_DEFAULT_PASS: mq-demo-password + RABBITMQ_DEFAULT_VHOST: app + # Erlang-Anwendungsparameter direkt im offiziellen Image; keine .conf-Datei. + RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: >- + -rabbit auth_mechanisms ['PLAIN'] + -rabbit anonymous_login_user none + volumes: + - data:/var/lib/rabbitmq + networks: + - mq + # Keine ports: Host/LAN erhalten keinen veröffentlichten Broker-Port. + # expose ist ebenfalls nicht erforderlich: Dienste im Netz erreichen rabbitmq:5672. + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 10s +networks: + mq: + driver: bridge + internal: true +volumes: + data: diff --git a/deployment/rabbitmq/compose.yaml b/deployment/rabbitmq/compose.yaml new file mode 100644 index 0000000..51c4679 --- /dev/null +++ b/deployment/rabbitmq/compose.yaml @@ -0,0 +1,22 @@ +# Nur lokale Entwicklung: bekannte Demo-Zugangsdaten, Ports nur auf Loopback. +services: + rabbitmq: + image: rabbitmq:4.3-management + hostname: phore-mq-demo + environment: + RABBITMQ_DEFAULT_USER: demo + RABBITMQ_DEFAULT_PASS: demo + RABBITMQ_DEFAULT_VHOST: demo + ports: + - "127.0.0.1:5672:5672" + - "127.0.0.1:15672:15672" + volumes: + - data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 10s +volumes: + data: diff --git a/deployment/rabbitmq/setup.php b/deployment/rabbitmq/setup.php new file mode 100644 index 0000000..22ae7fb --- /dev/null +++ b/deployment/rabbitmq/setup.php @@ -0,0 +1,173 @@ += 8.5.'); +} + +/** @return array{array, array} Verbindung und vollständig validierte Deklarationen. */ +function declarations(#[\SensitiveParameter] array $config): array +{ + $keys = array_keys($config); + sort($keys); + if ($keys !== ['connection', 'options', 'subscriptions', 'topics'] + || !is_string($config['connection']) + || !is_array($config['topics']) || !array_is_list($config['topics']) + || !is_array($config['subscriptions']) || !array_is_list($config['subscriptions'])) { + throw new \InvalidArgumentException('Erwartet: connection, options, topics und subscriptions.'); + } + $validName = static fn (mixed $name): bool => is_string($name) + && preg_match('/\A[a-zA-Z_][a-zA-Z0-9_.-]{0,99}\z/', $name) === 1; + $topics = $config['topics']; + foreach ($topics as $topic) { + if (!$validName($topic) || str_starts_with($topic, '_phore')) { + throw new \InvalidArgumentException('Ungültiger oder reservierter Topic-Name.'); + } + } + if (count(array_unique($topics)) !== count($topics)) { + throw new \InvalidArgumentException('Doppeltes Topic.'); + } + $connection = parse_url($config['connection']); + if ($connection === false || ($connection['scheme'] ?? null) !== 'amqp' + || !in_array($connection['host'] ?? null, ['127.0.0.1', 'localhost'], true) + || ($connection['port'] ?? null) !== 5672 + || isset($connection['query']) || isset($connection['fragment']) + || ($connection['user'] ?? '') === '' || !isset($connection['pass']) + || !str_starts_with($connection['path'] ?? '', '/') || strlen($connection['path']) < 2) { + throw new \InvalidArgumentException('Lokale AMQP-DSN mit Port 5672, Credentials und Namespace erforderlich.'); + } + $vhost = rawurlencode(rawurldecode(substr($connection['path'], 1))); + $actions = []; + foreach ($topics as $topic) { + $actions[] = ['PUT', "exchanges/$vhost/" . rawurlencode('phore.topic:' . $topic), [ + 'type' => 'topic', 'durable' => true, 'auto_delete' => false, + 'internal' => false, 'arguments' => (object) [], + ]]; + } + $seen = []; + foreach ($config['subscriptions'] as $sub) { + if (!is_array($sub)) { + throw new \InvalidArgumentException('Subscription muss ein Objekt sein.'); + } + $keys = array_keys($sub); + sort($keys); + if ($keys !== ['name', 'topic', 'type']) { + throw new \InvalidArgumentException('Subscription benötigt topic, name und type (null für alle).'); + } + ['topic' => $topic, 'name' => $name, 'type' => $type] = $sub; + if (!in_array($topic, $topics, true) || !$validName($name) + || ($type !== null && !$validName($type))) { + throw new \InvalidArgumentException('Ungültige Subscription, unbekanntes Topic oder ungültiger Typfilter.'); + } + $id = "$topic:$name"; + if (isset($seen[$id])) { + throw new \InvalidArgumentException('Doppelte Subscription.'); + } + $seen[$id] = true; + $queue = "phore.sub:$id"; + $failure = "phore.failure:$id"; + $actions[] = ['PUT', "queues/$vhost/" . rawurlencode($failure), [ + 'durable' => true, 'auto_delete' => false, 'arguments' => ['x-queue-type' => 'quorum'], + ]]; + // Fehler gehen ausschließlich an diese Subscription, niemals erneut als Fan-out. + $actions[] = ['PUT', "queues/$vhost/" . rawurlencode($queue), [ + 'durable' => true, 'auto_delete' => false, 'arguments' => [ + 'x-queue-type' => 'quorum', 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => $failure, 'x-overflow' => 'reject-publish', + 'x-dead-letter-strategy' => 'at-least-once', 'x-delivery-limit' => -1, + ], + ]]; + $actions[] = ['POST', "bindings/$vhost/e/" . rawurlencode('phore.topic:' . $topic) + . '/q/' . rawurlencode($queue), ['routing_key' => $type ?? '#', 'arguments' => (object) []]]; + } + return [$connection, $actions]; +} + +function managementRequest(string $method, string $path, #[\SensitiveParameter] string $authorization, ?array $body = null): string +{ + $context = stream_context_create(['http' => [ + 'method' => $method, + 'header' => "Authorization: Basic $authorization\r\nContent-Type: application/json\r\nConnection: close\r\n", + 'content' => $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR), + 'timeout' => 10, + 'ignore_errors' => true, + 'follow_location' => 0, + 'max_redirects' => 0, + ]]); + // Sicherheitsgrenze: festes Loopback-Ziel, keine Redirects und keine URL aus Payloads. + // Keine Credentials in URLs oder Fehlermeldungen; Antwortgröße ist begrenzt. + http_clear_last_response_headers(); + $result = @file_get_contents('http://127.0.0.1:15672/api/' . $path, false, $context, 0, 1048577); + $headers = http_get_last_response_headers() ?? []; + if ($result === false) { + throw new \RuntimeException('Management-Verbindung fehlgeschlagen; Broker und allow_url_fopen prüfen.'); + } + if (strlen($result) > 1048576) { + throw new \RuntimeException('Management-Antwort überschreitet das Größenlimit.'); + } + $status = 0; + foreach ($headers as $header) { + if (preg_match('/\AHTTP\/\S+\s+(\d{3})\b/', $header, $match) === 1) { + $status = (int) $match[1]; + } + } + if ($status < 200 || $status >= 300) { + throw new \RuntimeException("RabbitMQ Management HTTP $status; Bereitschaft, Rechte und Deklarationskonflikte prüfen."); + } + return $result; +} + +function main(array $arguments): void +{ + $path = __DIR__ . '/../../config/message-queue.json'; + $dryRun = false; + for ($i = 0; $i < count($arguments); $i++) { + if ($arguments[$i] === '--dry-run') { + $dryRun = true; + } elseif ($arguments[$i] === '--config' && isset($arguments[$i + 1])) { + $path = $arguments[++$i]; + } else { + throw new \InvalidArgumentException('Aufruf: php setup.php [--config DATEI] [--dry-run]'); + } + } + $json = @file_get_contents($path); + if ($json === false) { + throw new \RuntimeException('Konfigurationsdatei konnte nicht gelesen werden.'); + } + $config = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($config)) { + throw new \InvalidArgumentException('Konfiguration muss ein JSON-Objekt sein.'); + } + [$connection, $actions] = declarations($config); // Alles vor dem ersten Schreibzugriff prüfen. + if ($dryRun) { + echo json_encode($actions, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n"; + return; // Dry Run enthält weder DSN noch Zugangsdaten. + } + $authorization = base64_encode(rawurldecode($connection['user']) . ':' . rawurldecode($connection['pass'])); + foreach ($actions as [$method, $resource, $body]) { + if ($method === 'POST') { + $bindings = json_decode(managementRequest('GET', $resource, $authorization), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($bindings) || !array_is_list($bindings)) { + throw new \RuntimeException('Ungültige Binding-Antwort.'); + } + foreach ($bindings as $binding) { + if (!is_array($binding) || ($binding['routing_key'] ?? null) !== $body['routing_key'] + || ($binding['arguments'] ?? null) !== []) { + throw new \RuntimeException('Bestehender Typfilter weicht ab; neue Subscription oder explizite Migration erforderlich.'); + } + } + } + managementRequest($method, $resource, $authorization, $body); + } + printf("Topologie bereit: %d Topics, %d Subscriptions\n", count($config['topics']), count($config['subscriptions'])); +} + +// Ungefangene Exception liefert dem CLI einen Fehlerstatus; keine stillen Fehler. +if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + main(array_slice($argv, 1)); +} diff --git a/docs/message-queue-basics-101.md b/docs/message-queue-basics-101.md new file mode 100644 index 0000000..4a2cbb0 --- /dev/null +++ b/docs/message-queue-basics-101.md @@ -0,0 +1,204 @@ +# Message Queue Basics 101: Vom Senden zur Verarbeitung + +Ein Worker kann einen Auftrag fertig bearbeiten und trotzdem dieselbe Nachricht noch einmal erhalten. +Stell dir einen Export vor: Die ZIP-Datei ist fertig, dann stürzt der Worker ab — unmittelbar bevor er der Queue seinen Erfolg bestätigt. Die Queue weiß jetzt nur, dass ihr eine Bestätigung fehlt. Soll sie den Auftrag erneut zustellen oder riskieren, dass er verloren geht? + +Dieser kleine Abstand zwischen „erledigt“ und „bestätigt“ erklärt viele Entscheidungen beim Einsatz einer Message Queue. Ein Broker vermittelt Nachrichten und kann sie aufbewahren. Er kann aber nicht allein garantieren, dass ein externer Arbeitsschritt genau einmal ausgeführt wird. Publisher-Bestätigung und Consumer-Bestätigung betreffen unterschiedliche Schritte. [RabbitMQ: Bestätigungen](https://www.rabbitmq.com/docs/confirms) + +**Stand: 12. September 2026. Dieses Paket ist ein API-Entwurf. RabbitMQ ist als einziger Adapter vorgesehen und noch nicht implementiert.** Die Brokerfunktionen existieren unabhängig davon; die folgenden Zuordnungen beschreiben ihre geplante Nutzung durch PhoreMQ. + +## Die Begriffe an einem Beispiel + +Ein Benutzer wurde angelegt. Der Maildienst soll eine Begrüßung versenden, der Auditdienst den Vorgang protokollieren. Beide benötigen dieselbe Nachricht. Drei Mail-Worker sollen sich dagegen die Mailaufträge teilen. + +| Begriff | Bedeutung im Beispiel | +|---|---| +| Producer / Publisher | Die Anwendung, die das Ereignis veröffentlicht | +| Message / Payload | Die Nachricht und ihre fachlichen Daten, etwa Benutzer-ID und E-Mail | +| Broker | Die vermittelnde Infrastruktur, in diesem Paket RabbitMQ | +| Topic | Der logische Kanal `users` in diesem Paket | +| Message type | Der fachliche Vertrag `user.created.v1`; ein Topic kann mehrere Typen tragen | +| Subscription | Ein benanntes Abonnement für die Nachrichten eines Dienstes: `mail-users` oder `audit-users` | +| Consumer / Worker | Ein laufender Prozess, der Nachrichten seiner Subscription verarbeitet | +| Subject / Routing key | Brokerabhängige Routingbegriffe; keine zusätzlichen Pflichtargumente der PhoreMQ-API | +| Envelope | Payload plus technische Angaben wie ID, Typ, Korrelation und Signatur | + +Ein Topic ist hier der logische Kanal, ein Subject die Routingbezeichnung eines Nachrichtentyps. Die API verwendet dafür nur `type`. RabbitMQ bildet Topic und Subscription auf Exchange und Queue ab; der Typ wird zum Routing Key. Ein Subject ist keine separat anzulegende Ressource. Die konkrete Zuordnung und den Docker-Start zeigt der [Setup-Guide](setup.md). + +## Eine Nachricht für alle — oder Arbeit für einen + +Im PhoreMQ-Entwurf bestimmt die Subscription die Verteilung: + +- **Unterschiedliche Subscriptions:** `mail-users` und `audit-users` erhalten jeweils eine Zustellung. Das ist Fan-out. +- **Dieselbe Subscription:** Drei Worker von `mail-users` konkurrieren um deren Zustellungen. Pro Zustellversuch übernimmt einer die Arbeit. + +„Nur einer“ meint hier einen Worker, nicht einen Broker. Mehrere Brokerknoten können gemeinsam die Infrastruktur bilden und Daten replizieren. Daraus folgt nicht, dass die Anwendung denselben Job mehrfach bearbeiten soll. + +Bei Lastverteilung gewinnt ein verfügbarer Worker nach den Zustellregeln von RabbitMQ. Gleichmäßiger Zufall ist nicht garantiert. Mehr Worker erlauben parallele Verarbeitung, verändern aber die Reihenfolge der Fertigstellung. Nach einem Verbindungsabbruch können sogar zwei Versuche zeitweise überlappen: Ein alter Worker arbeitet weiter, während ein anderer übernimmt. [RabbitMQ: Consumers](https://www.rabbitmq.com/docs/consumers) + +## Was eine Zustellgarantie tatsächlich umfasst + +Der geplante Normalfall besteht aus vier Schritten: + +1. `publish()` sendet. Der Broker bestätigt seine Annahme; daraus entsteht `SendResult::receipt`. +2. Ein Worker erhält eine Zustellung, die zunächst als offen gilt. +3. Der Handler verarbeitet die Nachricht. Erst nach erfolgreicher Rückkehr erfolgt standardmäßig die Verarbeitungsbestätigung (Ack). +4. Fehlt die Bestätigung, wird der Auftrag nach Verbindungsabbruch oder gemäß Retry-Policy wieder verfügbar. Nach endgültigem Scheitern bleibt er in einer Fehlerablage. Broker verwenden dafür häufig eine Dead-Letter Queue (DLQ); die Library kann auch eine eigene Ablage nutzen. + +**At least once** sagt unter den vereinbarten Aufbewahrungs- und Verfügbarkeitsbedingungen mindestens eine Zustellung an einen zuständigen Consumer zu. Solange dessen Bestätigung fehlt, sind weitere Zustellversuche möglich; Retry-Grenzen und endgültige Fehlerablage bestimmen, wann diese enden. Es ist keine unbegrenzte Erfolgsgarantie. **At most once** vermeidet Wiederholung, kann dafür Nachrichten verlieren. **Exactly once** für einen vollständigen Geschäftsprozess entsteht nicht allein durch Queue-Einstellungen. + +Für den Export vom Einstieg hilft deshalb eine stabile Auftrags-ID: Der Worker erkennt einen bereits abgeschlossenen Export und verwendet dessen Ergebnis erneut. Das heißt *Idempotenz*. Ein Datenbankeintrag und ein Publish lassen sich bei Bedarf über eine Outbox koordinieren; das bleibt eine Anwendungsintegration, keine implizite Transaktion dieser Library. Auch Broker-Deduplizierung ersetzt diese Prüfung externer Seiteneffekte nicht. [RabbitMQ: Zuverlässigkeit](https://www.rabbitmq.com/docs/reliability) + +## Vier Zeitgrenzen, vier verschiedene Fragen + +| Grenze | Was sie beantwortet | +|---|---| +| **Retention** | Wie lange hält die Infrastruktur die Nachricht überhaupt vor? Zeit-, Größen- oder Längenlimits können sie entfernen | +| **TTL / Ablaufzeit** | Bis wann ist diese Nachricht noch gültig? Abgelaufene Nachrichten sind keine beliebig später ausführbaren Jobs | +| **Ack-Frist** | Wann beendet RabbitMQ einen Consumer-Channel wegen ausbleibender Bestätigung? Das ist keine pro Nachricht verlängerbare Lease. | +| **RPC-Wartefrist** | Wie lange wartet dieser Aufrufer auf eine Antwort? Ablauf stoppt die entfernte Arbeit nicht | + +## RabbitMQ als einzige Umsetzung + +Die Architektur sieht einen RabbitMQ-Adapter hinter `ConnectorInterface` vor. Eine Laufzeit-Auswahl oder Registrierung weiterer Adapter gehört nicht dazu. Die öffentliche API verwendet weiterhin Topic, Subscription und Nachrichtentyp; der Adapter kapselt das konkrete Protokoll. + +Dauerhafte fachliche Subscriptions werden als Quorum Queues angelegt. Publisher Confirms bestätigen die Annahme; Consumer-Acks schließen die Verarbeitung ab. Mehrere Worker einer Queue teilen deren Arbeit. Die einzelne Docker-Instanz aus dem Setup besitzt keine Ausfallredundanz, auch wenn sie denselben Queue-Typ nutzt. [RabbitMQ Quorum Queues](https://www.rabbitmq.com/docs/quorum-queues) + +## Aufbewahrung passend zur Anwendung wählen + +Ein erfolgreiches Ack entfernt die Nachricht aus ihrer Subscription. Ohne Ack bleibt sie bis zur Verarbeitung, expliziten Entfernung oder einem konfigurierten Ablauf-/Größenlimit erhalten. Die Demo setzt keine automatische Nachrichten-TTL und ist kein dauerhaftes Ereignisarchiv. Die Fehlerablage benötigt ebenfalls eine bewusste Bereinigung. [RabbitMQ TTL](https://www.rabbitmq.com/docs/ttl) + +Eine neue Subscription erhält nur Nachrichten ab ihrer Bindung, keine früheren Ereignisse. Ein Worker-Neustart verwendet dagegen den bestehenden Rückstand. Bei ZIP-Dateien muss zusätzlich der referenzierte Dateispeicher lang genug verfügbar bleiben. + +## Was der Anwendungsentwickler konfiguriert + +Verbindung und gemeinsame Vorgaben stehen in [config/message-queue.json](../config/message-queue.json). Der [Setup-Guide](setup.md) erklärt die bereits nutzbare Docker-Instanz und das PHP-Skript. Die PHP-Beispiele verwenden nach Implementierung: + +```php +$mq = new PhoreMQ(...demoConnection()); +``` + +Die Hilfsfunktion lädt ausdrücklich die gemeinsame Datei; sie gehört nur zu den Beispielen. Die Demo erlaubt mit `autoCreate` dynamische Topics und Subscriptions und wählt bewusst einen unsignierten lokalen Testmodus. Produktionskonfiguration verwendet eigene Zugangsdaten, TLS und eine passende Security-Policy. Ein Dateispeicher wird separat injiziert. + +Ein Prozess kann mehrere Handler registrieren. `run(maxMessages: 100, maxSeconds: 30)` begrenzt Zustellversuche und Gesamtlaufzeit, garantiert aber keine Zahl erfolgreicher Jobs. Synchrone Handler laufen nacheinander. Mehrere Prozesse derselben Subscription ermöglichen Parallelität; `maxInFlight` begrenzt vorgeholte offene Zustellungen pro Consumer. Lange Jobs brauchen passende Ack-Fristen und laufende Heartbeat-Verarbeitung. [Worker-Beispiel](../examples/api-draft/02-programmatic.php) + +## Wie eine Antwort zum richtigen Aufrufer zurückkommt + +Zwei Frontend-Prozesse bestellen gleichzeitig einen Export. Beide senden +`export.create.v1`. Der Nachrichtentyp allein kann ihre Antworten deshalb nicht +zuordnen. Im geplanten RPC-Protokoll werden zwei andere Angaben kombiniert: +eine eindeutige **Request-ID pro Aufruf** und ein **Reply-Ziel pro Client-Instanz**. + +| Aufrufer | Gesendeter Typ | Request-ID | Eigener Rückkanal | +|---|---|---|---| +| Client A | `export.create.v1` | `request-A-17` | `replies.client-A` | +| Client B | `export.create.v1` | `request-B-42` | `replies.client-B` | + +Die Namen und IDs in der Tabelle sind verkürzte Beispiele. Vor dem Senden +legt die Library den Rückkanal an bzw. bestätigt seine Bindung und registriert +die Request-ID lokal. Der Worker übernimmt das erlaubte Reply-Ziel aus dem +Request und setzt dessen Request-ID in seine Antwort. Client A empfängt die Antwort über seinen Rückkanal und ordnet sie anhand der Request-ID dem richtigen offenen Aufruf zu. Antwortet der Worker sehr schnell, ist die Zuordnung +bereits vorbereitet — sie entsteht nicht erst beim späteren `await()`. + +**Zwei unabhängige Clients dürfen nicht dieselbe konkurrierende Reply-Subscription +benutzen.** Sonst könnte A die Antwort für B abholen. Ein geteilter Rückkanal +benötigt stattdessen einen bewusst eingerichteten zentralen Verteiler, der alle +Aufrufe kennt. Die explizit aktivierte RPC-Konfiguration erzeugt deshalb +eigene Reply-Endpunkte je MQ-Instanz im reservierten RabbitMQ-Bereich. Die +Berechtigungen dafür werden beim Deployment festgelegt. Die Request-ID verknüpft Antworten; Signaturen, +zugelassene Reply-Ziele und verifizierte Identitäten schützen zusätzlich vor +fremden Antworten. Eine erratene oder kopierte ID ist keine Berechtigung. + +Doppelte Antworten werden anhand ihrer Zuordnung erkannt; nur die erste +passende verifizierte finale Antwort beendet den Aufruf. Fremde oder zu spät +eingetroffene Antworten dürfen keinen anderen Aufruf erfüllen. Ein nachträgliches +`await()` sendet den Auftrag nicht erneut. Eine fachliche `correlationId`, etwa +für einen gesamten Bestellvorgang, kann dagegen mehrere Requests verbinden und +ersetzt deshalb nicht die eindeutige Request-ID. Das sind Regeln des +[geplanten RPC-Vertrags](proposals/2026-09-12-message-queue-api.md), +keine automatische Eigenschaft eines Nachrichtentypnamens. + +## Wie derselbe Auftrag ohne doppelte Wirkung wiederholt werden kann + +Eine gemeinsame Worker-Gruppe verteilt normalerweise jeden Zustellversuch +an einen Worker. Ein Ack sagt der Queue anschließend, dass dieser Versuch +abgeschlossen ist. Beides beantwortet noch nicht, ob eine Wiederholung einen +zweiten Export oder eine zweite Abbuchung auslösen würde. + +Für eine dauerhaft gespeicherte Geschäftsoperation braucht die Anwendung eine +stabile **Idempotenz-ID pro fachlichem Auftrag**. Wiederholt der Aufrufer denselben +Auftrag nach einem Timeout, muss er dieselbe fachliche ID verwenden. Eine neu +erzeugte Message-ID bei jedem Publish erkennt solche Wiederholungen nicht. +Die Anwendung kann diese ID im Command als `operationId` mitgeben; sie ist von +der Request-ID des einzelnen RPC-Aufrufs getrennt. Die ID gilt innerhalb eines +klaren Mandanten-/Command-Bereichs; dieselbe ID mit anderen Parametern ist ein +Konflikt und darf nicht still das frühere Ergebnis liefern. + +Ein mögliches Verfahren innerhalb einer Datenbank sieht so aus: + +1. Ein eindeutiger Datenbankindex verhindert, dass zwei Worker denselben + Auftrag unabhängig als neu anlegen. +2. Fachliche Änderung und gespeichertes Ergebnis werden in derselben + Transaktion festgeschrieben. Ein bloßes „gesehen“-Flag vor der Arbeit wäre + zu früh: Nach einem Crash könnte es einen unerledigten Auftrag sperren. +3. Bei einer Wiederholung wird das fertige Ergebnis geladen und zurückgegeben, + ohne die Geschäftsoperation erneut auszuführen. Das gespeicherte fachliche + Ergebnis wird dabei in eine neue Antwort mit der aktuellen Request-ID und + dem aktuellen erlaubten Reply-Ziel verpackt. Auch ein bereits laufender + Auftrag braucht eine definierte Warte-/Konflikt- und Recovery-Regel. +4. Die Queue erhält ihr Ack erst nach der erforderlichen Verarbeitung und, + bei RPC, nach bestätigtem Antwort-Publish. Ein verlorenes Ack kann trotzdem + eine erneute Zustellung auslösen; diese findet nun das gespeicherte Ergebnis. + +Das schützt nur die Wirkungen innerhalb der gewählten Transaktionsgrenze. +Eine externe Zahlung oder E-Mail ist nicht automatisch Teil der lokalen +Datenbanktransaktion. Dafür braucht es eine passende Schnittstelle des Zielsystems, +beispielsweise einen Idempotenzschlüssel beim Zahlungsanbieter. Bei Leases können +zusätzlich **Fencing-Tokens** nötig sein: Die schreibende Ressource lehnt einen +veralteten Worker anhand einer monotonen Berechtigungsnummer ab. Ein lokales +„schon gesehen“-Set im PHP-Prozess überlebt keinen Neustart und koordiniert keine +anderen Prozesse. + +Der Entwurf verspricht daher wiederholbare Zustellung und Erweiterungspunkte +für Idempotenz beziehungsweise Inbox/Outbox, aber keine universelle Exactly-once- +Verarbeitung. Für den ZIP-Export kann die Anwendung etwa den fertigen Export +unter seiner Auftrags-ID wiederfinden; wie sie Datei und Ergebnisstatus +crashsicher zusammenführt, muss sie ausdrücklich festlegen. +[Entwurfsgrenzen und Idempotenz](proposals/2026-09-12-message-queue-api.md), +[RabbitMQ: Redelivery und Idempotenz](https://www.rabbitmq.com/docs/reliability) + +## Wie PhoreMQ darauf aufbaut + +`publish($dto)` übernimmt Topic und Typ aus lokal hinterlegten Metadaten; alternativ werden Topic, Typ und Payload ausdrücklich angegeben. `subscribe($callback)` kann das Mapping des ersten DTO-Parameters verwenden. Werden Topic, Typ oder Subscription sowohl ausdrücklich angegeben als auch in lokalen DTO-Metadaten festgelegt, müssen die jeweiligen Angaben übereinstimmen. Die PHP-Klasse des Senders muss nicht dieselbe wie beim Empfänger sein: Der fachliche Typname und die strukturelle Kompatibilität sind entscheidend. + +RPC (Remote Procedure Call) ist ein entfernter Funktionsaufruf mit Antwort und verwendet hier denselben Transport. `publish($command)->await(timeoutSeconds: 5)` wartet auf ein Responder-Ergebnis. Ohne rechtzeitige Antwort wirft es `RequestTimeoutException`; eine zur Übertragung freigegebene fachliche Fehlerantwort wird dagegen als `RemoteCommandException` oder ausdrücklich zugeordnete eigene Exception geworfen. `await()` sendet nichts erneut. [RPC-Beispiel](../examples/api-draft/05-rpc.php) + +Bei Callback-Exceptions sieht der Entwurf begrenzte Wiederholungen mit wachsender Wartezeit (Backoff) und danach die Speicherung in einer Fehlerablage vor. Eine ausdrücklich endgültige Ablehnung wird nicht erneut versucht. Scheitert die Speicherung in der Fehlerablage, wird die ursprüngliche Nachricht nicht bestätigt. [Fehlerbeispiel](../examples/api-draft/10-callback-errors.php) + +`check()` unterscheidet Verbindung und nachrichtenspezifische Bereitschaft. Ein erreichbarer Broker beweist noch keinen bereiten Handler; ein fehlendes Ping-Reply beweist nicht die Abwesenheit eines Listeners. Statusmeldungen haben deshalb Quellen und Ablaufzeiten. [Systemcheck](../examples/api-draft/09-system-check.php) + +Nicht universell zugesagt werden Exactly-once-Seiteneffekte, globale Reihenfolge, Prioritäten, Replay oder verteilte Locks. Unbekannte Optionen, widersprüchliche Topologie und nicht routbare Nachrichten sollen früh mit aussagekräftigen Exceptions auffallen. Für den Export bedeutet das: Die Queue kann einen verlorenen Zustellversuch ersetzen. Ob eine ZIP-Datei bereits erfolgreich erstellt wurde, muss die Anwendung weiterhin zuverlässig erkennen. + +## RPC nach einem Container-Neustart + +Zwei Publisher senden denselben Command-Typ. Wer bekommt welche Antwort? +Jede Verbindung besitzt ein eigenes privates Antwortziel; die Request-ID trennt +die einzelnen Aufrufe darin. Der Sender richtet Queue, Binding und Consumer vor +dem Versand ein. await verarbeitet dann die eingehenden Antworten. Der Typ allein +reicht für diese Zuordnung nicht aus. + +Stirbt der Publisher, verschwindet sein Rückkanal nach erkanntem Verbindungsabbruch. +Der neue Container beginnt mit einem neuen Rückkanal. Ein Timeout beweist deshalb +nicht, dass die Arbeit fehlgeschlagen ist: Sie kann bereits erledigt sein und nur +die Antwort fehlen. Für einen neuen Aufruf derselben Geschäftsoperation bleibt +die extern gespeicherte operationId gleich, während requestId und replyTo neu +sind. Der Worker kann das gespeicherte Ergebnis zurückgeben. Die Transaktion, +die Geschäftswirkung und Ergebnis zusammen schützt, gehört zur Anwendung. +[PHP-RPC-Grundprinzip](https://www.rabbitmq.com/tutorials/tutorial-six-php) + +Die geplanten QueueOptions unterscheiden dauerhafte Work-/RPC-Queues und +flüchtigen Broadcast. Broadcast mit positiver Retention speichert für bereits +angelegte Empfängergruppen; es liefert keine Historie an später neue Gruppen. +Ein Ack entfernt die jeweilige Kopie früher. Detaillierter Ablauf mit Kommentaren: +[RPC-Beispiel](../examples/api-draft/05-rpc.php), +[Profile und Konflikte](../examples/api-draft/11-queue-options.php). diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md new file mode 100644 index 0000000..2a212f8 --- /dev/null +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -0,0 +1,1869 @@ +# Phore Message Queue: API- und Architekturentwurf + +| Datum | Benutzername | Kurzbeschreibung | +|---|---|---| +| 2026-09-12 | dermatthes | §§ 1–12: Proposal mit API-Beispielen, Konnektorvergleich und Paketgrenzen angelegt | +| 2026-09-12 | dermatthes | §§ 1, 3, 5, 8, 11–14: Kleine explizite API, RPC, Begleitmeldungen, Metadaten und Middleware nach Frameworkvergleich ergänzt | +| 2026-09-12 | dermatthes | §§ 2, 12, 13.2, 15: Broadcast mit allen Lock-Antworten und Processing-Queue mit konkurrierenden Workern ergänzt | +| 2026-09-12 | dermatthes | §§ 1.1, 16: Standardisierte Systemchecks, deklarierte Nachrichtenabhängigkeiten, Listenerdiagnose und Frontend-/Monitoring-Anbindung ergänzt | +| 2026-09-12 | dermatthes | §§ 1, 1.1, 3, 4: PhoreMQ als zentrales Objekt mit DSN-/Connector-Konstruktor und gleichwertiger Factory-Erzeugung ergänzt | +| 2026-09-12 | dermatthes | §§ 5, 6, 6.2, 11: Callback-Kurzform, abgeleitete Metadaten, offene Topics und frühe Konfliktprüfung ergänzt | +| 2026-09-12 | dermatthes | §§ 1.1, 2, 5, 6.2, 11, 13, 13.1, 13.2, 13.4, 14.1: Einheitliches publish für DTO/Explizitform, optionales await, direkte Laufzeitparameter und Deadline-Regeln ergänzt | +| 2026-09-12 | dermatthes | §§ 5, 13.4: Worker-Limits, fehlendes minMessages und await-Timeout-Exception in den Beispielen erläutert | +| 2026-09-12 | dermatthes | §§ 4, 4.1, 7, 8, 10, 11.1, 13.1, 13.2, 13.4, 13.5: Kurze lokale file-Beispiele, zentrale Verbindungsvorgaben, Callback-Fehler und typisierte RPC-Exceptions ergänzt | +| 2026-09-12 | dermatthes | §§ 1–5, 7–16: RabbitMQ als einzige Umsetzung beschlossen; Interface ohne Austauschlogik, neutrale Konfiguration, Docker-Setup und Beispiele vereinheitlicht | +| 2026-09-12 | dermatthes | §§ 1, 10: PHP 8.5 als Mindestversion und ausschließlich PHP-Beispiele/Setup festgelegt | +| 2026-09-12 | dermatthes | §§ 2, 7, 11: Listener provisionieren; Publisher werfen bei fehlender Topologie QueueConfigurationMissingException | +| 2026-09-12 | dermatthes | §§ 1–5, 7, 11, 13: QueueOptions-Profile, Konfliktvertrag und RPC-Rückkanäle bei Container-Neustarts konkretisiert | +| 2026-09-12 | dermatthes | §§ 1.1, 4–5, 11, 13–14, 17: Alle Beispiele aus Nutzer-/Reviewersicht überarbeitet; gemeinsame PublishOptions, DTO-request, Prozessabläufe und Objektzuständigkeiten vereinheitlicht | + +## § 1 Abstract und Lieferumfang + +**Architekturentscheidung, 2026-09-12: Die erste Umsetzung verwendet ausschließlich RabbitMQ über AMQP 0-9-1.** Ein `RabbitMQConnector` implementiert `ConnectorInterface`; die MQ-Logik spricht nur dieses Interface an. Es gibt keine Adapterregistrierung, Treiberauswahl, Capability-Aushandlung, Fallbacks oder Laufzeit-Austauschlogik. Die Interface-Grenze ermöglicht spätere Änderungen, ohne heute zusätzliche Broker zu entwerfen. + +Die frameworkunabhängige PHP-Library bietet Topics, dauerhafte und flüchtige Subscriptions, +konkurrierende Worker, optionale strukturelle `phore/schema`-Hydration und +PHP-Attribute. `PhoreMQ` akzeptiert DSN oder Adapter mit `ConnectionOptions`. +RPC, Fehlerantworten, Metadaten, Middleware und Systemcheck bleiben Bestandteil +des Entwurfs. Signierung und Dateireferenzen behalten ihre fachlichen Verträge. + +**Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und Autoloading +stammen aus der Vorlage; die PHP-Mindestversion ist verbindlich >=8.5. +Der Docker-Start und das PHP-Setup aus [Setup](../setup.md) sind davon +unabhängige Entwicklungsdateien; sie implementieren keine MQ-Library. Alle +ausführbaren Beispiele und Setup-Skripte dieses Projekts sind in PHP >=8.5 +zu schreiben; verbindliche Projektregeln stehen in [AGENTS.md](../../AGENTS.md). + +| Umfang | Entscheidung | +|---|---| +| Transport | Genau ein RabbitMQ-Adapter hinter `ConnectorInterface` | +| Zustellung | Work/RPC dauerhaft mit Quorum Queues; Broadcast optional flüchtig; Confirms und explizites Settlement | +| API | `publish`, `subscribe`, `respond`, `run`, optional `await`; `check` für Diagnose | +| Konfiguration | Generische Namen; `topic`, `subscription`, `type`, `namespace`, `maxInFlight`, `autoCreate` | +| Entwicklung | Derselbe RabbitMQ-Adapter gegen einen Docker-Broker | +| Dateiübertragung | Verifizierte Referenzen über ausdrücklich injizierten Dateispeicher; keine eigene Speicherplattform | + +Die Referenz für den Entwicklungsaufbau ist RabbitMQ 4.3 mit Management-Plugin. +Die produktive PHP-Client-Abhängigkeit wird bei Implementierung festgelegt. + +### § 1.1 Kleine API auf einen Blick + +Die Empfehlung ist die konkrete Queue-Fassade `PhoreMQ`, die +`MessageQueueInterface` implementiert, mit den Operationen `publish`, `subscribe`, `respond` und `run`. +Senden, Warten und Empfang sind am jeweiligen Aufruf erkennbar; Broker, Routing, +Schema und Middleware werden einmal am Objekt konfiguriert. +`publish` sendet sofort und liefert ein `SendResult`; nur dessen +optional aufgerufenes `await` wartet auf eine fachliche Antwort. [geändert] + +Worker-Prozess: einmal verbinden, Handler registrieren, dann empfangen. [neu] + +```php +$mq = new PhoreMQ($dsn, $options); +try { + $mq->respond('calculator', 'calculator-workers', function (array $params): array { + return ['quotient' => $params['a'] / $params['b']]; // Validierung: Beispiel 05. + }, new SubscriptionOptions(type: 'math.divide.v1')); + $mq->run(); +} finally { + $mq->close(); +} +``` + +Separater Anwendungsprozess, nachdem der Worker bereit ist: [neu] + +```php +$mq = new PhoreMQ($dsn, $options); +try { + $reply = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3]) + ->await(timeoutSeconds: 5); + echo $reply->payload['quotient']; +} finally { + $mq->close(); +} +``` + +`request` veröffentlicht bereits vor await. `publish($dto)` bleibt die universelle +Sendemethode; die explizite RPC-Kurzform `request($dto)` liest dasselbe Mapping, +verlangt aber zwingend einen Rückkanal. Beide verwenden PublishOptions und liefern +SendResult. Es gibt keine zusätzliche RPC-Fassade oder RequestOptions-Klasse. +Reine Events zeigen `publish(..., options: new PublishOptions(reply: false))`, +RPC-Beispiele bevorzugen `request(...)->await()`. Die vereinbarte automatische +Antwortfähigkeit von publish bei konfiguriertem RPC bleibt aus § 13.4 erhalten; +der Hauptpfad verlässt sich beim Lesen nicht auf diese versteckte Vorgabe. [geändert] + +Für Diagnose gibt es zusätzlich genau einen Queue-Aufruf `check()`. +Dienstentwickler melden Zustandsänderungen über `HealthState::set()` in einem +gemeinsamen lokalen Zustandsobjekt; Transport, aktive Meldung und Ping-Antwort +verwaltet die Library. Details und standardisierter Vertrag in § 16. + +## § 2 Begriffe und Zustellvertrag + +| Begriff | Bedeutung und Beispiel | +|---|---| +| Topic | Logischer Nachrichtenkanal, etwa `users`; unabhängig vom Backendnamen | +| Message type / Subject | Fachlicher Vertrag und Routingbezeichnung, etwa `user.created.v1`; API-Name ist ausschließlich `type`, kein zusätzliches Subject-Argument | +| Namespace | Isolierter Namensraum aus dem DSN-Pfad, etwa `demo`; im Adapter auf einen Virtual Host abgebildet | +| Subscription | Benannter Empfangsvertrag auf einem Topic; Work/RPC dauerhaft, Broadcast optional flüchtig | +| Worker | Ein Prozess, der für eine Subscription arbeitet; mehrere teilen sich die Arbeit | +| Envelope | Transportneutrale Metadaten, JSON-Payload und Attachment-Deskriptoren | +| Delivery | Eine konkrete Zustellung inklusive opaque Receipt und Ack-/Retry-Steuerung | + +Jede dauerhafte Subscription erhält eine Kopie. Worker derselben Subscription +sind konkurrierende Consumer. Beispiel: `billing-users` und `audit-users` +erhalten beide `user.created.v1`; drei Billing-Worker teilen sich die +Billing-Zustellungen. Innerhalb eines Workers erhält ein Nachrichtenvertrag +genau einen registrierten Handler je Subscription; doppelte Registrierung +ist ein Konfigurationsfehler. Weitere unabhängige Handler verwenden eigene +Subscriptions. Ein Worker kann mehrere Topics abonnieren. „An alle“ bezeichnet +alle passenden benannten Subscriptions; „an einen“ einen ausgewählten Worker +innerhalb derselben Subscription. Die Subscription-Topologie bestimmt das +Verhalten, kein zusätzlicher Broadcast-Schalter beim Senden. Beispiele in § 15. + +Für dauerhafte Profile lautet der Grundvertrag **at least once innerhalb der konfigurierten +Aufbewahrung und Verfügbarkeit**. Doppelte Zustellungen sind möglich, ebenso +eine unklare Publish-Bestätigung bei Verbindungsabbruch. +`SendResult::receipt` enthält ein `PublishReceipt`: Es bestätigt Backend-Annahme, +keine Verarbeitung durch Empfänger. `SendResult::await()` liefert dagegen die +fachliche Antwort eines Responders, keine Bestätigung aller Subscriber. Es gibt keine +Exactly-once-Garantie und keine globale Reihenfolge. +Fachliche Seiteneffekte benötigen eine stabile fachliche Idempotenz-ID; Wiederzustellungen behalten zusätzlich dieselbe `messageId`. + +`subscribe()` bindet eine benannte Subscription und prüft ihren Vertrag. +Eine neu angelegte Subscription empfängt erst Nachrichten ab Erstellung ihrer +Bindung. Ein bestehender dauerhafter Rückstand bleibt bei Worker-Neustarts erhalten. +Es gibt weder Start-Cursor noch Replay-Option. In Produktion werden fachliche +Topics und Subscriptions vorab eingerichtet. `autoCreate: true` erlaubt +explizit ihre dynamische Anlage beim Registrieren von Listenern; `cancel()` beendet nur den lokalen Consumer, +löscht aber weder dauerhafte Subscription noch Rückstand. Flüchtige exklusive +Queues werden beim Ende ihrer Verbindung beziehungsweise ihres letzten Consumers entfernt. + +## § 3 Abstraktionsschichten und Erweiterungspunkte + +| Baustein | Verantwortung | +|---|---| +| `ConnectionFactory` / `ConnectionOptions` | Alternative Erzeugung von `PhoreMQ` und gemeinsame Konfiguration; derselbe RabbitMQ-Verbindungsaufbau wie im Konstruktor | +| `PhoreMQ` | Zentrales Objekt; akzeptiert DSN oder Connector und Optionen, implementiert `MessageQueueInterface` und verwaltet den Lebenszyklus | +| `MessageQueueInterface` | `publish`, `subscribe`, `request`, `respond`, `run`; Mapping-Komfort und Lebenszyklus gemäß § 1.1 | +| `MessageRegistry` | Fachliche Namen, Sendeklassen, optionale Schemas und Default-Topics zuordnen | +| `MessageCodecInterface` | JSON-kompatible Daten normalisieren, Envelope serialisieren und dekodieren | +| `SchemaMapperInterface` | Optional Strukturen prüfen und in lokal konfigurierte DTOs hydrieren | +| `MessageSecurityInterface` | Unveränderliche Nachrichtenbytes schützen und vor Verwendung verifizieren | +| `ConnectorInterface` | RabbitMQ kapseln: Topologie prüfen/anlegen, Bytes senden/empfangen und Zustellungen abschließen | +| `PayloadStoreInterface` | Streams ablegen, Referenzen auflösen, Lebensdauer verwalten | +| `QueueOptions` / `FailureStoreInterface` | Profile und Retry-Optionen auflösen, endgültige Fehler sicher ablegen | + +Sendepfad: Typ/Topic auflösen → Send-Middleware ausführen → Daten normalisieren +und ggf. validieren → Dateien ablegen → Envelope kodieren → signieren → Größenprüfung +→ Konnektor. Empfangspfad: begrenzten Transportframe lesen → Signatur und +Zeit-/Zielbindung prüfen → Envelope dekodieren → optional Dateien verifizieren +→ lokale Struktur prüfen/hydrieren → Handler-Middleware und Handler ausführen +→ bei RPC finale Antwort bestätigen lassen → Ack. Dateiinhalte werden erst +bei Zugriff geladen, bleiben aber vor Nutzung zu prüfen. Middleware darf weder +die Signaturprüfung noch Settlement umgehen; Details in § 14.3. + +Der Konnektor kennt keine Anwendungs-DTOnamen oder Callbacks. Seine +vorgeschlagenen primitiven Operationen sind `ensureTopology(TopologyDefinition, bool $autoCreate): void`, +`publish(OutboundFrame): TransportReceipt`, `receive(ReceiveRequest): iterable`, +`ack(DeliveryToken): void`, `release(DeliveryToken, RetryOptions): void` und +`close(): void`. `receive` respektiert Timeout/Stop und liefert `InboundFrame` +mit Routingkontext und Receipt; nackte Receipts gelangen nie in Nachrichten. +Ungültige oder bereits erledigte Receipts erzeugen eine Settlement-Exception. + +Ein `MessageSecurityInterface` bietet `protect(string $envelopeBytes, +SecurityContext $context): ProtectedFrame` und `verify(ProtectedFrame $frame, +SecurityContext $context): string`. Fehler werfen Exceptions. Der Kontext +enthält das lokal erwartete Topic und die Audience. Alle Verbindungswege, +einschließlich direkter Konnektoren, durchlaufen dieselbe Sicherheitskette. +Ein Provider kann auch verschlüsseln; HMAC allein tut dies nicht. + +## § 4 Verbinden und DSN-Factory + +[01-connect.php](../../examples/api-draft/01-connect.php) zeigt die Varianten. +Der Konstruktor verbindet sofort, einmal pro Prozess; `close()` gibt Ressourcen +idempotent frei. `stop()` beendet nur den Worker-Loop. Teilweise geöffnete +Ressourcen werden bei Fehlern geschlossen. Ein Adapter gehört exklusiv einem MQ. + +```php +$mq = new PhoreMQ($dsn, $options); +$mq = new PhoreMQ(new RabbitMQConnector($dsn), $options); +$mq = (new ConnectionFactory())->connect($dsn, $options); +``` + +Diese Zeilen sind Alternativen. Die Factory ist ein einfacher Konstruktor-Helfer; +keine Provider-Registry, keine Verbindungsattribute und keine dynamische Auswahl. +`PhoreMQ implements MessageQueueInterface` hat weiterhin den Konstruktor +`__construct(string|ConnectorInterface $connection, ?ConnectionOptions $options = null)`. +Ein String wird ausschließlich als RabbitMQ-AMQP-Verbindung ausgewertet. +Direkte Injektion bleibt für die Interface-Grenze und Tests erhalten; sie umgeht +weder Codec, Security noch Middleware. Weitere Implementierungen werden nicht geliefert. + +| DSN | Bedeutung | +|---|---| +| `amqp://demo:demo@127.0.0.1:5672/demo` | Lokaler Broker, Namespace `demo` | +| `amqps://user:password@mq.example.org:5671/app` | TLS mit Zertifikats-/Hostprüfung, Namespace `app` | + +DSN-Bestandteile werden einmal percent-dekodiert; `@` im Passwort ist `%40`, +der Namespace `/` wird als `/%2F` dargestellt. Ungültige Ports, Schemes, +Query-Optionen oder Pfade werden abgelehnt. Fehlerausgaben redigieren Credentials. +Kein Environment-Zugriff und keine automatische Secret-Erzeugung. Die +Security-Policy muss explizit vorliegen; eine reine DSN ohne erforderliche +Optionen schlägt früh mit `InvalidConfigurationException` fehl. + +`ConnectionOptions` bündelt `autoCreate` (Standard false), den expliziten +`managementUrl` für Topologieprüfungen, `maxInFlight` +(Standard 1, positive Ganzzahl), Security, Registry, optionale Schema-Bridge, +RPC, Health, Middleware, `queueDefaults` als `QueueOptions` und optionalen PayloadStore. Konfiguration wird als +Snapshot übernommen; bewusst geteilte Zustandsobjekte wie `HealthState` +bleiben geteilt. Unbekannte Optionen sind Fehler. + +`ConnectionOptions::fromArray(array $values, ?ConnectionOptions $overrides = null)` +ist ein geplanter Konfigurationshelfer, keine existierende Implementierung. +Er versteht ausschließlich dokumentierte Werte: `security.mode=unsigned` +wählt explizit die unsignierte Demo-Policy, `rpc.enabled` und `rpc.replyNamespace` +den Rückkanalmodus aus § 13.1. Keine Klassennamen oder ausführbarer Code aus JSON. +Explizit gesetzte Override-Felder ersetzen die entsprechenden Basiswerte; +ausgelassene Felder behalten sie. Objekt-Abhängigkeiten werden nur programmatisch +injiziert. Die optionale Schema-Bridge wird bei installiertem `phore/schema` +verwendet; andernfalls scheitert benötigte DTO-Hydration früh. + +### § 4.1 Kurzer lokaler Einstieg in den Beispielen + +[config/message-queue.json](../../config/message-queue.json) ist die gemeinsame +Quelle für Verbindung, Optionswerte und deklarierte fachliche Topologie. +[connection.php](../../examples/api-draft/connection.php) lädt diese Datei +explizit. Die Beispiele erzeugen weiterhin ein einzelnes `PhoreMQ`: + +```php +$mq = new PhoreMQ(...demoConnection()); +``` + +`demoConnection()` ist nur eine Beispiel-Hilfsfunktion, kein neuer Library-Aufruf. +Sie liefert DSN und Optionen; spezifische Optionen können ergänzt werden. +Die Demo ist ausdrücklich unsigniert und nur für den isolierten lokalen Broker. +Für produktive Dienste werden eigene Credentials, TLS und die HMAC-Policy +konfiguriert. Broker-Passwort und Signierschlüssel sind verschiedene Werte. +Die Demo richtet pro Client einen eigenen Rückkanal ein. Es gibt keinen +impliziten Dateispeicher; Beispiel 04 verlangt ihn ausdrücklich vom Aufrufer. + +Docker-Start, Einrichtung, Namensabbildung, dynamische Anlage und Bereinigung +sind im [Setup-Guide](../setup.md) beschrieben. Bestehende Subscriptions behalten +ihren Backlog; unabhängige Beispieldurchläufe beginnen mit einem frischen Demo-Broker. + +## § 5 Senden, empfangen und Worker-Lebenszyklus + +[Programmatische Beispiele: 02-programmatic.php](../../examples/api-draft/02-programmatic.php). +Die vorgeschlagenen öffentlichen Signaturen lauten: + +```php +publish(string|object $topic, string|PublishOptions|null $type = null, + array|object|null $payload = null, ?PublishOptions $options = null): SendResult; +subscribe(string|callable $topic, ?string $subscription = null, + ?callable $handler = null, ?SubscriptionOptions $options = null): SubscriptionHandle; +request(string|object $topic, string|PublishOptions|null $type = null, + array|object|null $payload = null, ?PublishOptions $options = null): SendResult; +respond(string|callable $topic, ?string $subscription = null, ?callable $handler = null, + ?SubscriptionOptions $options = null): SubscriptionHandle; +registerHandlers(object $handler): void; +run(?RunOptions $options = null, ?int $maxMessages = null, + ?float $maxSeconds = null, ?float $idleTimeoutSeconds = null): void; +stop(): void; +close(): void; +``` + +`publish($topic, $type, $payload)` benennt Topic und Typ ausdrücklich; +`publish($dto)` liest sie aus Registry oder Attribut der lokalen Sendeklasse. Ein fehlendes oder widersprüchliches +Mapping wirft `MessageMappingException`. Registry, Attribute und explizite +Angaben ergänzen nur offene Werte; widersprüchliche feste Angaben werden +abgelehnt statt still überschrieben. Mehrfache programmatische Registrierung +desselben Sendetyps wird abgelehnt. `PublishOptions` kann eine stabile `messageId`, `expiresAt`, +`correlationId`, getrennte `metadata` und Attachments tragen. Ein Retry eines +unklar bestätigten Publishes verwendet dieselbe ID und denselben fachlichen +Inhalt. `request`/`respond` sind die optionale RPC-Erweiterung aus § 13; +`subscribe` sendet niemals automatisch einen Rückgabewert. + +`publish` entscheidet ausschließlich anhand des ersten Parameters: Ein String +ist das explizite Topic und benötigt einen String-Typ sowie Array-/Objekt-Payload. +Ein Objekt ist die gesamte Payload; Topic und Wire-Typ werden aus seinem lokalen +Mapping ergänzt. Für diese Form sind `publish($dto, $options)` und +`publish($dto, options: $options)` gleichwertig. Der Parametername `topic` bleibt +für bestehende benannte Aufrufe erhalten. Ein Array als erster Parameter wird +nicht als DTO interpretiert. Ohne Mapping kein Ableiten aus dem PHP-Klassennamen. + +`PublishOptions` kann `topic` und `type` für offene Mappingwerte enthalten, +etwa `publish($dto, options: new PublishOptions(topic: 'audit.users'))`. +Identische Angaben sind zulässig, widersprüchliche feste Angaben aus Klasse, +Registry oder explizitem Aufruf werfen weiterhin `MessageMappingException`. +Zusätzliche Payload/Typ-Argumente in der Objektform, ein Optionsobjekt an +zweiter und vierter Position oder eine unvollständige explizite Form werden +vor Publish als `InvalidArgumentException` abgelehnt. Diese Fälle werden nicht +heuristisch umgedeutet. Die bisher vorgeschlagene Methode `emit` entfällt, +auch als Alias, da die API noch nicht implementiert ist. + +`SubscriptionOptions` enthält optional `topic` und `subscription` für die +Callback-Kurzform sowie `type` als exakten Filter, +`payloadClass` als lokale Zielklasse, `ackMode` und `queue: QueueOptions`. +Profile legen Haltbarkeit und Retry fest (§ 7.2); Cursor und Replay sind nicht vorgesehen. +Ohne Typfilter muss der Array-Handler alle +Nachrichtentypen des Topics verarbeiten können. Das Binding filtert bereits bei der Zustellung in die Queue. Ein dennoch +eingehender unpassender Frame ist ein Routing-/Validierungsfehler und wird +sicher abgelegt; kein separater Handler darf dieselbe Subscription mit anderem Filter übernehmen. +Filteränderungen benötigen eine neue Subscription oder explizite Migration. + +Die bisherigen Aufrufe `subscribe($topic, $subscription, $handler, $options)` +bleiben gültig. Neu ist `subscribe($callback)` bzw. +`subscribe($callback, options: new SubscriptionOptions(...))`. Der erste +Parameter heißt zur Kompatibilität weiter `topic`; ist `handler` gesetzt, +muss `topic` ein String sein (explizite Form). Ohne `handler` muss er ein +aufrufbarer Callback sein (Kurzform). Ein String ohne Handler ist unvollständig und wird abgelehnt, auch wenn eine +gleichnamige PHP-Funktion existiert. Die Callback-Kurzform akzeptiert Closure, +aufrufbares Array oder invokable Objekt; eine benannte Funktion wird ausdrücklich +als First-class Callable `myHandler(...)` übergeben. Eine Subscription in der zweiten Position ergänzt bei der +Kurzform einen offenen Wert. Vermischte ungültige Aufrufe werden mit +`InvalidHandlerException` abgelehnt. Ein wirklich leeres `subscribe()` besitzt +keinen Callback und ist kein gültiger Aufruf. [geändert] + +`subscribe`/`respond` prüfen und binden die Topologie sofort; sie führen noch +keinen Business-Callback aus. `run` aktiviert die Consumer und startet den +blockierenden Empfang. Ohne registrierten Business- oder ausdrücklich aktivierten +Control-Handler wirft run vor dem Warten InvalidHandlerException. Ein beendetes +run lässt Registrierung und Connection bestehen, ein weiteres run ist zulässig. +Vorgesehen: `RunOptions(maxMessages, maxSeconds, idleTimeoutSeconds)` oder +direkt `run(maxMessages: 100, maxSeconds: 30, idleTimeoutSeconds: 2)`; +`run` kehrt beim ersten erreichten Limit zurück. `stop` beendet nach dem +laufenden Handler, `close` gibt Verbindungen frei. Empfangs-Timeout ohne +Nachricht ist kein Fehler. Ein Handler erhält Payload und optional +`MessageContext`; letzterer liefert `messageId`, Typ, Topic, Versuch und +Attachment-Zugriff. Broker-spezifische Objekte werden nicht weitergereicht. [geändert] + +Optionsobjekte bleiben als erster Parameter erlaubt, etwa +`run(new RunOptions(maxSeconds: 60), maxMessages: 100)`. Nicht-null direkt +angegebene Parameter überschreiben denselben Wert aus dem Optionsobjekt; +nicht angegebene Werte übernehmen dessen Konfiguration bzw. Library-Defaults. +Das Optionsobjekt wird nicht mutiert. Limits müssen endlich und positiv sein, +`maxMessages` ganzzahlig; ungültige Werte werfen `InvalidArgumentException` +vor Eintritt in den Loop. Diese Regel gilt auch für die direkten Parameter +von `await`; Routingkonflikte aus § 6.2 werden dagegen weiterhin abgelehnt. +`run` bedient alle registrierten Handler, nie das vorherige Sendekommando. + +`maxMessages` zählt abgeschlossene fachliche Zustellversuche über alle +Subscriptions dieses `run` zusammen; der Zähler startet je Aufruf bei null. +Fan-out-Kopien und Wiederholungen zählen separat, auch Versuche mit behandeltem +Retry-/Reject-/Validierungsfehler. Interne Health-/Reply-Verarbeitung, leere +Polls und vorgeholte, noch nicht bearbeitete Zustellungen zählen nicht. +Ein wegen ungültigem Typ abgelehnter Delivery zählt als abgearbeiteter Versuch. Das Limit ist keine Anzahl erfolgreicher oder +eindeutiger Geschäftsoperationen. Nach Erreichen wird keine weitere fachliche +Zustellung verarbeitet; bereits vorgeholte Einträge bleiben sicher unbestätigt +bzw. werden beim Schließen des Empfangschannels erneut verfügbar. + +`maxSeconds` ist das Gesamtbudget ab Loop-Start, einschließlich Warten und +Verarbeitung. `idleTimeoutSeconds` begrenzt eine zusammenhängende Wartephase +ohne fachliche Zustellung; bei Rückkehr ins Warten beginnt diese Frist neu, +Handlerlaufzeit zählt nicht als Leerlauf. Interner Health-Verkehr setzt den +Idle-Timer nicht zurück. Empfangswarten wird auf die verbleibenden Budgets +begrenzt. Ein laufender synchroner Handler wird nicht präemptiv abgebrochen +und kann das Gesamtbudget überschreiten; weitere Handler starten dann nicht. +Erreichen eines Worker-Limits führt zu normaler Rückkehr, nicht zu einer +Timeout-Exception. Echte Infrastrukturfehler bleiben Exceptions. + +`minMessages` gehört nicht zum Entwurf. `maxMessages` ist eine Obergrenze, +keine Mindestzahl: Zeitlimit, Leerlauf oder `stop()` können den Loop früher +beenden. Nicht gesetzte Grenzen sind unbegrenzt; `run()` ohne Limits läuft +bis Stop oder Fehler. Eine fachlich erforderliche Mindestzahl bestätigt die +Anwendung explizit, wie die Lock-Antwortaggregation in Beispiel 07. Die +Optionskommentare stehen gesammelt bei processBatch in Beispiel 02. [geändert] + +Standardmäßig folgt Ack erst nach erfolgreicher Callback-Rückkehr. Ein +temporärer Handlerfehler löst eine begrenzte Retry-Policy aus; endgültige +Fehler gehen vor Bestätigung in den FailureStore. `AckMode::Manual` erlaubt +`context->ack()`, `context->retry(delaySeconds: ...)` oder +`context->reject(reason: ...)`. Es ist genau eine Settlement-Entscheidung pro +Zustellung zulässig. Rückkehr ohne Settlement gibt die Nachricht erneut frei. +Eine Lease-Verlängerungsmethode gehört nicht zur API. Lange synchrone Handler +brauchen passende Broker-Ack-Fristen und eine Laufzeit, die AMQP-Heartbeats +bedient; eine offene TCP-Verbindung allein verhindert keinen Heartbeat-Abbruch. + +Retry erfolgt durch RabbitMQ-Redelivery und die in § 7 beschriebene Adapterlogik. +Es darf keine verlustbehaftete Folge aus Ack vor erneutem Publish geben. +Nichtatomare Kopier-vor-Ack-Schritte dürfen Duplikate erzeugen und müssen +dies dokumentieren. Fehler im FailureStore führen zu **keinem Ack** und +beenden den Worker mit Infrastrukturfehler. Ein Error-Observer bekommt +sanitisierte Fehlerdaten; systemische Transportfehler werden aus `run` +geworfen statt in einer Endlosschleife verborgen. + +## § 6 SDK-Typen, Attribute und strukturelle Kompatibilität + +[Attributbeispiele: 03-attributes.php](../../examples/api-draft/03-attributes.php). +Mit „Annotationen“ sind zunächst native PHP-8-Attribute gemeint: +`#[MessageType('user.created.v1', topic: 'users', subscription: 'sdk-users')]` +auf DTOs ermöglicht `subscribe($callback)` mit einem typisierten ersten +Parameter. Alternativ reicht `#[Subscribe]` auf einer öffentlichen +Handler-Methode; `registerHandlers($object)` verwendet denselben Resolver. +Offene Werte werden am Handler oder Aufruf ergänzt. PHPDoc-Annotationen als +zweites Metadatensystem sind vorerst nicht vorgesehen; die normalen +PHPDoc-Feldtypen von `phore/schema` bleiben nutzbar. + +Ein SDK enthält ausschließlich Contracts/DTOs und optionale Attribute, keine +Connection, Secrets oder Worker. Ein SDK darf auch ganz ohne MQ-Attribute +auskommen: `registry->register('user.created.v1', T_UserCreated::class, +topic: 'users')` ordnet bestehende Klassen von außen zu. Ein Empfänger darf +eine eigene Klasse `LocalUserCreated` benutzen. Der Wire-Typname stimmt +überein; weder Composer-Paketname noch PHP-FQCN werden verglichen oder als +Class-Loading-Anweisung übertragen. Gleiche Kurzklassennamen allein erzeugen +kein Mapping; der fachliche Name muss lokal bewusst zugeordnet sein. + +| Callback-Parameter | Geplantes Verhalten | +|---|---| +| `array $data` | Dekodierte Daten; keine DTO-Hydration; Schema-Prüfung nur bei explizit registriertem Contract | +| `LocalDto $data` | Reflection bestimmt die lokale Klasse; Schema-Bridge validiert/hydriert vor dem Aufruf | +| Explizites `payloadClass` | Muss zum Callback passen; Konflikt wird schon beim Registrieren abgelehnt | +| Untyped / `mixed` | Array wie bei untypisiertem Empfang | +| Union, Intersection, Interface, abstrakte Klasse | Keine automatische Zielwahl; im ersten Release `InvalidHandlerException` | + +Typisierte Handler benötigen einen eindeutigen Message-Typ aus +`SubscriptionOptions::type`, `Subscribe::type` oder Klassenmapping. Ohne ihn +ist die Registrierung ungültig. Die optionale Schema-Bridge muss verfügbar +sein, sobald DTO-Hydration oder Contract-Validierung verlangt wird; +sonst `MissingDependencyException`, niemals stiller Rückfall auf Arrays. +Der Resolver aus § 6.2 prüft alle vorhandenen Angaben auf Übereinstimmung. + +Kompatibilität bedeutet: Pflichtfelder müssen vorhanden sein, die vorhandenen +bekannten Felder müssen rekursiv ihren Datentypen entsprechen. Zusätzliche +Felder sind im vorgeschlagenen `Compatible`-Profil erlaubt und werden vor +Hydration auf die bekannte lokale Struktur projiziert. Der untypisierte +Empfang bewahrt sie. `Strict` lehnt unbekannte Felder ausdrücklich ab. +Defaults und nullable Felder folgen zunächst `phore/schema`: ohne Default +und ohne Nullbarkeit ist ein Feld required; nullable darf auch fehlen. +„Required und nullable“ braucht später eine eigene explizite Regel. +Inkompatible Änderungen bekommen einen neuen Wire-Namen, etwa `.v2`. + +### § 6.1 Tatsächliche Integration in phore/schema + +Geprüfte Einstiegspunkte sind `SchemaParser::parseClass`, +`Hydrator::hydrate`, `ClassSchema::hydrate` und `Validator::validate`. +Composer nennt das Paket `phore/schema`; das Repository heißt +`phore/phore-schema`. Der Hydrator akzeptiert Array-/stdClass-Strukturen und +kann verschachtelte Klassen daraus erzeugen, lehnt aber unbekannte Properties +ab. Der aktuelle Validator verwendet bei `ClassReferenceSchemaType` teilweise +`instanceof`; bloß `validate($schema, $wireArray)` erfüllt deshalb den hier +verlangten rekursiven Strukturvertrag nicht zuverlässig. + +Die geplante `PhoreSchemaMapper`-Bridge normalisiert Objekte anhand öffentlicher +Schema-Properties in Wire-Strukturen, projiziert im Compatible-Profil rekursiv +auf bekannte Felder und verwendet den Hydrator als strukturelle Prüfung und +DTO-Erzeugung. Class-References werden nur über lokal konfigurierte Schemas +aufgelöst. Cycles, maximale Tiefe und Größen werden begrenzt; Maps und Listen +bleiben unterscheidbar. Untypisierte Daten werden ohne lokale DTO-Klasse +dekodiert; reine Contract-Prüfung darf intern hydrieren und das Ergebnis +verwerfen. Konstruktoren solcher Contracts müssen seiteneffektfrei sein. + +Ein allgemeiner `Validator::validate()`-Aufruf wird im Entwurf daher nicht +als fertige Wire-Validierung dargestellt. Ein verbesserter struktureller +Validator in `phore/schema` wäre eine gesonderte spätere Aufgabe. Klassen- +Identitätsprüfungen für Wire-Payloads sowie `unserialize()` sind ausgeschlossen. +JSON unterstützt nur definierte Datentypen; Ressourcen, Closures, Zyklen, +NaN/Infinity und unbekannte Objekttypen werfen `SerializationException`. + +### § 6.2 Metadaten einmal definieren und aus dem Callback ableiten + +```php +#[MessageType('user.created.v1', topic: 'users', subscription: 'sdk-users')] +final class UserCreated { public string $userId; } + +$mq->subscribe(function (UserCreated $event): void { + // Topic users, Subscription sdk-users, Typ user.created.v1; + // Payload wird vor dem Callback strukturell geprüft und hydriert. +}); +``` + +`MessageType(string $type, ?string $topic = null, ?string $subscription = null)` +bezeichnet einen stabilen Wire-Typ und optional feste Routingwerte. +`Subscribe(?string $topic = null, ?string $subscription = null, ?string $type = null)` +kann ohne Argumente auf einer Methode stehen. Reflection untersucht den ersten +Payload-Parameter des Callbacks; der optionale zweite `MessageContext` liefert +keine Routingwerte. Closures, Funktionsnamen, öffentliche Methoden-Callables +und aufrufbare Objekte werden einheitlich über ihre tatsächliche Signatur +aufgelöst, ohne den Callback auszuführen. Mehrdeutige Payload-Typen bleiben +wie in § 6 beschrieben ungültig. + +Der Resolver sammelt Klassenmapping/`MessageType`, ein gegebenenfalls am +Callback vorhandenes `Subscribe`-Attribut und explizite Aufruf-/Optionswerte. +Für Topic, Subscription, Typ und Zielklasse gilt: fehlend lässt sich ergänzen; +mehrere identische Angaben sind zulässig; unterschiedliche feste Angaben +sind ein Fehler. Es gibt keinen stillen Vorrang. Methodennamen, PHP-FQCNs, +Hostname oder Instanz-ID werden niemals zu Topic- oder Subscriptionnamen +umgedeutet. Für SDKs ohne Attribute kann +`registry->register($type, $class, topic: ..., subscription: ...)` dieselben +Metadaten lokal hinterlegen. + +Topic und Subscription müssen nach Auflösung eindeutig vorhanden sein; +für einen DTO-Handler zusätzlich der Wire-Typ. Untypisierte/Array-Handler +benötigen explizite Topic-/Subscription-Angaben und optional den Typfilter. +Ohne Typfilter bleibt ihr bisheriger Empfang aller Typen des Topics gültig. +`payloadClass` muss weiterhin zum Callback passen. Ohne Schema-Bridge gibt +es auch in der Kurzform keine automatische DTO-Hydration. + +Ein für mehrere Topics verwendeter Contract lässt `MessageType::topic` +vollständig weg. Das Topic wird für jede Registrierung explizit gewählt; +es gibt keine automatische Expansion, kein Wildcard-Abonnement und keine +Liste im `topic`-Feld. Für unabhängige Gruppen bleibt entsprechend +`MessageType::subscription` offen. Der fachliche Typ wird weiterhin aus der +Klasse übernommen: + +```php +#[MessageType('audit.entry.v1')] +final class AuditEntry { public string $text; } + +$handler = function (AuditEntry $event): void { /* ... */ }; +$mq->subscribe($handler, options: new SubscriptionOptions( + topic: 'audit.users', subscription: 'audit-reader', +)); +$mq->subscribe($handler, options: new SubscriptionOptions( + topic: 'audit.billing', subscription: 'audit-reader', +)); +``` + +Die Subscription ist die Gruppe für konkurrierende Worker, kein intrinsischer +Bestandteil der Nachricht auf dem Wire. Eine im SDK festgelegte Subscription +wird daher nur gewählt, wenn diese Gruppierung absichtlich für alle Nutzer +gelten soll. Mehrere Dienste, die dieselben vollständigen Metadaten übernehmen, +konkurrieren um Arbeit; für Fan-out braucht jeder seine eigene, am Contract +offen gelassene Subscription. Ein gleicher Gruppenname an unterschiedlichen +logischen Topics bezeichnet unterschiedliche Bindungen. + +`publish($dto)` verwendet dieselben festen Topic-/Typ-Angaben; die Subscription +spielt beim Senden keine Rolle. Ohne festes Topic ergänzt die Anwendung es mit +`publish($dto, options: new PublishOptions(topic: ...))` oder verwendet +`publish($topic, $type, $dto)` bzw. ein Array. Bei einem gemappten DTO müssen +explizite Topic-/Typ-Werte zu dessen festen Metadaten passen; ein offenes +Topic lässt sich frei ergänzen. `publish` ohne auflösbares Topic wirft +`MessageMappingException`. Mehrere Topics werden durch mehrere ausdrückliche +Publishes angesprochen, nicht durch einen verborgenen Broadcast. + +Die Registrierung prüft alle Metadaten vor dem Anlegen/Binden von Ressourcen. +Widersprüche werfen `MessageMappingException` mit `code=MAPPING_CONFLICT`, +`field`, `sources` und den betroffenen nicht geheimen Mappingwerten; fehlende +Pflichtwerte `MAPPING_INCOMPLETE` mit `missingFields`. Ein zweiter lokaler +Handler für dieselbe Topic-/Subscription-Bindung wirft +`InvalidHandlerException` mit `code=DUPLICATE_SUBSCRIPTION`, auch wenn beide +Callbacks gleich aussehen oder verschiedene Typfilter wünschen. Andere +Prozesse derselben Gruppe sind ausdrücklich erlaubt. + +`registerHandlers` prüft sämtliche ausgewählten Methoden zunächst gemeinsam +auf lokale Konflikte und bindet danach. Kein globales Dateisystem-Scanning; +eine bereits einzeln registrierte Methode darf nicht durch anschließendes +`registerHandlers` doppelt gebunden werden. Schlägt das tatsächliche Binden +am Broker teilweise fehl, werden die in diesem Aufruf neu geöffneten lokalen +Bindings geschlossen; zuvor bestehende Registrierungen bleiben erhalten. +Dabei bereits angelegte dauerhafte Brokerressourcen werden nicht automatisch +gelöscht. Die Meldung benennt die betroffenen Bindings. + +Vorgesehene Contract-Tests: identisches Routing aller drei Registrierungswege, +DTO-Hydration, offene Topics, feste Topic-/Typ-/Subscription-Konflikte, fehlende +Metadaten, doppelte lokale Gruppen, gemeinsame Gruppe in zwei Prozessen, +keine Ressourcenerzeugung bei Metadatenfehlern und Cleanup bei Bindefehlern. +Beispiel 03 zeigt die erfolgreichen Varianten und erwartete Exceptions; +es bleibt ausschließlich API-Entwurf. + +## § 7 RabbitMQ-Adapter und Konfigurationsabbildung + +Die öffentliche API verwendet generische Begriffe. RabbitMQ-Begriffe erscheinen +nur im Adapter, Deployment und zur Erklärung der konkreten Abbildung. + +| Öffentlicher Begriff | RabbitMQ-Abbildung im ersten Adapter | +|---|---| +| Namespace | Virtual Host aus dem DSN-Pfad | +| Topic `users` | Dauerhafte Topic-Exchange `phore.topic:users` | +| Typ / Subject `user.created.v1` | Exakter Routing Key; keine eigene Ressource | +| Subscription `audit-users` (Work/RPC oder Broadcast mit Retention) | Dauerhafte Quorum Queue `phore.sub:users:audit-users` | +| Subscription ohne Typfilter | Binding mit `#`; empfängt alle Typen dieses Topics | +| Subscription mit `type` | Binding mit genau diesem Typ; keine öffentliche Wildcard-Sprache | +| `maxInFlight` | Consumer-Prefetch; keine Zahl parallel ausgeführter PHP-Callbacks | +| Fehlerablage | Quorum Queue `phore.failure:users:audit-users` je Subscription | + +Namen bestehen aus einem führenden Buchstaben/Unterstrich und höchstens 99 +weiteren Buchstaben, Ziffern, Unterstrichen, Punkten oder Bindestrichen. +Doppelpunkte in physischen Namen sind dadurch eindeutige Trenner. Reservierte +interne Namen beginnen mit `_phore`; Anwendungstopologien verwenden sie nicht. + +`publish` verwendet persistente Frames, Publisher Confirms und `mandatory`. +Eine nicht routbare fachliche Nachricht wirft `QueueConfigurationMissingException`; eine positive +Publish-Bestätigung beweist keine Handler-Bereitschaft und nicht die Existenz +aller fachlich erwarteten Subscriptions. Fachliche Bindungen müssen vor Publish +existieren. Eine Exchange selbst speichert keinen Backlog. + +`publish` und `request` legen niemals fachliche Topics, Queues oder Bindings +an, auch nicht bei `autoCreate: true`. Die Anlage gehört dem Listener beim +Registrieren mit `subscribe`/`respond` oder dem expliziten Setup. Der Publisher +meldet ein nachweislich fehlendes Topic oder eine nicht routbare Nachricht mit +`QueueConfigurationMissingException extends InvalidConfigurationException`. +Diese besitzt `reason`, `topic` und `messageType`; erlaubte Gründe sind +`TOPIC_MISSING` und `NO_MATCHING_SUBSCRIPTION`. Beispielmeldung: +„Für Topic users und Typ user.created.v1 fehlt die Queue-Konfiguration. +Möglicherweise wurde der zuständige Listener-Dienst noch nicht initialisiert.“ +Keine Credentials oder Payloads in dieser Diagnose. + +Der Fehler entsteht direkt bei `publish`/`request`, nicht erst bei `await`. +Eine Vorabprüfung ersetzt weder `mandatory` noch Publisher Confirms: Wird die +Topologie zwischen Prüfung und Versand entfernt, wird auch die Brokerantwort +in denselben Fehlervertrag übersetzt. Rechtefehler, Verbindungsabbrüche und +unklare Publish-Bestätigungen behalten ihre eigenen Exceptions; sie beweisen +keine fehlende Konfiguration und erlauben keinen blinden automatischen Retry. +Die internen, ausdrücklich aktivierten RPC-Rückkanäle aus § 13.1 bleiben davon +getrennt; sie erzeugen keine fachliche Empfänger-Subscription. + +Eine vorhandene passende dauerhafte Subscription ohne aktiven Worker ist +weiterhin ein gültiges Versandziel und sammelt Backlog. Die Exception sagt +nichts Sicheres über einen fehlenden Container aus. Für aktuelle Listener- +Bereitschaft und alle erwarteten Empfänger dient `check()`; ein routbares +Publish allein beweist nur mindestens ein passendes Ziel. + +`subscribe` validiert/anlegt Exchange, Queue und Binding gemäß `autoCreate`. +Identische Definitionen sind wiederholbar. Abweichende Typfilter, Queue-Eigenschaften +oder Namensbindungen werfen `QueueConfigurationConflictException`; kein automatisches +Löschen oder Umbauen gefüllter Queues. `autoCreate: false` prüft nur. +Eine passive AMQP-Queue-Prüfung beweist nicht die vollständige Binding-Konfiguration; +strikte Prüfung nutzt die über `managementUrl` konfigurierte Management-API +mit passenden Rechten. Deren Zugang verwendet die expliziten Verbindungscredentials; +produktive Endpunkte müssen HTTPS mit Zertifikatsprüfung verwenden. Keine +ableitende URL-Heuristik und kein Fallback nach fehlgeschlagener Prüfung. Ohne Prüfmöglichkeit folgt +`TopologyVerificationException`, keine Behauptung erfolgreicher Vollprüfung. + +Retry-Veröffentlichungen gehen ausschließlich über ein internes Ziel zurück +an dieselbe Subscription, niemals erneut über die fachliche Topic-Exchange. +Verzögerung erfolgt mit internen Wartequeues fester TTL und Rückführung an die +Zielqueue. Erst nach bestätigtem Retry-/Fehler-Publish wird das Original bestätigt. +Crash-Fenster dürfen Duplikate, aber kein vorzeitiges Erfolgs-Ack erzeugen. +Der unveränderte signierte fachliche Frame bleibt erhalten; Versuchszähler +liegen in vertrauenswürdig verwalteten Transportmetadaten (§ 11.1). + +Quorum Queues erhalten bei der Einrichtung bestätigtes Dead-Lettering mit +`reject-publish` und einem eigenen Fehlerziel. Der automatische Delivery-Limit- +Default wird explizit deaktiviert; die begrenzte Handler-Retry-Policy verwaltet +PhoreMQ. Transportabbrüche können zusätzliche Zustellversuche auslösen und +werden nicht als exakte Zahl bereits gestarteter Handler interpretiert. +Die Fehlerqueue erhält keine automatische Ablaufzeit. Betriebsseitige Limits +werden bewusst gesetzt und überwacht; die Demo ist kein Hochverfügbarkeitscluster. + +### § 7.1 Was andere PHP-Abstraktionen bereits vorsehen [gelöscht] + +### § 7.2 QueueOptions: Profile, Defaults und Konflikte + +`respond($callback)` und `#[Respond]` nutzen denselben Resolver für den ersten +DTO-Parameter wie subscribe; nur fehlende Metadaten müssen explizit ergänzt werden. + +`QueueOptions` ist das gemeinsame immutable Optionsobjekt für programmatische +Registrierung und `#[Queue]` am Nachrichten-DTO. `QueueOptions::workQueue()`, +`::rpc()` und `::broadcast()` sind benannte Konstruktoren desselben Objekts. +`new QueueOptions(...)` setzt nur die ausdrücklich genannten Felder. Profilfabriken setzen nur das Profil und explizite Argumente; Defaults werden erst +beim Auflösen ergänzt. Alle Beispiele +stehen in [11-queue-options.php](../../examples/api-draft/11-queue-options.php). + +| Feld | Bedeutung und Default | +|---|---| +| `profile` | `QueueProfile::WorkQueue`, `Rpc` oder `Broadcast`; subscribe ohne Vorgabe WorkQueue, respond Rpc | +| `revision` | Positive Contract-Version, Default 1; keine automatische Migrationsfreigabe | +| `retentionSeconds` | Positive maximale Wartezeit in der Subscription; `null` unbegrenzt bei Work/RPC, beim Broadcast ohne Offline-Aufbewahrung; 0 ungültig | +| `maxInFlight` | Positive Anzahl offener Zustellungen je Consumer; fällt auf ConnectionOptions.maxInFlight (Default 1) zurück; erzeugt keine Worker | +| `maxAttempts` | Mindestens 1; Work/RPC Default 4 inklusive Erstversuch, Broadcast Default 1 | +| `retryDelaySeconds` | Positive feste Retry-Wartezeit, Default 10; kein sleep im Handler und kein Jitter | + +Work und RPC nutzen dauerhafte Quorum Queues und eine bestätigte Fehlerablage. +RPC ergänzt den Antwortvertrag; das Profil allein macht aus einem subscribe-Handler +keinen Responder. Broadcast ohne Retention nutzt eine exklusive, nicht dauerhafte +Classic Queue je registrierter Verbindung mit eigenem Binding; der physische Name +enthält einen zufälligen Verbindungsteil. Es erreicht die beim Versand gebundenen +Instanzen, ohne Offline-Garantie. Nach Callback-Erfolg wird die jeweilige Kopie +bestätigt; ohne Retry wird ein Fehler diagnostiziert und endgültig verworfen. +Redelivery nach Transportfehler ist trotzdem möglich. Broadcast mit positiver +Retention verwendet dauerhafte Quorum Queues und stabile Subscription-Namen: +je Empfängergruppe ein anderer Name, gleiche Namen teilen Arbeit. Endgültige +Fehler gehen dort in die Fehlerablage. Neue Gruppen bekommen keine Historie. + +Retention ist eine maximale Verweildauer für wartende Nachrichten, keine +Mindestaufbewahrung nach Ack und kein Ausführungstimeout. Die Abbildung nutzt +Nachrichten-TTL der Queue (x-message-ttl, nicht x-expires); bereits laufende Handler werden dadurch nicht abgebrochen. Retries +bewahren zusätzlich die ursprüngliche Ablaufzeit, statt den Auftrag durch jede +Wartequeue zu verjüngen. Abgelaufene Work/RPC-Aufträge gehen in die Fehlerablage; +RPC sendet nur bei noch gültiger Antwortfrist eine sichere terminale Fehlerantwort. + +Defaults füllen offene Felder: feste DTO-Werte und explizite Subscription-Werte +müssen übereinstimmen; diese Werte haben Vorrang vor `ConnectionOptions::queueDefaults`, +danach folgen Profildefaults. Das Queue-Attribut setzt dieselben Felder wie das +Optionsobjekt. Unpassende Kombinationen (etwa flüchtiger Broadcast mit Retries) +werfen `UnsupportedQueueOptionException` mit Option und Begründung. Ein DTO ohne +festes Topic darf auf mehreren Topics verwendet werden; seine Queue-Vorgaben +gelten dann für jede Registrierung. Queue-Eigenschaften gelten pro Subscription, +nicht pro PHP-Klasse. Das bestehende einzelne Binding/Typfilter-Modell bleibt erhalten. + +Bei Registrierung wird der vollständige Contract vor Consumerstart verglichen. +Fehlend: mit autoCreate anlegen. Identisch: wiederverwenden. Widersprüchlich: +`QueueConfigurationConflictException` mit `topic`, `subscription`, `option`, +`actual`, `requested`, `actualRevision` und `requestedRevision`. Benötigt eine +Änderung Neuerstellung, folgt deren Unterklasse `QueueMigrationRequiredException`. +Keine heimliche Löschung, kein Downgrade und kein automatisches Policy-Update. +Auch eine höhere Revision verlangt zunächst eine explizite Migration. + +Ein dauerhaftes Contract-Register muss auch Library-Werte wie Retry-Intervall +atomar vergleichen und beim ersten Anlegen unveränderlich festhalten. Seine +interne Persistenz und Compare-and-create-Implementierung sind vor dem Runtime-Bau +noch festzulegen und mit gleichzeitig startenden Containern zu prüfen. Beliebige +AMQP-Metadaten oder eine Revisionsnummer allein sind dafür keine zugesicherte +Vergleichsoperation. Bis zu dieser Implementierung wird kein vollständiger +revisionssicherer Abgleich als verfügbar behauptet. Broker-Eigenschaften werden +zusätzlich direkt geprüft; Teilanlage startet keinen Consumer. Das Setup-Skript +liefert noch kein Contract-Register. Automatische Upgrades bleiben zurückgestellt; +kein Listener darf bestehende Policies überschreiben. + +## § 8 Transparente Sicherheit + +Default-Provider bei konfiguriertem Shared Secret ist **HMAC-SHA-256** mit +Key-ID. Ein bloßer SHA-Hash mit angehängtem Secret ist kein geeignetes +Signaturverfahren. Verbindungskonfiguration verlangt eine explizite Policy: +HMAC oder bewusstes `UnsignedSecurity` für isolierte Tests (§ 4.1). Kein pro Prozess +neu erzeugtes Wegwerf-Secret, fest eingebauter Schlüssel oder stillschweigend +fehlendes Secret. Ein Empfänger mit +HMAC-Policy weist unsignierte Nachrichten immer zurück. + +Das signierte Envelope enthält Protokollversion, `messageId`, fachlichen Typ, +Topic, Audience, UTC-`issuedAt`, optional `expiresAt`, Content-Type, Payload, +Correlation-Metadaten und Attachment-Deskriptoren inklusive Digest/Länge. +Anwendungsmetadaten und RPC-Felder (`requestId`, `replyTo`, Deadline, +Nachrichtenart, Notice-ID) sind ebenfalls Teil der geschützten Bytes. +Algorithmus und Key-ID sind ebenfalls kryptografisch gebunden. Ein versionierter +ProtectedFrame transportiert die **exakten ursprünglichen Envelope-Bytes** +und die Signatur; eine längenpräfixierte Signiereingabe mit Domain-Separator +verhindert mehrdeutige Konkatenation. Empfänger serialisieren zur Prüfung +nicht neu. Das Frame-Format muss vor Implementierung mit gemeinsamen +Testvektoren fixiert werden. + +Vor Hydration, Callback oder externem Dateiabruf wird mit lokal erlaubtem +Algorithmus/Key geprüft, konstantzeitlich verglichen und das Topic/Audience +mit dem erwarteten Routingkontext abgeglichen. Key-ID dient nur zur Auswahl +aus einem lokalen Keyring. Rotation erlaubt einen aktiven Signierschlüssel +und mehrere verifizierende Schlüssel; keine Algorithmuswahl allein aus +unverifizierten Headern. Der HMAC-Inhaber kann auch selbst signieren: +Shared Secret ist keine individuelle Absenderidentität. + +Zeitprüfung toleriert begrenzte Uhrabweichung und lehnt zukünftige oder +abgelaufene Nachrichten ab. Ein optionales maximales Alter muss zum gesamten +Queue-Backlog und Retry-Fenster passen; kein pauschales Fünf-Minuten- +Limit für dauerhafte Queues. Redelivery behält ID, Bytes und ursprüngliche +Signatur. Broker-Versuchszähler gehören nicht zum unveränderlichen Envelope. + +Signierung verhindert Replay allein nicht. Eine optionale Inbox speichert +`(audience, subscription, messageId)` mit Zuständen processing/completed und +begrenzten Leases. Erst erfolgreicher Abschluss markiert completed; +fehlgeschlagene Versuche dürfen erneut verarbeitet werden. Ein früher globaler +Nonce-Verbrauch würde legitime Wiederholungen und andere Subscriptions +blockieren. Atomizität zwischen fachlicher DB-Änderung und Inbox erfordert +Anwendungs-/Transaktionsintegration, nicht nur eine transportseitige Duplikaterkennung. + +Ungültige Signaturen werden ohne Callback quarantänisiert oder nach expliziter +Policy verworfen, niemals endlos wiederholt. Quarantäne speichert begrenzte +Originalframes und sanitisierte Gründe, keine Secrets. Später kann ein +PGP-Provider dieselbe Schnittstelle implementieren; Keyring, Trust-Modell, +Widerruf und optional Verschlüsselung gehören zu diesem Provider. TLS, +Broker-ACLs und sicherer Dateispeicher bleiben zusätzlich erforderlich. + +## § 9 ZIP-Dateien und große Payloads + +[Dateibeispiel: 04-files-and-local.php](../../examples/api-draft/04-files-and-local.php). +Die Anwendung übergibt `Attachment::fromPath(...)` oder einen Stream; +die Queue verschickt einen verifizierbaren Deskriptor. Ein konfigurierter +`PayloadStoreInterface` übernimmt Upload und spätere Auflösung. Binärdaten werden nicht unbeschränkt base64-kodiert in die Queue geschrieben. + +Der Deskriptor enthält einen opaken Store-Key, Größe, SHA-256, MIME-Typ, +Dateiname und Lebensdauer. Er ist Teil der Signatur; der Digest allein ist +keine Authentifizierung. Der Empfänger lädt ausschließlich über konfigurierte +Store-Adapter, niemals von beliebigen URLs aus Nachrichten. Größe und Digest +werden beim Streamen geprüft. `context->attachment('archive')->copyTo($path)` +schreibt zunächst begrenzt in eine temporäre Datei und veröffentlicht das +Ziel erst nach erfolgreicher Verifikation. ZIP wird nicht automatisch entpackt; +Zielpfad und Entpackungsgrenzen bestimmt die Anwendung. + +Upload geschieht vor Publish. Bei unbekanntem Publish-Ergebnis darf die Datei +nicht sofort gelöscht werden. Store-TTL/Lifecycle muss Queue-Retention, +Offline-Consumer, Retry und Dead-Letter-Aufbewahrung plus Reserve abdecken. +Fan-out verbietet Löschung nach dem ersten Ack; initial wird konservative +Lifecycle-Bereinigung statt verteilter Referenzzählung vorgeschlagen. +Verwaiste Uploads werden nach einer Sicherheitsfrist bereinigt. Ein zu früh +abgelaufenes oder fehlendes Objekt erzeugt `AttachmentUnavailableException`. + +Der injizierte Dateispeicher muss für Sender und Empfänger erreichbar sein; +RabbitMQ speichert ausschließlich die Referenz, keine automatisch verwaltete ZIP-Datei. Begrenzungen gelten +für Dateigröße, Zahl der Attachments, Downloads und temporären Speicher. +Automatisches Offloading beliebig großer JSON-Bodies sowie Chunking mit +Reassembly sind spätere Erweiterungen und kein impliziter Bestandteil von +`publish`. Ohne Store oder bei zu großem Frame folgt eine eindeutige Exception. + +## § 10 Lokale Entwicklung mit RabbitMQ + +Die Entwicklung verwendet denselben Adapter wie der spätere Betrieb. +[compose.yaml](../../deployment/rabbitmq/compose.yaml) startet einen einzelnen +RabbitMQ-Knoten mit Management-Plugin und Demo-Namespace. Ports sind nur an +Loopback gebunden. Das benannte Volume überlebt Neustarts; `down -v` entfernt +gezielt den temporären Demo-Zustand. Ein einzelner Quorum-Knoten besitzt keine +Ausfallredundanz. Anleitung und ausführbare Befehle: [Setup](../setup.md). + +[setup.php](../../deployment/rabbitmq/setup.php) übersetzt die neutrale +Konfigurationsdatei in RabbitMQ-Deklarationen über dessen HTTP-Management-API. +Es benötigt PHP >=8.5 CLI mit `allow_url_fopen=1`, keine installierte PhoreMQ-Library. `--dry-run` prüft und zeigt die +Operationen ohne Verbindung. Das Skript legt nichts durch Publish an und führt +keine Handler aus. Es löscht keine Ressourcen; entfernte Konfigurationseinträge +entfernen daher keine existierenden Queues. Migrationen sind explizite Vorgänge. + +## § 11 Exceptions und Diagnose + +Alle Library-Exceptions implementieren ein gemeinsames +`MessageQueueException`-Markerinterface und erben von passenden PHP- +Standardexceptions. Kontext: Fehlercode, Phase, redigierter Konnektorname, +Topic/Subscription, verifizierte `messageId`, optional Feldpfad und +`previous`. Unverifizierte Metadaten sind als solche markiert. Kein kompletter +Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. + +| Exception | Beispiel / Behandlung | +|---|---| +| `InvalidDsnException` | Ungültiger Port oder unbekannte Option; Konfiguration korrigieren | +| `MissingDependencyException` | RabbitMQ-Client oder benötigte Schema-Bridge fehlt; vor Workerstart abbrechen | +| `QueueConfigurationConflictException` / `TopologyVerificationException` | Deklaration widerspricht bestehender Topologie oder kann nicht vollständig geprüft werden | +| `QueueConfigurationMissingException` | Fachliches Topic fehlt oder keine passende Subscription; Grund `TOPIC_MISSING` oder `NO_MATCHING_SUBSCRIPTION` | +| `ConnectionException` / `AuthenticationException` | Netzwerkproblem retrybar; falsche Credentials nicht endlos wiederholen | +| `PublishException` | Annahme fehlgeschlagen oder unbekannt; `outcome` = rejected/unknown | +| `ReplyNotEnabledException` | await auf ohne Rückkanal gesendeter Nachricht; kein nachträgliches Senden | +| `PendingCapacityExceededException` | Lokale Kapazität für weitere antwortfähige Sends erschöpft; vor Publish ablehnen | +| `MessageMappingException` / `InvalidHandlerException` | `MAPPING_INCOMPLETE`, `MAPPING_CONFLICT`, `DUPLICATE_SUBSCRIPTION` oder mehrdeutige Reflection; Details in § 6.2 | +| `SerializationException` / `InvalidEnvelopeException` | Nicht unterstützte Payload oder defekter Frame; endgültig | +| `MessageValidationException` | `user.created.v1: $.email: required property is missing` | +| `MessageHydrationException` | Konstruktor-/Property-Zuweisung gescheitert; Schema-Exception als previous | +| `InvalidSignatureException` / `ExpiredMessageException` | Keine Verarbeitung; Security-Failure-Policy | +| `PayloadTooLargeException` / `PayloadStoreRequiredException` | Brokergrenze überschritten oder Dateispeicher fehlt | +| `AttachmentUnavailableException` / `AttachmentIntegrityException` | Storefehler ggf. retrybar; falscher Digest endgültig | +| `RetryableMessageException` / `RejectMessageException` | Explizite fachliche Wiederholung bzw. endgültige Ablehnung | +| `SettlementException` | Ack fehlgeschlagen oder Delivery-Channel geschlossen; Duplikate berücksichtigen | +| `FailureStoreException` | Sichere Fehlerablage fehlgeschlagen; kein Ack, Worker abbrechen | + +Validierung meldet konkrete Pfade und erwartete Typen, aber keine sensiblen +Istwerte. Nicht passende Typen werden im Binding gefiltert. Ein dennoch +zugestellter unpassender Typ ist ein sicher abzulegender Routingfehler; fehlt +einem Handler ein benötigtes Schema, ist dies ein Mappingfehler. Nicht explizit klassifizierte Handler-Exceptions werden +begrenzt wiederholt und anschließend abgelegt. Syntax-/Konfigurationsfehler +sind keine Nachrichten-Retries. + +RPC ergänzt `RequestTimeoutException`, `RemoteCommandException`, +`InvalidReplyException` und `RpcNotConfiguredException`. Die lokal vom +Responder geworfene `CommandFailedException` beschreibt einen ausdrücklich +freigegebenen fachlichen Fehler; über den Rückkanal geht nur dessen sicheres +Fehlerobjekt. Ein Fehlerlevel in einer Begleitmeldung ist kein terminaler +Command-Fehler und ändert die Settlement-Entscheidung nicht. + +### § 11.1 Wenn ein Callback eine Exception wirft + +[Beispiel 10](../../examples/api-draft/10-callback-errors.php) zeigt die Fälle +direkt im Handler; [Beispiel 06](../../examples/api-draft/06-metadata-middleware.php) +zeigt das sichere Weiterwerfen nach Diagnose. Bei Auto-Ack bedeutet eine +Exception vor Settlement: kein Erfolgs-Ack. Die Runtime fängt behandelbare +`Throwable`s an der Handlergrenze ab und entscheidet bei dauerhaften Profilen +nach folgender Policy; flüchtiger Broadcast folgt § 7.2. + +| Callback-Ergebnis | Standard im Entwurf | +|---|---| +| Normale Rückkehr | Ack nach erfolgreicher Verarbeitung | +| `RetryableMessageException` | Begrenzter Retry mit Verzögerung; kein unendliches Erzwingen | +| Andere unbehandelte Exception, einschließlich `TypeError` | Ebenfalls begrenzt wiederholen; nach Ausschöpfen sichere Fehlerablage und Alarm | +| `RejectMessageException` | Sofort endgültig in die Fehlerablage, kein Retry | +| `CommandFailedException` oder freigegebene `RemoteException` (§ 13.5) in `respond` | Bewusster fachlicher RPC-Fehler: sichere finale Antwort, danach Ack; keine technische Wiederholung | +| Infrastrukturfehler bei Retry/Ack/FailureStore | Kein vorgetäuschter Erfolg; `run` wirft Infrastruktur-Exception, unbestätigte Nachricht bleibt wiederholbar | + +Vorgeschlagene Default-Policy: höchstens vier Versuche insgesamt, also drei +Wiederholungen, jeweils mit 10 Sekunden Verzögerung. Feste Wartequeues halten +den ersten Retry-Aufbau überschaubar; frei wählbarer Jitter ist nicht vorgesehen. `context->attempt` beginnt bei 1 und wird dauerhaft je Zustellung an +eine Subscription geführt; bestätigte Retry-Übergaben erhöhen den Zähler +dauerhaft. Prozessneustart setzt diesen Stand nicht zurück. Ein Crash vor +der Retry-Übergabe kann denselben Versuch wiederholen: vier Versuche sind eine +Grenze der regulären Handler-Retry-Runden, keine Exactly-once-Ausführungszählung. Die Werte stehen ausschließlich in `SubscriptionOptions::queue` als +`QueueOptions(maxAttempts: 4, retryDelaySeconds: 10)`; ein separates `retryPolicy` entfällt. Diese Defaults sind unsere Designentscheidung, keine Zusage des +Brokers. Ein laufender Retry blockiert nicht durch sleep den ganzen Worker; +der Job wird verzögert wieder verfügbar. + +Nach endgültiger Ablehnung oder ausgeschöpften Versuchen wird zuerst der +FailureStore sicher bestätigt, dann die Ursprungszustellung beendet. Ohne +verfügbaren FailureStore kein Verwerfen und kein Ack; `FailureStoreException` +beendet den Loop. Der RabbitMQ-Adapter verwendet die zugehörige Fehlerqueue aus § 7. Fehlerdaten +enthalten ID, Subscription, Versuchszahl, Zeit und sichere Diagnose; Payloads +und Stacktraces sind nur in zugriffsgeschützter lokaler Ablage zulässig, nicht +ungefiltert in Events oder Frontend-Antworten. Manuelles Redrive erfolgt erst +nach Ursachenklärung mit erhaltenem Bezug und neuer expliziter Retry-Runde. + +Bei technischen RPC-Fehlern wartet der Client über die zulässigen Retries. +Nach endgültigem Scheitern sendet die Runtime, soweit Rückkanal und Deadline +es noch erlauben, einen generischen sicheren `HANDLER_FAILED`-Fehler; der +Client erhält `RemoteCommandException`. Rohtexte unerwarteter Exceptions +werden nie übertragen. Fehlerablage und terminaler Reply-Publish werden vor +Request-Ack bestätigt; technische Fehler dabei bleiben wiederholbar. Bei +nicht erreichbarem Rückkanal kann stattdessen `RequestTimeoutException` beim +Client eintreten. Für eine bekannte fachliche `CommandFailedException` oder freigegebene +`RemoteException` ist keine technische FailureStore-Runde nötig; die sichere +Antwort bleibt aber zu bestätigen. + +Ein behandelter Callback-Fehler beendet normalerweise nicht den Worker; +andere Jobs können weiterlaufen. Middleware darf die Exception loggen und +muss sie für korrekte Retry-/Ack-Entscheidung weiterwerfen. Prozesskill oder +Speichermangel sind nicht zuverlässig abfangbar: fehlendes Ack und das +Schließen des Delivery-Channels ermöglichen Recovery. Externe Seiteneffekte werden nicht zurückgerollt; +Idempotenz oder anwendungsseitige Transaktionen bleiben nötig. Ein bereits +manuell gesetztes Ack lässt sich durch eine spätere Exception nicht widerrufen. + +Orientierung: [RabbitMQ – Acknowledgements](https://www.rabbitmq.com/docs/confirms) +unterscheidet Bestätigung, Requeue und Dead Letter. Unser Entwurf verbietet +stilles Verwerfen ohne sichere Ablage und begrenzt auch ausdrücklich retrybare +Fehler. Abruf 2026-09-12. Spätere Tests: Retry-Zählung über Neustarts, Fehlerablage +ausgefallen, Middleware schluckt/erhält Fehler, RPC-Endfehler und manuelles Ack. + +## § 12 Paketgrenzen, spätere Prüfungen und Quellen + +In die Library gehören Transportvertrag, Registry, Worker-Lebenszyklus, +Serialization, optionale Schema-Bridge, Security-/PayloadStore-Schnittstellen +und konsistente Exceptions. Der RabbitMQ-Adapter gehört zur ersten Implementierung; seine PHP-AMQP- +Abhängigkeit wird bei der Implementierungsplanung festgelegt. SDK-Verträge lassen sich unabhängig +von Brokerinstallationen verteilen. + +Nicht in den Kern gehören fachliche DTOs, Business-Workflows, vollständige +Job-Scheduler, langfristige Workflow-/RPC-Ergebnisarchive, Cloud-Provisionierung, +Admin-UIs, Virenscanner, ZIP-Entpackung, PGP-Keyverwaltung oder eine eigene +verteilte Dateispeicherplattform. Erweiterungspunkte dürfen diese verbinden, +ohne den Grundvertrag damit zu belasten. Keine scheinbar universellen +Transaktionen, Prioritäten oder Exactly-once-Zusagen. Der nun beauftragte +RPC-Umfang bleibt eine optionale Request/Reply-Erweiterung gemäß § 13. + +Globale Lock-/Konsensverfahren gehören nicht in die MQ-Library. § 15 zeigt +Broadcast und das Einsammeln von Lock-Bestätigungen; die tatsächlichen +lokalen Leases und gegebenenfalls ein autoritatives Fencing-Verfahren +verantwortet ein separater Lock-Dienst der Anwendung. + +Für die spätere Umsetzung sind fokussierte Contract-Tests vorgesehen: +unabhängige Subscriptions versus Worker-Gruppe, Redelivery nach Crash, +Ack-Verlust, unbekanntes Publish-Ergebnis, lokale DTOs mit anderem Namespace, +verschachtelte Strukturen/required/null/zusätzliche Felder, manipulierte +Signaturen samt Metadaten, Rotation, Backlog-Zeitprüfung, fehlgeschlagene +Dateiprüfung, konkurrierende Consumer und Verbindungsabbrüche. Dieser +Entwurfs-PR fügt keine Laufzeitimplementierung oder Tests dafür hinzu. + +Primärquellen, abgerufen am 2026-09-12: + +- §§ 2–5, 7, 10: [RabbitMQ Queues](https://www.rabbitmq.com/docs/queues), [Exchanges](https://www.rabbitmq.com/docs/exchanges), [Confirms](https://www.rabbitmq.com/docs/confirms), [Quorum Queues](https://www.rabbitmq.com/docs/quorum-queues), [Management HTTP API](https://www.rabbitmq.com/docs/http-api-reference). + +- § 6.1: [phore/schema Hydrator](https://github.com/phore/phore-schema/blob/aa8e60ab3b371fc3503f2a7ec8e2a3a63305074c/src/Hydrator/Hydrator.php), [Validator](https://github.com/phore/phore-schema/blob/aa8e60ab3b371fc3503f2a7ec8e2a3a63305074c/src/Validator/Validator.php), [Nutzungsinfo](https://github.com/phore/phore-schema/blob/aa8e60ab3b371fc3503f2a7ec8e2a3a63305074c/.ai-usage-info.md). + +## § 13 RPC: Command, Rückgabewert und Begleitmeldungen + +[Beispiel 05](../../examples/api-draft/05-rpc.php) enthält Verbindung, +programmatischen Responder, alternativ denselben Handler per `#[Respond]`, +Parameterübergabe, Ergebnis, Warning und Fehlerbehandlung in getrennten +Prozessen. `publish` und die optionale RPC-Komfortform `request` +veröffentlichen sofort und geben dasselbe `SendResult` zurück; erst dessen +`await()` blockiert auf die fachliche Antwort. Der Responder liefert mit `return` ein Array oder +DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. + +### § 13.1 Einmalige Konfiguration und Aufruf + +`ConnectionOptions::rpc` nimmt `RpcConnectionOptions` entgegen. Bei `enabled: true` +richtet der Adapter vor dem ersten antwortfähigen Publish eine private, zufällig +benannte Classic-Reply-Queue pro Verbindung ein: nicht dauerhaft, exklusiv und +auto-delete. Alle Clients nutzen eine gemeinsame interne Topic-Exchange unter +`replyNamespace` (Demo `_phore.rpc`), aber jede Queue erhält ein eigenes, exakt +eindeutiges Binding. Es entsteht keine Exchange je Container. Die interne +Exchange bleibt als begrenzte gemeinsame Infrastruktur bestehen. Diese Einrichtung +ist auch bei `autoCreate: false` erlaubt; Rechte dafür sind explizit erforderlich. + +Vor Publish müssen Queue, Binding und Consumer bestätigt eingerichtet sein; +anschließend wird die neue Request-ID im lokalen Register aufgenommen. Erst dann +geht der Request mit geschütztem `replyTo` und `requestId` an die Work-Queue. +`await()` betreibt den Dispatcher; es startet weder einen Hintergrundthread noch +sendet es erneut. Bis dahin puffert der Broker Antworten. Mehrere Aufrufe teilen +den Rückkanal ihrer Connection, niemals den eines anderen Publishers. + +Feste gemeinsame Reply-Queues und ein konfigurierbarer zentraler Demultiplexer +entfallen aus der ersten API. Der Adapter nutzt kein Direct Reply-to. Ein Reply-Ziel +wird auf reservierten Namespace, zulässige Identität und Authentizität geprüft; +eine UUID ist kein Berechtigungsnachweis. Rückkanal und Pending-Register werden +nach Anzahl, Bytes und Lebensdauer begrenzt (§ 13.6). + +`request` verwendet dieselben `PublishOptions` wie publish: `replyTimeoutSeconds` +(Default 30 Sekunden ab Senden), `metadata`, `correlationId`, `messageId`, +`expiresAt`, Attachments und offene Routingwerte. `reply: false` ist bei request +ungültig und wird vor Versand abgelehnt; null/true verlangt die konfigurierte +RPC-Schicht. request hat dieselben expliziten/DTO-Aufrufformen und Konfliktregeln +wie publish. `responseClass`, `errorTypes` und `onNotice` gehören ausschließlich +zu await; es gibt keine doppelte Konfiguration vor und nach dem Senden. [geändert] + +`SendResult::await(?AwaitOptions $options = null, ?float $timeoutSeconds = null, +?string $responseClass = null, ?callable $onNotice = null, +?array $errorTypes = null): Reply` verarbeitet +nur den internen Rückkanal dieser +Connection, keine beliebigen Business-Handler. Mehrere Pending-Requests +teilen einen Dispatcher, der nach Request-ID puffert; Anzahl und Speicher +sind begrenzt. Gleichzeitige/nestende `run`-/`await`-Loops auf derselben +Connection sind ungültig. Für RPC aus einem Handler eine separate Connection +und einen unabhängig laufenden Responder verwenden. +`PendingReply` entfällt als separater +Rückgabetyp im Entwurf; bestehende `request(...)->await()`-Beispiele bleiben +gültig. [geändert] + +`Reply` besitzt schreibgeschützte `payload`, `metadata` und `notices`. +`responseClass` hydriert `payload` strukturell nach § 6, ohne die PHP-Klasse +des Responders zu vergleichen. Ohne diese Option kommt ein Array zurück. +Metadaten und Begleitmeldungen werden nicht in das Ergebnis-DTO hineingemischt. +Ohne konfigurierte RPC-Schicht sind `request` und `respond` frühe +`RpcNotConfiguredException`s statt stiller Fire-and-forget-Aufrufe. + +### § 13.2 Nachrichtenvertrag und Zustellverhalten + +| Nachrichtenart | Body | Geschützte Metadaten | +|---|---|---| +| Request | Command-Parameter | `requestId` (= Request-`messageId`), `replyTo`, Deadline, `kind=request`, optionale fachliche `correlationId` | +| Result (`rpc.result.v1`) | Rückgabedaten | Ursprüngliche `requestId`, `kind=result`, eigene `messageId`, Antwortmetadaten und gesammelte Notices | +| Error (`rpc.error.v1`) | Sicheres Fehlerobjekt mit `code`, `message`, begrenzten `details` und optionalem `errorType` (§ 13.5) | `requestId`, `kind=error`, eigene `messageId`, gesammelte Notices | +| Notice (`rpc.notice.v1`) | `level`, `code`, `message`, begrenzte `details` | `requestId`, `noticeId`, eigene `messageId`, `kind=notice` | + +Command-Typ und Subscription bestimmen eine logische Responder-Gruppe, +deren Worker die Arbeit teilen. Fan-out an mehrere unabhängig ausführende +Services ist für ein RPC-Command nicht der Standard und wird durch bewusst +provisionierte Topologie verhindert. Ein unbekanntes Command bleibt ein +Mappingfehler; es löst niemals Reflection-Aufrufe auf vom Sender benannten +PHP-Methoden, Shell-Kommandos oder Klassen aus. + +Das erste passende verifizierte Result/Error beendet den Pending-Request. +Duplikate werden anhand Request-/Reply-ID behandelt; fremde oder verspätete +Antworten werden im eigenen Rückkanal nach konfigurierter Ablage-/Discard- +Policy bestätigt. Notices beenden den Request nicht. Der Client prüft +Signatur, Audience, Reply-Topic, Request-ID, erwarteten Antworttyp und Schema; +eine Korrelations-ID allein ist keine Authentifizierung. + +`publish(...)->await()` und `request(...)->await()` +warten absichtlich nur auf eine terminale Antwort. +Wer Antworten aller Teilnehmer braucht, verwendet `publish` plus eine +aggregierende Subscription wie in § 15.2; hierfür wird keine mehrdeutige +`request(all: true)`-Option oder zusätzliche Queue-Methode eingeführt. + +Bei vorhandenem Rückkanal folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack +des Requests. Für ein nachweislich verschwundenes Antwortziel gilt § 13.6. +Bei unklarer Antwortannahme bleibt der Request wiederholbar. +Ein Crash zwischen fachlicher Aktion, Reply-Publish und Request-Ack kann +mehrfache Ausführung/Replies erzeugen. Für verändernde Commands sind eine +idempotente Operation sowie ein persistenter Request-/Ergebnisspeicher mit +atomarer Anwendungsanbindung nötig: bekannte fertige Requests senden das +gespeicherte Ergebnis erneut, ohne die Aktion zu wiederholen. Dies ist keine +Exactly-once-Garantie der Queue und kein still aktivierter globaler Cache. + +Ein Timeout begrenzt nur das lokale Warten: Der Server kann noch arbeiten +oder bereits fertig sein. Vor Handlerstart wird die geschützte Deadline +geprüft; während der Ausführung ist Abbruch kooperativ und keine Zusage. +Timeout führt nicht automatisch zu erneutem Senden. Offene Handles werden +bei `close` beendet; Reply-Retention und Bereinigung sind konfiguriert. +Abgelaufene Requests/Replies können in die lokale Fehlerablage gehen, ohne +noch einen rechtzeitig ankommenden Remote-Fehler versprechen zu können. + +### § 13.3 Warnings, Fehler und Rückgabe-Metadaten + +Der zweite Responder-Parameter ist `RequestContext`, eine Spezialisierung +von `MessageContext`. Er stellt genau zwei zusätzliche Operationen bereit: +`notify(Notice $notice): void` und `setReplyMetadata(array $metadata): void`. +`Notice(level: 'warning', code: ..., message: ..., details: ...)` sendet eine +Begleitmeldung, ohne Parameter oder Ergebnisstruktur zu ändern. `info` und +`error` sind ebenfalls zulässig; `error` als Notice kann etwa einen behobenen +Teilfehler melden und ist ausdrücklich nicht gleichbedeutend mit Abbruch. + +`notify` sammelt eine begrenzte Notice-Liste und versucht die sofortige +Veröffentlichung am Rückkanal. Ein temporärer Notice-Publish-Fehler wird lokal +gemeldet und löst nicht allein eine erneute Command-Ausführung aus. Die +abschließende Result-/Error-Nachricht enthält die gesammelten Notices erneut, +damit verlorene oder überholte Zwischenmeldungen sichtbar bleiben. Beim +Erreichen des Limits folgt ein expliziter Truncation-Hinweis. Der Client +dedupliziert `onNotice` anhand `noticeId`; `Reply::notices` ist die finale +Zusammenfassung, keine zusätzlich ungefiltert auszugebende Ereignisliste. + +`CommandFailedException` erzeugt eine terminale Fehlerantwort mit freigegebenem +Code und Text; `await` wirft daraus lokal `RemoteCommandException` mit +`errorCode`, `requestId`, sicheren Details und Notices. Fremde PHP-Exception- +Klassen/Stacks werden niemals übertragen oder instanziiert. Temporäre +Infrastrukturfehler folgen zunächst der begrenzten Retry-Policy; endgültige +unbekannte Fehler werden als neutraler `HANDLER_FAILED` gemeldet und intern +abgelegt. Ein fehlerhafter `onNotice`-Callback darf den bereits laufenden +Command nicht erneut senden; er wird lokal gemeldet, der Dispatcher setzt +Empfang und Deadline-Verarbeitung fort. [geändert] + +### § 13.4 Sofort senden, anschließend optional warten + +```php +$sent = $mq->publish($command); // Sendet sofort, wartet nur auf Broker-Annahme. +// Andere lokale Arbeit ... +$reply = $sent->await(timeoutSeconds: 5); + +// Gleichwertig in einer Zeile, ohne zweites Sendekommando: +$reply = $mq->publish($command)->await(timeoutSeconds: 5); +$reply = $mq->publish('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3]) + ->await(new AwaitOptions(timeoutSeconds: 5)); +``` + +`Phore\MessageQueue\SendResult` hat eine readonly `receipt: PublishReceipt` und `await` mit der +Signatur aus § 13.1. Es ist ein bereits gesendeter Vorgang, kein verzögerter +Builder: weder `await` noch Destruktor oder `run` veröffentlichen ihn erneut. +`$mq->publish($event);` ohne weitere Verwendung sendet ebenso unmittelbar. +Fehlgeschlagene oder unklar bestätigte Broker-Annahme wirft bereits beim +Sendebefehl `PublishException`. Send-Middleware behält ihren internen +`PublishReceipt`-Vertrag; die Fassade ergänzt darüber das öffentliche +`SendResult`, ohne Middleware ein zweites Mal auszuführen. + +Weil `await` erst nach dem Senden aufgerufen wird, müssen Antwortfähigkeit, +Reply-Ziel, Korrelation und Wire-Deadline bereits beim Publish feststehen. +`PublishOptions::reply` ist nullable: null (Default) aktiviert den Rückkanal +für fachliche Nachrichten automatisch, wenn die Connection einen vollständig +konfigurierten RPC-Client besitzt; false sendet ausdrücklich ohne Rückkanal; +true verlangt ihn und wirft bei fehlender Konfiguration **vor dem Senden** +`RpcNotConfiguredException`. Ohne RPC-Client sendet der Default normale +Events. Ein späteres `await` auf einem nicht antwortfähigen `SendResult` +wirft `ReplyNotEnabledException` und kann das gesendete Event nicht nachträglich +in einen Request verwandeln. Fehler im konfigurierten Rückkanal führen vor +Publish zum Fehler, nicht zu stillem Rückfall ohne Antwortfähigkeit. + +Antwortfähige `publish`-Nachrichten tragen wie Requests signierte +`requestId`, `replyTo` und Deadline; ihr `kind=event` bleibt erhalten. +`respond` akzeptiert zusätzlich zu `kind=request` solche antwortfähigen +Events für seinen registrierten Typ und antwortet nach demselben Protokoll. +`subscribe` führt seinen Handler wie bisher aus und sendet auch bei gesetztem +Reply-Ziel **keinen** automatischen fachlichen Rückgabewert. Antwortfähigkeit +ändert weder Fan-out noch Worker-Gruppen. Interne Replies, Notices und +Health-Protokollnachrichten werden zwingend ohne neue Antwortanforderung +transportiert, damit keine Antwortschleifen entstehen. + +`PublishOptions::replyTimeoutSeconds` bestimmt die vor dem Senden signierte +Antwortfrist (Default 30 Sekunden ab Sendebeginn). `expiresAt` kann die Frist +zusätzlich verkürzen. `Phore\MessageQueue\Rpc\AwaitOptions(timeoutSeconds, responseClass, onNotice, errorTypes)` +steuert dagegen ausschließlich das lokale Warten und die lokale +Ergebnisdarstellung. Direkte nicht-null Await-Parameter überschreiben das +Optionsobjekt wie bei `run`. Das effektive Warten endet am früheren Zeitpunkt +aus lokaler Wartefrist ab `await` und ursprünglicher Antwortdeadline; ohne +lokalen Timeout gilt die verbleibende Antwortfrist. Längere Remote-Fristen +müssen vor Publish gesetzt sein und lassen sich mit `await` nicht verlängern. +Ungültige Await-Optionen werfen `InvalidArgumentException`; die Nachricht +ist zu diesem Zeitpunkt ausdrücklich bereits gesendet. + +Ein lokaler Timeout oder Ablauf der ursprünglichen Antwortdeadline ohne +rechtzeitiges finales Ergebnis wirft `RequestTimeoutException` mit `requestId`, +niemals null/false oder ein leeres Erfolgs-Reply; der entfernte Handler wird +nicht abgebrochen. Notices setzen keine Frist zurück. Solange die ursprüngliche Antwortfrist läuft, darf derselbe +Handle erneut warten, ohne erneutes Senden; eine bereits verifizierte terminale +Antwort wird bis zur Handle-Freigabe zwischengespeichert. Ein nach Ablauf erst +eintreffendes Result wird nicht mehr als rechtzeitige Antwort akzeptiert. +Bereits rechtzeitig empfangene finale Ergebnisse bleiben abrufbar. Die erste +Await-Ausführung fixiert `responseClass`, `errorTypes` und Notice-Callback für diesen Handle; +widersprüchliche spätere Änderungen sind ungültig, ein neuer lokaler Timeout +ist erlaubt. Notices werden pro Handle dedupliziert; vor `await` empfangene +Notices bleiben nur im begrenzten Puffer, dessen Overflow explizit gemeldet +wird, und sind nach Möglichkeit zusätzlich im finalen Reply enthalten. + +Der Client puffert nur begrenzt viele offene Vorgänge/Antwortbytes. Eine +erschöpfte Kapazität wird vor einem weiteren antwortfähigen Publish als +`PendingCapacityExceededException` gemeldet. Freigegebene Handles geben ihre +lokalen Slots frei; später eintreffende Replies werden sicher verworfen bzw. +nach der konfigurierten Ablagepolicy behandelt. Deadline und `close` räumen +verbliebene offene Vorgänge auf. Auch ohne `await` müssen Rückkanal-Retention +und Ressourcenlimits wirken; keine unbegrenzte Hintergrundwarteschlange und +kein impliziter Hintergrundthread. Bewusst reine Events können mit +`PublishOptions(reply: false)` den Antwortaufwand vermeiden. + +Bei fehlendem Responder oder einem reinen `subscribe`-Empfänger endet `await` +mit Timeout; Schweigen beweist nicht, dass niemand existiert. Ein gezielter +Health-Check kann vorab Bereitschaft prüfen, aber die Antwort nicht garantieren. +Ein Broker-Ack ist kein RPC-Ergebnis. Wer Antworten aller Subscriber benötigt, +verwendet weiter die explizite Aggregation aus § 15.2. `request` bleibt die +Komfortform, die Antwortfähigkeit zwingend verlangt, `kind=request` setzt und +`PublishOptions::replyTimeoutSeconds` als ursprüngliche Remote-Frist übernimmt; +eine zweite Promise-/Worker-API entsteht dadurch nicht. [geändert] + +Vorgesehene Contract-Tests: genau ein Publish mit/ohne/nach mehrfachem await, +Antwort vor await, lokaler Timeout und späteres Result, unverlängerbare +Wire-Deadline, fehlender Rückkanal/Responder, Notice-Puffergrenze, ignorierte +Handles, Kapazitätsgrenze, interne Antworten ohne Rekursion und direkte +Parameter versus Optionsobjekt. Beispiel 05 zeigt beide Sendeformen. + +### § 13.5 Eigene RPC-Exceptions mit freigegebener Meldung + +Der einfache Weg bleibt `throw new CommandFailedException(errorCode: ..., +publicMessage: ...)` im Responder und `catch (RemoteCommandException $e)` um +`await`. Es gibt keine Anwendungspflicht, Fehlernachrichten zu abonnieren oder +einen Fehler-Payload manuell auszuwerten; die Runtime verarbeitet das interne +`rpc.error.v1` und wirft lokal eine Exception. Timeout ist weiterhin eine +separate `RequestTimeoutException`. + +Für typisierte SDK-Fehler ist `#[RemoteError('math.division_by_zero.v1')]` +auf einer konkreten Unterklasse von `RemoteException` vorgesehen. +`RemoteException` erbt von `RemoteCommandException` und markiert explizit +für Übertragung freigegebene Fehler. Sein gemeinsamer Konstruktor lautet +`__construct(string $message, string $errorCode = 'REMOTE_ERROR', array $details = [])`; +SDK-Unterklassen überschreiben ihn nicht und benötigen keine zusätzlichen +Pflichtfelder. Der Server wirft beispielsweise +`new DivisionByZero('Division durch null ist nicht möglich.')`. Die Meldung +ist bewusst öffentlich; sensible Rohmeldungen dürfen nicht hineinkopiert werden. + +Der Client erlaubt lokale Klassen mit +`await(errorTypes: [DivisionByZero::class])` oder +`await(new AwaitOptions(errorTypes: [...]))`. Das ist eine lokale Allowlist, +keine Liste vom Sender. Der Resolver ordnet den stabilen RemoteError-Namen der +Klasse zu; doppelte Namen, ungeeignete Klassen/Konstruktoren oder fehlende +Attribute sind ungültige Await-Konfiguration. Ein gemeinsames SDK liefert +dieselbe Exception-Klasse auf beiden Seiten; alternativ darf der Client eine +anders benannte lokale Unterklasse mit demselben Fehlernamen erlauben. +Ohne passenden Eintrag wird `RemoteCommandException` mit derselben sicheren +Meldung geworfen. Typisierte Fehler sind deshalb auch generisch fangbar. + +Auf dem Wire bleibt es ein begrenztes Fehlerobjekt mit `errorType`, `code`, +`message`, freigegebenen JSON-`details` und verifizierter Request-Zuordnung. +Die Factory erzeugt ausschließlich einen lokal erlaubten Exception-Typ und +setzt den Request-Kontext aus der verifizierten Antwort. PHP-FQCN, Trace, +`previous` und beliebige Objektproperties werden nicht übertragen; kein +`unserialize` und keine allgemeine Throwable-Hydration über phore/schema. +`RemoteError` ist ein besonderer Fehlervertrag, kein `MessageType`, den +`publish` als normalen Event automatisch versendet. Erst das Werfen im +Responder löst die terminale Fehlerantwort aus. + +Nur eine ausdrücklich deklarierte `RemoteException` oder die bestehende +`CommandFailedException` darf den vorgesehenen sicheren Text exportieren. +Beliebige `Exception`-/`RuntimeException`-Unterklassen oder vom Handler +weitergeworfene generische Remote-Fehler werden nicht automatisch freigegeben: +für sie gelten begrenzter Retry und generisches `HANDLER_FAILED` aus § 11.1. +Typed Errors sind terminale fachliche Antworten ohne Retry; Veröffentlichung +vor Ack bleibt erforderlich. Ein kaputtes/unerlaubtes Fehlerframe wird nicht +als erfolgreiche Antwort oder als frei gewählte lokale Exception behandelt. + +Beispiel 05 enthält Server-Throw, generischen Client-Catch und typisierten +Client-Catch einschließlich Meldung. Vorgesehene Tests: gleiche/andere lokale +Klasse, unbekannter Fehlername, doppelte Allowlist-Namen, keine Offenlegung +technischer Rohfehler, manipulierter Fehlerframe, Timeout versus Remote-Fehler +und wiederholtes await ohne erneute Ausführung des Commands. + +### § 13.6 RPC bei mehreren Docker-Containern und Neustarts + +[05-rpc.php](../../examples/api-draft/05-rpc.php) enthält den Ablauf direkt als +Kommentare. Publisher A und B haben getrennte Verbindungen und Rückkanäle; +ihre zufällig eindeutigen Request-IDs trennen zusätzlich gleichzeitige Calls. +Worker derselben Subscription konkurrieren um die Requests. Sie senden die +Antwort ausschließlich an das geschützte aktuelle replyTo, mit der zugehörigen +requestId, nicht an ein allgemeines Ergebnis-Topic. + +| Ereignis | Verhalten und Konsequenz | +|---|---| +| Erfolgreiche Antwort | Erster verifizierter terminaler Reply beendet genau einen Call; Ergebnis vor Request-Ack bestätigt | +| Lokaler await-Timeout | RequestTimeoutException; kein Cancel und kein erneutes Senden; bei verbleibender Wire-Frist darf derselbe SendResult erneut warten | +| Wire-Deadline abgelaufen | Pending-Eintrag entfernen; späte/unbekannte Replies bestätigen und verwerfen, begrenzte Diagnose | +| close / Consumer-Cancel | Private Queue samt Binding löschen; die gemeinsame interne Exchange bleibt | +| Publisher hart beendet | Exklusive Queue wird gelöscht, sobald der Broker den Verbindungsverlust erkennt, ggf. erst nach Heartbeat-/Netzwerk-Timeout | +| Publisher neu gestartet | Neue Connection, neue Queue, leeres Register; alte PHP-Aufrufe und Antworten werden nicht übernommen | +| Worker vor Ack beendet | Unbestätigter Request kann erneut zugestellt werden, auch wenn die Geschäftsaktion bereits ausgeführt wurde | +| Reply-Ziel nachweislich verschwunden | Sichere Diagnose; nach gesichertem Ergebnis Request abschließen, keine erneute Geschäftsaktion nur wegen fehlenden Clients | +| Reply-Publish unklar / Verbindung weg | Kein Erfolgs-Ack; Wiederzustellung möglich, Ergebnis über Idempotenz wiederverwenden | + +Die Library begrenzt Reply-Queues auf 1000 Nachrichten und 16 MiB mit +reject-publish statt stiller Verdrängung; Reply-Nachrichten erhalten höchstens +die verbleibende Antwortfrist als TTL. Das Pending-Register nimmt höchstens 1000 +Calls pro Connection auf und weist weitere vor Publish ab. Diese internen +Startwerte sind Entwurfsentscheidungen. Cleanup erfolgt bei Dispatcher-/API- +Aktivität, spätestens bei close; ohne Eventloop gibt es keinen PHP-Hintergrundtimer. +Broker-TTL bleibt unabhängig davon wirksam. + +Für verändernde Commands muss die Anwendung eine stabile operationId außerhalb +des Containers speichern und bei bewusstem Wiederanlauf wiederverwenden. Eine +neue Übertragung bekommt trotzdem eine neue requestId und den neuen Rückkanal. +Der Worker prüft operationId plus Parameterfingerprint und speichert Aktion und +Ergebnis atomar in seiner Datenbank; konkurrierende Zugriffe brauchen Unique-Key +und Transaktionsschutz. Gespeicherte Ergebnisse werden in einen neuen Reply mit +der aktuellen requestId/replyTo verpackt. Externe Nebenwirkungen brauchen eigene +Idempotenz bzw. Outbox/Fencing. Der Ergebnisspeicher muss länger leben als die +zulässigen Wiederholungen; er ist eine Anwendungsabhängigkeit, kein Container-RAM. +Timeout bedeutet unbekannter Ausgang, nicht nachgewiesener Misserfolg. + +Grundlagen: [RabbitMQ PHP RPC](https://www.rabbitmq.com/tutorials/tutorial-six-php), +[temporäre/exklusive Queues](https://www.rabbitmq.com/docs/queues) und +[Confirms und Acks](https://www.rabbitmq.com/docs/confirms). Die konkreten +QueueOptions-, Cleanup- und Fehlerverträge oben sind Entscheidungen dieser Library. + +## § 14 Metadaten, Middleware und API-Entscheidung + +[Beispiel 06](../../examples/api-draft/06-metadata-middleware.php) zeigt +Trace-/Locale-Metadaten, eine Send-Middleware, eine Handler-Middleware und +das eigenständige Publizieren von Warnungen/Fehlern auf ein Diagnose-Topic. +Das ist sowohl mit normalen Events als auch mit RPC nutzbar. + +### § 14.1 API-Entscheidung + +Die öffentliche API bleibt klein und erklärt die Wirkung am Aufruf: +`publish` sendet sofort, `await` wartet auf eine Antwort, `subscribe` und +`respond` registrieren Handler, `run` verarbeitet Zustellungen. Ein zentrales +`PhoreMQ` bündelt Konfiguration und Lebenszyklus. Fachliche DTOs tragen optionale +Metadaten; Arrays und explizite Topic-/Typ-Angaben bleiben gleichwertig möglich. +RabbitMQ-spezifische Klassen werden nur bei direkter Adapter-Injektion benötigt. + +### § 14.2 Metadaten außerhalb des fachlichen Payloads + +`PublishOptions(metadata: [...])` für publish und request +tragen eine begrenzte JSON-kompatible Map. `MessageContext::metadata` ist +die schreibgeschützte Empfangssicht. Schlüssel unter `app.*` sind für die +Anwendung vorgesehen, etwa `app.traceId`, `app.locale`, `app.tenantId` oder +`app.source`. Systemfelder wie `requestId`, `replyTo`, Typ, Audience, +Deadline und Signatur dürfen darüber nicht überschrieben werden. Grenzen +für Schlüsselzahl, Tiefe und Bytes werden vor Versand geprüft. [geändert] + +Metadaten werden mit signiert; ihre Integrität ist damit geschützt, sie sind aber nicht automatisch +autorisiert. Tenant-/Benutzerangaben müssen gegen die authentifizierte +Verbindung/Identität geprüft werden. Kein implizites Weiterreichen von +Tokens oder sämtlichen eingehenden Metadaten. Antwortmetadaten werden +ausdrücklich gesetzt, etwa `app.worker` oder `app.durationMs`. Allgemeine +Warnings/Errors sind normale `diagnostic.v1`-Events mit Level, Code und +sicherem Text; ihre `correlationId` verbindet sie mit dem betreffenden Request. +Es gibt dafür keine zusätzliche Spezialmethode auf der Queue-Fassade. + +### § 14.3 Genau zwei optionale Middleware-Hooks + +`ConnectionOptions(sendMiddleware: [...], handleMiddleware: [...])` akzeptiert +Callables oder invokable Objects. Es braucht weder Basisklasse noch Service- +Locator. Die Verträge lauten `send(OutgoingMessage $message, callable $next): +PublishReceipt` und `handle(mixed $payload, MessageContext $context, +callable $next): mixed`. Das sind Callable-Signaturen, keine zusätzlich +aufzurufenden Queue-Methoden. `$next` wird im Normalfall genau einmal aufgerufen; +Exceptions propagieren. Rückgabewerte dürfen nicht verloren gehen. + +Send-Hooks laufen vor finaler Schema-/Envelope-Prüfung und Signierung; +`withMetadata` erzeugt eine neue lokale Nachricht mit zusammengeführten +Anwendungsmetadaten. Gesendete Retries verwenden die bereits geschützten +Bytes, ohne neue Trace-IDs oder Zeitstempel einzumischen. Handler-Hooks laufen +nach Signaturprüfung und Hydration, aber vor Result-/Error-Erzeugung und Ack; +sie ändern keine geschützten Eingangsbytes. Die Reihenfolge folgt der +Registrierung, mit Rückweg in umgekehrter Reihenfolge. Die mandatory +Security-/Settlement-Schritte sind keine entfernbaren Nutzer-Middleware. + +Die Diagnose-Middleware publiziert über eine separate, bereits konfigurierte +Connection ohne dieselbe Diagnose-Middleware, damit kein Fehler-Event wieder +ein Fehler-Event auslöst. Sie verwendet verifizierte Kontextdaten, redigiert +Texte und wirft den ursprünglichen Fehler erneut; der Fehler wird nicht +versehentlich als Erfolg bestätigt. Ein fehlgeschlagenes Diagnose-Publish +meldet sie lokal. Für garantiert vollständige Diagnosezustellung braucht es +eine persistente Outbox; ein erfolgreicher Business-Callback wird nicht nur +wegen eines Telemetriefehlers wiederholt. + +### § 14.4 Sicherheitsgrenzen und spätere Prüfung + +`replyTo` bezeichnet nur ein lokales logisches Topic aus der autorisierten +Allowlist, niemals eine DSN, URL oder vom Request gelieferte neue Verbindung. +Der Server prüft Reply-Zielberechtigung und Nachrichtenlimits vor Handlerstart; +im Mehrmandantenbetrieb gilt die Allowlist pro authentifiziertem Principal. +Der gemeinsame HMAC-Key des Beispiels allein trennt keine Mandanten. Replies, +Notices und Diagnose-Events benutzen dieselbe Schutzkette wie Events. +Fehlende Signaturen erhalten keine Antwort an untrusted Rückkanäle. + +Zusätzliche spätere Contract-Tests: unmittelbare Antwort vor Beginn von +`await`, parallele Request-IDs, doppelte/späte Replies, Timeout bei laufendem +Command, unzulässiges Reply-Topic, Warnung vor/nach Ergebnis, Notice-Duplikate, +Notice-/Diagnose-Publish-Fehler, Fehler im Notice-Callback, Schemafehler im +Result, Weitergabe eines Middleware-Rückgabewerts und Rückwurf der +ursprünglichen Exception. Keine solchen Laufzeittests in diesem Entwurfs-PR. + +Quellen für §§ 13–14, abgerufen am 2026-09-12: + +- [RabbitMQ: RPC mit PHP](https://www.rabbitmq.com/tutorials/tutorial-six-php). + +## § 15 An alle Subscriber oder an einen Worker + +### § 15.1 Dasselbe Topic, bewusst gewählte Subscriptions + +| Ziel | Subscription-Namen | Ergebnis | +|---|---|---| +| Alle beteiligten Dienste informieren | `locks-service-a`, `locks-service-b`, `locks-service-c` | Jeder Dienst erhält eine Kopie und kann separat antworten | +| Alle konkreten Instanzen informieren | Je Instanz ein stabiler eigener Name, etwa `locks-instance-17` | Jede erwartete Instanz erhält ihre eigene Kopie | +| Einen Job verteilen | Alle Worker: `text-processors` | Ein verfügbarer Consumer erhält die konkrete Zustellung zur Bearbeitung | + +Ein Topic kann beides gleichzeitig haben, etwa eine Worker-Subscription und +eine unabhängige Audit-Subscription. „An einen“ bedeutet deshalb nicht +weltweit exklusiv, falls daneben weitere Subscriptions existieren. Für die +Processing-Queue provisioniert man bewusst nur die ausführende Worker-Gruppe; +Audit-Consumer führen den Job nicht aus. Die ersten beiden Muster brauchen +die RabbitMQ-Bindungen: Jede unabhängige Subscription besitzt ihre eigene Queue. + +### § 15.2 Lock-Koordination: alle bekannten Teilnehmer antworten + +[Beispiel 07](../../examples/api-draft/07-broadcast-locking.php) verwendet +`publish('maintenance.locks', 'lock.acquire.v1', ...)`. Jeder Teilnehmer +besitzt eine eigene Subscription auf diesem Topic, nimmt eine lokale Lease +für seine Ressource und sendet `lock.state.v1` an den Rückkanal. Der Koordinator +abonniert den Rückkanal vor dem Broadcast und zählt **Teilnehmer-IDs**, keine +Nachrichtenanzahl. Doppelte Antworten erhöhen den Zähler nicht. Erst alle +positiven Antworten der festen Teilnehmerliste erlauben den nächsten Schritt. + +Die Liste ist ein Membership-Snapshot aus der Anwendungskonfiguration, keine +aus Queue-Subscriber-Zahlen erratene Größe. Erwartete Subscriptions werden +vor dem Lauf angelegt. Offline-Teilnehmer bleiben erwartet und führen zum +Timeout; neue Teilnehmer gehören erst zur nächsten Runde. Eine negative +Antwort oder die Akquise-Deadline bricht die Runde ab und löst einen +`lock.release.v1`-Broadcast aus. Jede Runde besitzt eine eindeutige ID; +alte/fremde Antworten werden verworfen. Je Rückkanal läuft nur ein +zuständiger Koordinator oder ein expliziter Demultiplexer. + +Der gezeigte `LocalLeaseManager` ist eine **Anwendungsabhängigkeit**, keine +MQ-API. Erwerb ist idempotent pro Ressource/Runden-ID, hat eine absolute +begrenzte Gültigkeit und verlängert sich bei Redelivery nicht. Release gibt +nur die eigene Runde frei und hinterlässt bis zum Ablauf eine Abschlussmarke, +damit verspätete Acquire-Nachrichten einen freigegebenen Lock nicht erneut +nehmen. Leases laufen unabhängig vom Queue-Worker ab; dadurch bleiben bei +Koordinator-Crash oder verlorenem Release keine unbegrenzten Locks zurück. + +„Alle haben ihren lokalen Lock bestätigt“ ist eine koordinierte Barriere, +kein Beweis eines linearisierbaren globalen Locks oder dauerhafter Gesundheit +aller Teilnehmer. Die kritische Arbeit muss vor der kleinsten sicheren +Lease-Deadline enden; Clock-Skew und Ausführungszeit brauchen Reserve. Ein +einfacher Zeitvergleich in PHP verhindert keine Pause nach dem Vergleich. +Für geschützte Schreibzugriffe muss die Zielressource deshalb veraltete +Operationen über einen autoritativen monotonen Fencing-Token ablehnen. +Die Runden-ID ist nur Korrelation/Ownership, kein solcher Fencing-Token. +Quorum/Konsens, Membership-Änderung und Lease-Verlängerung sind hier bewusst +keine Behauptung der Queue-Abstraktion. + +Teilnehmer-IDs aus Reply-Payloads sind allein nicht vertrauenswürdig. Das +Beispiel setzt kooperative Teilnehmer mit gemeinsamer Entwicklungs-Identität +voraus. Produktion muss jede Antwort einer erlaubten Teilnehmeridentität +zuordnen, etwa über getrennte Signing-Keys/Principals oder getrennte +Reply-Topics mit durchgesetzten Publisher-ACLs. Ein gemeinsamer HMAC-Key +beweist nicht, welcher Teilnehmer tatsächlich den Lock besitzt. + +### § 15.3 Processing-Queue: ein Worker verarbeitet und antwortet + +[Beispiel 08](../../examples/api-draft/08-processing-workers.php) startet +mehrere Prozesse mit `respond('jobs.text', 'text-processors', ...)`. +**Der Subscription-Name bleibt bei allen Workern identisch.** Der Adapter +erzeugt getrennte Transport-Consumer-IDs; eine Worker-ID dient im Beispiel +nur als Antwortmetadatum, nicht als neue Subscription. Der Client ruft +`request('jobs.text', 'text.process.v1', $params)->await()` auf und erhält +Payload und die Kennung des verarbeitenden Workers zurück. + +RabbitMQ verteilt an verfügbare Consumer unter Berücksichtigung ihres Prefetch-Limits. „Random“ wird hier als „beliebiger verfügbarer Worker, +ohne feste Zielinstanz“ verstanden. Gleichmäßiger Zufall, Round-robin oder +garantierte Fairness sind kein portabler Vertrag; auch mehrere Jobs +hintereinander beim selben Worker sind zulässig. Wer eine bestimmte +Verteilungsstrategie benötigt, braucht einen gesonderten Scheduler. + +Pro Zustellversuch wird ein Consumer ausgewählt; ein normaler Job wird +nicht an alle Worker kopiert. Bei Crash, verlorenem Ack oder Verbindungsabbruch +kann derselbe Job dennoch erneut zugestellt werden. Ein pausierter alter +Worker kann nach Verlust seines Channels sogar noch weiterlaufen, während ein neuer +übernimmt. „Nur ein Worker“ ist daher keine Exactly-once-/Seiteneffektgarantie: +lange Verarbeitung braucht passende Ack-Fristen und Heartbeat-Verarbeitung, kritische Aktionen benötigen +Idempotenz oder ressourcenseitiges Fencing. Das Beispiel verarbeitet reinen +Text ohne externe Seiteneffekte; Ergebnis-Publish erfolgt gemäß § 13 vor +Request-Ack. + +### § 15.4 Spätere Prüfungen und Quellen + +Vorgesehene Contract-Tests: Broadcast an drei Subscriptions versus drei +Worker einer Gruppe, doppelte Teilnehmerantworten, fehlender/negativer +Teilnehmer, spätes Acquire nach Release, Koordinator-Crash, veraltete Lease, +falsche Teilnehmeridentität und erneute Job-Ausführung nach Verbindungsabbruch. +Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. + +- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). + +Abruf: 2026-09-12; die konkrete API und die Barrierenlogik sind der +hier vorgeschlagene Anwendungsentwurf. + +## § 16 Standardisierter Systemcheck und Dienststatus + +### § 16.1 Ziel und kleine Schnittstelle + +Frontend-Backends und Monitoring sollen frühzeitig erkennen, ob die für eine +Nachricht benötigten Dienste bereit sind, und dieselbe verständliche Ursache +erhalten. Ein bekanntes Berechtigungsproblem soll den betroffenen Handler +deaktivieren können, während der Prozess seinen Zustand weiterhin meldet. +Der Check ist eine zeitlich begrenzte Bereitschaftsaussage; er kann spätere +Laufzeitfehler oder einen Ausfall unmittelbar nach der Prüfung nicht ausschließen. + +```php +$connection = $mq->check(); // Verbindung + lokale Konfiguration, keine Consumer-Zusage. +$system = $mq->check('jobs.text', 'text.process.v1'); // Erwartete Consumer aus Konfiguration. +if (!$system->ready) { + // Funktion im Frontend als momentan nicht verfügbar kennzeichnen. +} +``` + +Die vorgeschlagene Signatur ist `check(?string $topic = null, ?string $type = +null, ?CheckOptions $options = null): HealthReport`. Topic und Typ werden +gemeinsam angegeben oder gemeinsam weggelassen. Mit +`check(options: new CheckOptions(requireDeclared: true))` werden sämtliche +in `HealthOptions::requirements` deklarierten Nachrichtenabhängigkeiten +unter einem gemeinsamen Zeitbudget geprüft; ohne diesen ausdrücklichen +Schalter bleibt `check()` der reine Verbindungscheck. Eine leere +Anforderungsliste bei `requireDeclared: true` ist ein Konfigurationsfehler. +`requireDeclared: true` zusammen mit einem expliziten Topic/Typ ist ungültig. `CheckOptions` enthält +`timeoutSeconds` (Default 3 Sekunden Gesamtbudget), optional +`requiredSubscriptions`, `minReadyPerSubscription` (Default 1) und +`requiredInstances`. Eine `ReadinessRequirement` in `ConnectionOptions::health` +legt diese Anforderungen je Topic/Typ einmalig fest. Fehlt eine erwartete +Topologie, meldet der gezielte Check `EXPECTED_CONSUMERS_UNDEFINED`/unknown; +eine leere Antwortliste ist niemals automatisch ein gesunder Zustand. + +Der reine Verbindungscheck verwendet nur native, nicht verändernde +Operationen. Ein gezielter Check erzeugt begrenzte Probe-/Reply-Nachrichten +im reservierten Health-Kanal, aber keine fachlichen Jobs, Test-Logins, +Dateiuploads oder Handler-Aufrufe. `autoCreate: false` bleibt wirksam: fehlende +Health-Ressourcen werden gemeldet und nicht im Check heimlich angelegt. +Ein fehlgeschlagener initialer `connect`-Aufruf bleibt eine Connection-/Auth- +Exception; `check` untersucht eine bereits erzeugte Connection erneut. + +### § 16.2 Was geprüft wird und welche Aussage daraus folgt + +| Prüfung | Positive Aussage | Grenzen / mögliche Probleme | +|---|---|---| +| Verbindung | Native Brokeranfrage mit den konfigurierten Zugangsdaten gelingt | Kein Nachweis für Publish-/Consume-Rechte auf allen Topics | +| Lokale Konfiguration | Mapping, installierter Connector, Schema-Metadaten und Security-Konfiguration sind auflösbar | Keine Ausführung eines DTO-Konstruktors/Business-Handlers als Probe | +| Ziel-Topologie | Topic/Subscription/Binding existieren, soweit der Adapter sie prüfen darf | Ohne Prüfmöglichkeit/Rechte unknown, niemals erfundener Erfolg | +| Consumer-Bereitschaft | Aktuelle Antworten der erwarteten Gruppen/Instanzen, registrierter Handler für Typ, aktive Consume-Bindung und keine blockierende Störung | Consumer-Zähler allein reichen nicht | +| Anwendungsabhängigkeiten | Benannte Prüfungen melden z. B. Datenbank, Ausgabeverzeichnis oder Fremddienst bereit | Nur tatsächlich geprüfte Abhängigkeiten; Probe muss seiteneffektfrei sein | +| Betriebsprobleme | Optionale, aktuelle Werte für Rückstau, älteste Nachricht, Pending/Retry/Dead Letter und letzte Fehler | Schwellen konfiguriert; nicht messbare Werte sind null/unknown | + +Ein erfolgreicher Health-Roundtrip beweist den Health-Pfad. Er beweist nicht +automatisch den fachlichen Publish-Pfad, dessen Berechtigungen oder die +Kompatibilität jeder späteren Payload. Der Bericht nennt deshalb jede +Prüfung mit Quelle, Zeitpunkt und Grenzen. Schema-/Versionsinformationen +werden als strukturelle Fähigkeit gemeldet; unterschiedliche lokale PHP- +Klassennamen oder Schema-Fingerprints sind allein kein Inkompatibilitätsfehler. + +Für eine Processing-Gruppe reicht standardmäßig ein frischer bereiter Worker +pro erwarteter Subscription. Für Broadcast müssen alle erwarteten +Subscriptions bereit sein. Sind konkrete Instanzen zwingend, werden ihre +IDs zusätzlich als fester Snapshot vorgegeben. Ein gesunder Ersatzworker +genügt dann nicht anstelle einer ausdrücklich verlangten Instanz. Ein +ausgefallener optionaler Worker bei erfüllter Mindestkapazität kann einen +degraded-Bericht mit `ready=true` erzeugen. + +### § 16.3 Gemeinsamer Dienstzustand: aktiv melden und auf Ping antworten + +`HealthState(serviceId, instanceId)` besitzt die Operation +`set(string $check, HealthFinding $finding, ?string $topic = null, ?string +$type = null): void`. Topic und Typ müssen auch hier gemeinsam gesetzt oder weggelassen werden. +Ein benannter Check wird ersetzt statt als unendliche +Fehlerliste angehängt. `HealthFinding::healthy`, `degraded`, `unhealthy` und +`unknown` erzeugen typisierte Befunde mit stabilem Code, sicherem +`publicMessage` und einer konkreten Handlungsempfehlung `action`. Ohne +Topic/Typ betrifft der Befund den ganzen Dienst, sonst nur passende Handler. +Abhängigkeiten beginnen unknown, bis eine echte Prüfung vorliegt. + +`HealthOptions(state: $health, refresh: $probe, refreshIntervalSeconds: 5, +statusTtlSeconds: 20)` bindet das lokale Zustandsobjekt ein. `refresh` ist +ein begrenzter, seiteneffektfreier Callback `callable(HealthState): void`, der +die Befunde aktualisiert. Callback-Exceptions werden als `HEALTH_PROBE_FAILED` +erfasst; sichere Details bleiben lokal. Ein gleichbleibender Fehler wird +dedupliziert, bei Änderung entsteht unmittelbar eine Statusmeldung, zusätzlich +gibt es begrenzte Aktualisierungen mit Ablaufzeit. Die Zahlen sind +Entwurfsdefaults, keine universellen Betriebsintervalle. Vor dem Binden an +eine Connection aktualisiert `set` nur den lokalen Zustand; beim Start wird +dieser veröffentlicht. Synchrones PHP kann einen hängenden Probe-Callback +nicht präemptiv abbrechen: Abhängigkeitsclients müssen eigene kurze Timeouts +einhalten. Harte Ausführungsgrenzen benötigen Prozessisolation. + +Registrierte `subscribe`-/`respond`-Handler, tatsächliche Consume-Bindungen +und deren Zustand werden von der Library ergänzt. Eine Anwendung darf mit +`set(...healthy...)` eine fehlende Registrierung, Authentifizierung oder +geschlossene Verbindung nicht überstimmen. Probe-Antworten und aktive +`system.health.v1`-Events stammen aus derselben Snapshot-Erzeugung; ein +Frontend muss keine unterschiedlichen Fehlerformate je Dienst verstehen. + +Ein negativer Pflichtbefund pausiert nur die betroffene Verarbeitung. Der +Worker bleibt im Health-/Recovery-Loop erreichbar, prüft mit Backoff erneut +und nimmt Arbeit erst nach erfolgreicher Wiederherstellung an. Bei einem +Berechtigungsfehler wird also nicht einfach der Container beendet. Tritt +der Fehler nach der letzten Prüfung im Handler auf, setzt die Anwendung +den gleichen Befund und wirft eine passende Retry-/Reject-Exception; die +Library bestätigt die fehlgeschlagene Verarbeitung nicht als Erfolg. + +Die Runtime fragt für pausierte Ziele keine neuen Jobs ab. Bereits zugestellte +Nachrichten werden nach der Retry-Policy verzögert freigegeben oder +begrenzt gehalten, nicht engmaschig konsumiert und erneut veröffentlicht. +Health-Probes sind davon getrennt. Unterbrechungsschutz, sichere Fehlerablage +und die bestehenden Retry-Grenzen bleiben wirksam. + +### § 16.4 Health-Kanal, Ausfälle und Authentifizierung + +Health ist eine reservierte Erweiterung mit `system.health.probe.v1`, +`system.health.reply.v1` und `system.health.v1`. Ein Probe enthält Probe-ID, +Ziel-Topic/-Typ, erwartete Subscriptions/Instanzen und Deadline. Pro Instanz +gibt es eine eigene Control-Subscription; Antworten werden je verifizierter +Instanz und Probe-ID aggregiert, nicht wie normales RPC nach der ersten +Antwort beendet. Pflichtinstanzen werden nicht aus Antwortzahlen erraten. +Interne Health-Nachrichten durchlaufen Signierung und Größenlimits, aber +keine fachlichen Handler oder rekursiv meldende Diagnose-Middleware. + +Die Standardeinstellungen benutzen einen reservierten konfigurierbaren +Namespace, etwa `_phore.health.probes`, `_phore.health.status` und +`_phore.health.replies.`. Reply-Ziele sind lokal provisionierte, +berechtigte Namen, niemals DSNs aus einem Probe. Zugriffe, Service- und +Instanzzuordnung müssen über verifizierte Identitäten/Keys beziehungsweise +Broker-ACLs begrenzt sein. Ein gemeinsamer Entwicklungs-HMAC-Key ist keine +verlässliche Identität einzelner Dienste oder Mandanten. + +Verliert ein Dienst die Berechtigung für seinen fachlichen Kanal, kann er +über einen separat berechtigten Health-Kanal weiter antworten. Verliert er +auch diesen Zugang, kann er den Fehler **nicht über genau diese defekte +Verbindung zuverlässig melden**. Der Monitor markiert nach TTL/Deadline +den Zustand unknown/stale, mit letzter bekannter Ursache ausdrücklich als +historischer Information. Eine aktuelle Ursache ist dann nicht automatisch +bestimmbar; Timeout darf nicht als bewiesener Rechtefehler ausgegeben werden. + +Für unabhängig verfügbare Diagnose kann `HealthOptions::controlConnection` +eine explizit injizierte separate Verbindung mit begrenzten Rechten verwenden. +Ein `HealthOptions`-gebundener reiner Diagnose-Client kann über `run()` den +Health-State auch dann bedienen, wenn der Aufbau der Business-Connection +scheitert; die Anwendung trägt deren bekannte Fehlerursache in den geteilten +State ein. Für Totalverlust der MQ-Infrastruktur kann derselbe Snapshot +über einen externen HTTP-/Monitoring-Adapter exportiert werden. Weder +HTTP-Server noch neue Zugangsdaten werden implizit erzeugt. + +Eine zusätzliche Socket-Verbindung bedeutet keine parallele PHP-Ausführung. +Ein synchron blockierter Handler kann Probe-Antworten verzögern. Die +Health-Runtime kann zwischen Jobs und in Recovery-Phasen antworten; für +Antworten während beliebig langer blockierender Arbeit ist ein separat +überwachter Prozess oder ein ausdrücklich unterstütztes asynchrones +Laufzeitmodell erforderlich. Auch der Checker konsumiert ausschließlich +seinen internen Rückkanal; reentrante `check`-/`run`-/`await`-Loops auf derselben +Connection sind ungültig. + +### § 16.5 Versioniertes Ergebnis und stabile Fehlercodes + +`HealthReport` ist eine schreibgeschützte DTO-Struktur mit `toArray()` für +JSON-Ausgabe. Probe- und Push-Berichte teilen denselben Vertrag: +`reportVersion`, `reportId`, `kind` (instance/aggregate), `scope`, `checkedAt`, +`expiresAt`, `durationMs`, `status`, `ready`, `checks`, `consumers`, `issues`. +Ein Einzelbericht hat zusätzlich `serviceId`, `instanceId`, `generation` +(zufällige Start-ID) und `sequence` (monoton innerhalb dieser Generation). +Ein Aggregate enthält die ausgewerteten Einzelberichte/Referenzen. +Ein Check aller deklarierten Abhängigkeiten hat `scope=system` und zusätzlich +`targets`: eine Liste vollständiger Zielberichte mit `topic` und `type`. +Ein gezielter Bericht hat `scope=message` mit diesen beiden Zielfeldern. +`ready` des Systemberichts setzt alle als erforderlich deklarierten Ziele +voraus; eine frontendseitig optionale Funktion bleibt einzeln auswertbar. + +| Status | Bedeutung | Einfluss auf `ready` | +|---|---|---| +| `healthy` | Alle angeforderten Pflichtprüfungen aktuell positiv, keine bekannten Zusatzprobleme | true für den ausgewiesenen Scope | +| `degraded` | Pflichtanforderungen erfüllt, aber Warnung, optionale Messlücke oder reduzierte Redundanz | true, solange keine blockierende Schwelle überschritten ist | +| `unhealthy` | Mindestens eine Pflichtanforderung nachweislich verletzt | false | +| `unknown` | Mindestens eine Pflichtanforderung ungeprüft, nicht beantwortet oder veraltet | false | + +Aggregationsreihenfolge: nachgewiesener Pflichtfehler vor Pflicht-Ungewissheit, +danach degraded und healthy. Findings optionaler Prüfungen blockieren keine +erfüllte Bereitschaft, bleiben aber als Problem sichtbar. Ein Brokerfehler +blockiert den Gesamtscope, auch wenn ein alter Consumer-Bericht noch positiv +ist. `check()` ohne Ziel darf `ready=true` nur für `scope=connection` +ausweisen; Frontends dürfen daraus keine Freigabe einer Funktion ableiten. + +Checks enthalten `name`, `status`, `required`, `source`, `observedAt` und +`expiresAt`. Issues enthalten `code`, `severity`, `publicMessage`, `action` +und Ziel-/Dienstbezug. Consumer-Zeilen enthalten Subscription, Service, +Instanz, Handler-Typ, Zustand und Frische. Messwerte ohne Messung sind null, +nicht null Sekunden oder null wartende Jobs. Stabile Codes sind unter +anderem `BROKER_UNREACHABLE`, `AUTHENTICATION_FAILED`, `AUTHORIZATION_DENIED`, +`HANDLER_NOT_REGISTERED`, `SCHEMA_UNAVAILABLE`, `DEPENDENCY_UNAVAILABLE`, +`OUTPUT_PERMISSION_DENIED`, `CONSUMER_NOT_RESPONDING`, `STATUS_STALE`, +`HEALTH_CHANNEL_UNAVAILABLE`, `INSUFFICIENT_READY_CONSUMERS`, +`EXPECTED_CONSUMERS_UNDEFINED`, `METRIC_UNAVAILABLE` und `BACKLOG_HIGH`. + +Erwartete Betriebsprobleme werden im Report zurückgegeben, damit ein +Monitoringaufruf nicht beim ersten unerreichbaren Dienst abbricht. Ungültige +Check-Konfiguration wirft `InvalidHealthCheckException`; sonst bleibt die +Gesamtdeadline wirksam und unvollständige Teilprüfungen werden unknown. +Exceptions/OS-Fehler im Dienst werden gezielt auf Codes abgebildet, nicht +anhand beliebiger Fehlertexte erraten. Rohpfade, Zugangsdaten, Stacktraces +und interne Details gehören nicht in `publicMessage`. + +### § 16.6 Frontend, Login und Überwachung + +Beim Login kann das Backend einmal die für das Frontend benötigten +Topic-/Typ-Ziele prüfen und dem Browser pro Funktion `ready`, `status`, +`expiresAt` und freigegebene Fehlertexte liefern. Der Browser erhält keine +Broker-Credentials. Eine ausgefallene optionale Funktion muss nicht die +gesamte Anmeldung blockieren; die Anwendung legt fest, welche Funktionen +zwingend benötigt werden. Beispiel 09 zeigt eine solche Backend-Antwort. + +Ein zentraler Monitor abonniert `system.health.v1`-Events, aktualisiert +die Sicht und meldet Zustandswechsel frühzeitig. Push verkürzt die +Erkennungszeit; periodische aktive Checks und Ablaufzeiten decken verlorene +Events oder verschwundene Dienste ab. Ein einmaliger Login-Check bleibt +nicht für die ganze Sitzung gültig: nach Ablauf wird erneut geprüft oder +eine frische, autorisierte Monitor-Sicht verwendet. Recovery-Meldungen +heben einen Fehler erst nach erfolgreicher Prüfung auf. + +Der Collector dedupliziert generation/sequence pro Instanz und akzeptiert +keine Rückstufung auf eine ältere Sequenz. Eine neue Startgeneration wird +über eine aktuelle authentifizierte Probe oder vertrauenswürdige +Registrierung bestätigt, nicht anhand beliebiger verspäteter Events. Fremde +Probe-IDs, abgelaufene Berichte und ungültige Signaturen bleiben außerhalb +der aktuellen Bereitschaft. Ein einfaches `subscribe` allein ist noch kein +solcher vollständiger Collector; Beispiel 09 zeigt die Event-Anbindung. + +Die Benachrichtigungsintegration dedupliziert gleiche Ursachen und kann +Hysterese/Backoff verwenden. Sie ist ein Adapter, keine eingebaute E-Mail- +oder Browser-Push-Plattform. Runtime-Fehlerbehandlung, Exceptions und +Idempotenz bleiben auch nach positivem Check erforderlich. + +### § 16.7 Beispiel, Ausbaustufe und spätere Prüfungen + +[Beispiel 09](../../examples/api-draft/09-system-check.php) enthält einen +Worker mit standardisierter Schreibrechte-Prüfung, aktiver Statusmeldung +und automatischem Pausieren/Wiederaufnehmen sowie gezielte Checks, eine +Frontend-Antwort und ein Monitor-Abonnement. Es ist wie alle Beispiele +ausschließlich API-Entwurf, keine bereitgestellte Health-Implementierung. + +Erster Health-Ausbau: lokaler State, Verbindungsprobe, signierte Bereitschafts- +Probes, Instanz-/Gruppenaggregation, Push-/Recovery-Berichte und JSON-Vertrag. +Metrikadapter und externe Exporte sind optionale Erweiterungen. Vorgesehene +Tests: Broker erreichbar bei fehlendem Handler, Rechtefehler bei lebendem +Prozess, Status-Recovery, fehlende/falsche Instanz, Mindestkapazität versus +alle Teilnehmer, falsche Signatur, TTL/Sequenzen/Startgeneration, verlorene +Statusmeldung, blockierter Health-Kanal, hängender Probe-Callback und +Deadline-Verbrauch über mehrere Ziele. Keine fachlichen Nachrichten oder +automatischen Ressourcenänderungen durch einen Check. + +Primärquelle: [RabbitMQ Monitoring](https://www.rabbitmq.com/docs/monitoring) +für die Unterscheidung von Broker-, Queue- und Anwendungszustand; abgerufen +am 2026-09-12. Der einheitliche API-/Statusvertrag ist der hier vorgeschlagene +Entwurf, kein behaupteter branchenweiter Standard. + +### § 16.8 Deklarierte Abhängigkeiten und Listenerdiagnose + +Die Anwendung erklärt in `HealthOptions::requirements`, welche Topic-/Typ-Paare +sie benötigt. `ReadinessRequirement(topic, type, subscriptions, +minReadyPerSubscription: 1)` beschreibt die erwarteten verarbeitenden Gruppen. +Eine eigene `subscribe`-Registrierung erklärt dagegen, was diese Anwendung +selbst empfängt; daraus wird keine Abhängigkeit von fremden Verarbeitern +erraten. Beide Richtungen bleiben im Statusbericht sichtbar. + +Jede Consumer-Zeile enthält `subscription`, `serviceId`, `instanceId`, `topic`, +`type`, `status`, `ready`, `observedAt`, `expiresAt`, `issues` und optional +`diagnostics`. Der Runtime-Registry-Eintrag stammt aus tatsächlich registrierten +Handlern und der Consume-Bindung. Ein Listener kann also vorhanden, aber wegen +eines Rechtefehlers nicht bereit sein. Meldungen können nicht garantieren, +dass ein kurz danach abgestürzter Prozess noch vorhanden ist. + +`presence` unterscheidet `present`, `absent`, `unknown`. `present` verlangt einen +frischen verifizierten Instanznachweis. `absent`/`NO_LISTENER` ist nur zulässig, +wenn eine autoritative aktuelle Registry/Connector-Sicht die Abwesenheit für +diesen Scope bestätigt. Ein fehlendes Probe-Reply bedeutet ansonsten +`unknown` mit `CONSUMER_NOT_RESPONDING`; ein alter Eintrag `STATUS_STALE`. +Auch die Aussage „kein Listener“ ist daher mit Quelle und Messzeit versehen. +Die Library darf nicht aus einem leeren Antwortarray Abwesenheit beweisen. + +`HealthOptions::diagnostics` ist ein optionaler begrenzter Callback ohne +Parameter, der Betriebsdaten als Array liefert. Standardfelder sind `host` +(String oder null), `processMemoryBytes`, `processPeakMemoryBytes` und optional +`containerMemoryBytes` (je nichtnegative Integer oder null), mit gemeinsamer +`observedAt`-Zeit der Runtime. Prozesswerte im PHP-Beispiel messen den +PHP-Allocator, nicht RSS oder den ganzen Container. Containerwerte benötigen +einen eigenen passenden Adapter; Einheiten stehen im Feldnamen. Erweiterungen +verwenden einen anwendungseigenen Namensraum. Größenlimits und eine Allowlist +verhindern unbegrenzte Diagnose-Payloads. Ohne Provider fehlen die optionalen +Daten; das allein blockiert keine Bereitschaft. + +Hostnamen und detaillierte Betriebsdaten sind für berechtigte interne +Überwachung opt-in, nicht automatisch Teil einer Browserantwort. Eine hohe +Speicherzahl ist zunächst eine Messung. Erst eine explizite Schwellenprüfung +setzt etwa einen degraded-Befund; eine überschrittene blockierende Grenze +muss als Pflichtbefund definiert sein. Die Library erfindet keine universell +passenden Speichergrenzen. + +Beispiel einer Consumer-Zeile im standardisierten Bericht (synthetische Werte; +der vollständige Bericht hat zusätzlich die Felder aus § 16.5): + +```json +{ + "subscription": "export-workers", + "serviceId": "export-service", + "instanceId": "export-2", + "topic": "jobs.export", + "type": "export.create.v1", + "presence": "present", + "status": "unhealthy", + "ready": false, + "observedAt": "2026-09-12T12:00:00Z", + "expiresAt": "2026-09-12T12:00:20Z", + "diagnostics": { + "host": "worker-host-02", + "processMemoryBytes": 33554432, + "processPeakMemoryBytes": 41943040, + "observedAt": "2026-09-12T12:00:00Z" + }, + "issues": [{ + "code": "OUTPUT_PERMISSION_DENIED", + "severity": "error", + "publicMessage": "Der Exportdienst kann sein Ausgabeziel nicht beschreiben.", + "action": "Berechtigungen des Ausgabeziels prüfen.", + "serviceId": "export-service", + "instanceId": "export-2" + }] +} +``` + +Zusätzliche spätere Contract-Tests: deklarierte Mehrzielprüfung unter einem +Gesamtbudget, nachgewiesene Abwesenheit versus Timeout, vorhandener unbereiter +Listener, Dateneinheiten/null, Diagnose-ACL und Browser-Allowlist. + + +## § 17 API-Review: erkennbare Wirkung und zuständiges Objekt + +Jede Beispieldatei wurde aus zwei Perspektiven durchgegangen: Nutzerabsicht +(„Was muss ich anfassen?“) und Erst-Review („Was passiert an dieser Zeile?“). +Der [Beispielindex](../../examples/api-draft/README.md) nennt pro Datei den +Anwendungsort, die Reihenfolge und das beseitigte Missverständnis. Die Beispiele +sind Anwendungsentwürfe, keine ausführbare Runtime und keine automatisierten Tests. [neu] + +| Ich möchte … | Zuständiges Objekt / Aufruf | Sichtbare Wirkung | +|---|---|---| +| Broker/Signierung/Middleware konfigurieren | ConnectionOptions beim new PhoreMQ | Verbindung sofort aufbauen; Optionen danach als Snapshot | +| Eigene DTOs klassifizieren | MessageRegistry vor dem Konstruktor oder MessageType am DTO | Nur lokalen Vertrag definieren, keine Nachricht senden | +| Queue-Eigenschaften bestimmen | SubscriptionOptions.queue / Queue-Attribut | Bei subscribe/respond prüfen und ggf. fehlende Topologie anlegen | +| Für alle Subscriptions dieselben offenen Vorgaben setzen | ConnectionOptions.queueDefaults | Defaults; feste DTO-/Aufrufwerte dürfen nicht widersprechen | +| Event empfangen | subscribe oder Subscribe-Attribut + registerHandlers | Registrieren; Handler erst in run aufrufen; Rückgabe nicht versenden | +| Command verarbeiten und antworten | respond oder Respond-Attribut + registerHandlers | Registrieren; return im Handler wird zum RPC-Ergebnis | +| Event senden | publish mit reply:false | Sofort an Broker senden, keine Antwort anfordern | +| Command senden | request mit optionalen PublishOptions | Sofort senden und Rückkanal verlangen; Handler läuft entfernt | +| Auf genau diesen Auftrag warten | SendResult.await | Nur Antwort abholen; lokale Fehler-/DTO-Auswertung hier konfigurieren | +| Einen Listener abmelden | SubscriptionHandle.cancel | Lokal entfernen; dauerhafte Queue und Binding bleiben | +| Worker-Schleife beenden | PhoreMQ.stop | Nach laufendem Handler; Registrierung und Verbindung bleiben | +| Eigene Connection freigeben | PhoreMQ.close im finally | Loop/Verbindung und private Rückkanäle schließen | +| Metadaten eines empfangenen Auftrags lesen | MessageContext | Zustellungsbezogene Daten; keine globale Konfiguration | +| RPC-Warning/Antwortmetadaten setzen | RequestContext.notify / setReplyMetadata | Begleitmeldung bzw. Metadaten für diesen Reply | +| Dienstzustand melden | HealthState.set | Zustand für Publish/Ping/Readiness verändern; kein Business-Ergebnis | +| Bereitschaft prüfen | check(topic, type) oder requireDeclared | Begrenzte Netzabfrage; keine Ausführung des Commands | + +Optionen vor dem Senden verändern den neuen Frame. Das spätere Ändern eines +Optionsobjekts oder DTOs verändert keine bereits gesendete Nachricht. AwaitOptions +wirken nur auf den lokalen Warteaufruf. Ein ungültiger Await-Parameter bedeutet +folglich nicht, dass der Auftrag ungesendet geblieben ist. Nachträgliches await +kann einen ohne Rückkanal gesendeten Frame nicht zum RPC machen. [neu] + +SubscriptionHandle.cancel entfernt die lokale Registrierung; erneut abonnieren +ist anschließend möglich. Kein Entfernen einer dauerhaften Queue oder ihres +Bindings. stop gilt für den aktuellen Loop und wird beim nächsten run zurückgesetzt; +close ist idempotent, danach sind weitere Aktionen auf dieser Instanz ungültig. +Eine Funktion schließt nur eine Connection, die sie selbst erzeugt hat; injizierte +Connections bleiben Eigentum des Aufrufers. registerHandlers registriert nur +markierte Methoden des übergebenen Objekts, kein Verzeichnisscan und kein Workerstart. [neu] + +RequestOptions entfällt zugunsten von PublishOptions. Der Antwort-Timeout heißt +überall vor dem Versand replyTimeoutSeconds; timeoutSeconds bei await bleibt +ausschließlich das lokale Wartebudget. Vier reguläre Versuche bedeuten höchstens +drei Retry-Abstände, zusätzlich kommen Warte- und Ausführungszeiten hinzu. Eine +RPC-Frist von 15 Sekunden lässt daher bei zehn Sekunden Retry-Abstand nicht alle +vier Versuche zu. Fristen begrenzen Arbeit; maxAttempts garantiert ihre Ausführung +nicht. Kurze Rechenbeispiele verzichten absichtlich auf lange Fehler-Wartezeiten. [neu] + +Fehler werden an der passenden Grenze sichtbar: lokale Mapping-/Optionsfehler +vor I/O; fehlende Topologie bei Registrierung oder Publish; Handlerfehler in der +Runtime mit Retry/Reject; sichere Remote-Fehler erst beim await. Für unbekannte +endgültige RPC-Handlerfehler gilt einheitlich HANDLER_FAILED. Ein run-Limit ist +normale Rückkehr und ein Check liefert auch bei ungesunden Diensten einen Bericht; +beides darf nicht als erfolgreicher Geschäftsabschluss ausgelegt werden. [neu] diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..31a8f16 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,259 @@ +# RabbitMQ starten und PhoreMQ konfigurieren + +PhoreMQ wird zunächst ausschließlich für RabbitMQ umgesetzt. Der Adapter bleibt +hinter einem Interface; eine Brokerauswahl oder Treiberregistrierung gibt es nicht. +Die Anwendungsbegriffe sind **Namespace, Topic, Subscription und Nachrichtentyp**. + +**Bereits verwendbar:** Docker Compose und das PHP-Setup in diesem Guide. +**Noch Entwurf:** sämtliche `PhoreMQ`-Klassen und PHP-Beispiele. Der Container +installiert keine PHP-Library; `composer install` macht die Entwurfs-API nicht ausführbar. + +## 1. Eine temporäre Instanz starten + +Voraussetzung: Docker mit Compose v2 und PHP >= 8.5 CLI (für HTTP-Zugriffe mit `allow_url_fopen=1`). Alle Befehle laufen aus dem +Repository-Verzeichnis: + +```bash +docker compose -f deployment/rabbitmq/compose.yaml up -d --wait +php deployment/rabbitmq/setup.php +``` + +Der erste Befehl startet RabbitMQ mit Management-Plugin. Der zweite legt die +fachlichen Topics, Subscriptions und zugehörigen Fehlerqueues aus +[config/message-queue.json](../config/message-queue.json) an. Er kann nach +unveränderter Konfiguration erneut ausgeführt werden. Falls die Management-API +beim ersten Aufruf noch nicht bereit ist, den zweiten Befehl erneut ausführen. + +| Zugang | Wert | +|---|---| +| AMQP-Verbindung vom Host | `amqp://demo:demo@127.0.0.1:5672/demo` | +| Management-Oberfläche | `http://127.0.0.1:15672` | +| Benutzer / Passwort | `demo` / `demo` | +| Namespace | `demo` | + +Die Ports sind ausschließlich an Loopback gebunden. Die Zugangsdaten und der +explizit unsignierte Nachrichtenmodus sind Entwicklungswerte. Das Image +`rabbitmq:4.3-management` folgt der Patch-Serie; reproduzierbare produktive +Deployments pinnen einen geprüften Image-Digest. Docker-Dokumentation: +[offizielles RabbitMQ-Image](https://hub.docker.com/_/rabbitmq). + +Der Broker verwendet ein benanntes Volume. Ein Neustart erhält den Zustand: + +```bash +docker compose -f deployment/rabbitmq/compose.yaml restart +``` + +Wenn die Demo nicht mehr benötigt wird, entfernt dieser Befehl Container und +**alle Nachrichten und Einstellungen ihres Volumes**: + +```bash +docker compose -f deployment/rabbitmq/compose.yaml down -v +``` + +Ein anschließendes `up -d --wait` und `setup.php` ergeben einen frischen Demo-Broker. +Die Demo nutzt einen einzelnen Knoten, besitzt also keine Ausfallredundanz. +Für produktive Quorum Queues sind üblicherweise drei Knoten in getrennten +Ausfallbereichen vorgesehen. [RabbitMQ Quorum Queues](https://www.rabbitmq.com/docs/quorum-queues) + +## 2. Eine gemeinsame Konfiguration + +Die JSON-Datei enthält Verbindung, Laufzeitoptionen und fachliche Topologie. +Die Konfiguration spricht absichtlich nicht von Exchanges oder Bindings: + +```json +{ + "connection": "amqp://demo:demo@127.0.0.1:5672/demo", + "options": { + "autoCreate": true, + "managementUrl": "http://127.0.0.1:15672", + "maxInFlight": 1, + "security": {"mode": "unsigned"}, + "rpc": {"enabled": true, "replyNamespace": "_phore.rpc"} + }, + "topics": ["users"], + "subscriptions": [ + {"topic": "users", "name": "audit-users", "type": "user.created.v1"}, + {"topic": "users", "name": "billing-users", "type": "user.created.v1"} + ] +} +``` + +Dies ist ein verkürzter Ausschnitt; die vollständige Datei enthält auch `telemetry`. +Der Namespace steht im DSN-Pfad. Sonderzeichen in Benutzer, Passwort oder +Namespace werden percent-kodiert; der Namespace `/` lautet `/%2F` im DSN. +Ein Client in einem anderen Container desselben Compose-Netzes verwendet +`rabbitmq:5672` als Host und Port; `127.0.0.1` bezeichnet dort seinen eigenen Container. + +| Einstellung | Bedeutung | +|---|---| +| `connection` | Genau eine Verbindung; `amqp` lokal, `amqps` mit geprüften TLS-Zertifikaten im Betrieb | +| `topics` | Logische Kanäle, die das Setup vorher einrichtet | +| `subscriptions[].name` | Dauerhafter Gruppenname; mehrere Prozesse mit diesem Namen teilen Arbeit | +| `subscriptions[].type` | Ein exakter Nachrichtentyp; `null` empfängt alle Typen des Topics | +| `autoCreate` | Erlaubt die dynamische Anlage beim Registrieren von Listenern; Publisher legen nichts an; Standard false, Demo true | +| `managementUrl` | Expliziter Verwaltungsendpunkt für die vollständige Topologieprüfung; im Betrieb HTTPS und begrenzte Rechte | +| `maxInFlight` | Maximale Anzahl unbestätigter Zustellungen pro Consumer; kein zusätzlicher PHP-Thread | +| `security.mode` | In dieser isolierten Demo explizit `unsigned`; produktiv eine passende HMAC-Policy konfigurieren | +| `rpc.enabled` | Bereitet pro Client einen eigenen Rückkanal vor, damit späteres `await()` möglich ist | +| `rpc.replyNamespace` | Reservierter Bereich für interne Antwortziele; keine gemeinsame konkurrierende Reply-Queue | + +`setup.php` verarbeitet ausschließlich `connection`, `topics` und `subscriptions`; +`options` sind Vorgaben für die geplante PHP-Library. Das Skript ist ein lokales +Entwicklungswerkzeug mit festem Management-Endpunkt `127.0.0.1:15672`. Es erstellt +keine Benutzer oder Namespaces; die Demo-Werte dafür setzt Compose beim ersten +Start des frischen Volumes. Bei geänderten Zugangsdaten beide Konfigurationen +aufeinander abstimmen. Bestehende Volumes werden durch geänderte Docker- +Initialisierungsvariablen nicht automatisch migriert. + +## 3. Was wird aus einem Topic oder Subject? + +| Anwendungsbegriff | Konkrete RabbitMQ-Ressource | +|---|---| +| Namespace `demo` | Virtual Host `demo` | +| Topic `users` | Dauerhafte Topic-Exchange `phore.topic:users` | +| Subscription `audit-users` auf `users` | Quorum Queue `phore.sub:users:audit-users` | +| Nachrichtentyp / Subject `user.created.v1` | Routing Key `user.created.v1`, keine eigene anzulegende Ressource | +| Typfilter der Subscription | Binding zwischen Exchange und Queue | +| Fehlerablage dieser Subscription | Quorum Queue `phore.failure:users:audit-users` | + +**Ein Subject muss nicht „eröffnet“ werden.** In diesem Entwurf ist damit die +Routingbezeichnung `type` gemeint. Es gibt bewusst keinen zweiten Parameter +`subject`. Beim Publish wird der Typ mitgeschickt. Die passende Subscription +muss bereits gebunden sein. `type: null` erzeugt intern ein `#`-Binding; exakte +Typnamen werden unverändert gebunden. Öffentliche Pattern-Filter gibt es zunächst nicht. + +Zwei Subscription-Namen ergeben zwei Kopien. Drei Worker derselben Subscription +teilen deren Queue. Eine Exchange allein bewahrt keine Nachrichten auf: +Wird vor der ersten passenden Bindung gesendet, bekommt eine später angelegte +Subscription diese Nachricht nicht nachträglich. Die geplante Library lehnt +nicht routbare Publishes ausdrücklich ab. [RabbitMQ Exchanges](https://www.rabbitmq.com/docs/exchanges) + +## 4. Dynamisch oder per Setup-Skript? + +Beides ist möglich, aber nur das PHP-Skript ist bereits vorhanden: + +| Vorgang | Bereits verwendbares Setup | Geplante PHP-Library | +|---|---|---| +| Fachliche Topologie vorher anlegen | Konfiguration bearbeiten, `setup.php` ausführen | Beim Start registrieren und mit `autoCreate: true` deklarieren | +| Bestehende Ressourcen weiterverwenden | Identische Deklarationen wiederholen | `autoCreate: false`, erwartete Topologie prüfen | +| Neues Subject verwenden | Exakten Filter ergänzen oder Subscription ohne Typfilter verwenden | `publish(topic, type, payload)`; keine eigene Subject-Ressource | +| Filter oder Queue-Eigenschaften ändern | Neue Subscription oder explizite Migration | Konflikt als Exception; keine automatische Migration | +| Ressourcen entfernen | Expliziter administrativer Eingriff | `cancel()` beendet nur den lokalen Consumer | + +Entwurfsbeispiel nach Implementierung, mit der gemeinsamen Demo-Konfiguration: + +```php +require_once __DIR__ . '/../examples/api-draft/connection.php'; + +$mq = new \Phore\MessageQueue\PhoreMQ( + ...\Examples\MessageQueue\demoConnection() +); +try { + $mq->subscribe('users', 'audit-users', function (array $event): void { + echo $event['userId']; + }, new \Phore\MessageQueue\SubscriptionOptions(type: 'user.created.v1')); + + // Die Binding-Anlage ist abgeschlossen, bevor die Nachricht veröffentlicht wird. + $mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); + $mq->run(maxMessages: 1, maxSeconds: 5); +} finally { + $mq->close(); +} +``` + +Das Beispiel ist aus einem hypothetischen PHP-Skript im Verzeichnis `docs/` +referenziert; die vollständigen Beispieldateien verwenden ihre eigenen relativen Pfade. +Weitere Topics und Subscription-Namen in den Beispielen dürfen wegen des +expliziten Demo-`autoCreate` dynamisch entstehen. Die Topologieliste im JSON ist +kein Handler-Register; Callbacks werden weiterhin programmatisch oder über +Attribute registriert. Ein neues Topic im JSON startet keinen Worker. + +Ohne `autoCreate` werden alle fachlichen Subscriptions einschließlich ihrer +internen Retry-/Fehlerressourcen vorab provisioniert. Das kleine PHP-Skript +zeigt die Basis- und Fehlerqueues; es provisioniert noch keine PhoreMQ-RPC-, +Health- oder Retry-Laufzeit. `rpc.enabled` erlaubt ausdrücklich die dynamischen +privaten Rückkanäle auch bei vorab angelegter fachlicher Topologie. Diese benötigen +eigene eingeschränkte Rechte. Health-Control-Ressourcen werden entsprechend der +expliziten Health-Konfiguration bereitgestellt; ein Check selbst legt nichts an. + +**Nein, ein Konfigurationsskript ist technisch nicht zwingend:** RabbitMQ erlaubt +Deklarationen über AMQP während des Betriebs. Vorab-Provisionierung ist eine +Betriebsentscheidung. Bestehende Queues mit unvereinbaren Eigenschaften können +nicht einfach neu deklariert werden. [RabbitMQ Queue-Deklarationen](https://www.rabbitmq.com/docs/queues) + +## 5. Prüfung und Grenzen + +```bash +php deployment/rabbitmq/setup.php --dry-run +``` + +Der Dry Run validiert die Topologiedaten und zeigt die geplanten Operationen, +ohne Zugangsdaten auszugeben oder eine Verbindung aufzubauen. Beim echten Setup +führen HTTP-, Berechtigungs- und Deklarationsfehler zu einem Fehlerstatus. Bereits +erfolgreich angelegte Ressourcen bleiben bei Teilfehlern bestehen. Das Skript +löscht weder Nachrichten noch veraltete Bindungen; Filterwechsel benötigen eine +bewusste Migration. Identische Wiederholung ist zulässig. + +Die Management-Oberfläche zeigt Queues und Consumer, beweist aber keine +fachliche Bereitschaft eines Dienstes. Dafür bleibt der standardisierte +PhoreMQ-Systemcheck vorgesehen. Die PHP-Beispiele brauchen später eine +Implementierung und einen laufenden Worker. Reine JSON-Nachrichten, die man +im Management-UI testweise veröffentlicht, sind noch keine gültigen signierten +PhoreMQ-Envelopes. [RabbitMQ Management](https://www.rabbitmq.com/docs/management) + +## Publisher melden fehlende Initialisierung + +Die automatische Anlage liegt beim Listener (`subscribe`/`respond`) oder beim +expliziten Setup. `publish` und `request` legen auch mit `autoCreate: true` keine +fachlichen Ressourcen an. Ein fehlendes Topic oder keine passende Subscription +führt direkt zu `QueueConfigurationMissingException` mit `reason`, `topic` +und `messageType`. Die Gründe sind `TOPIC_MISSING` und `NO_MATCHING_SUBSCRIPTION`. +Ein Beispiel zum Abfangen steht in [02-programmatic.php](../examples/api-draft/02-programmatic.php). + +Die Oberfläche kann daraufhin anzeigen: „Die Nachrichtenverarbeitung ist noch +nicht eingerichtet. Möglicherweise fehlt die Initialisierung des zuständigen +Dienstes.“ Ein vorhandenes Queue-Ziel ohne laufenden Worker nimmt dagegen +weiterhin Nachrichten an; aktuelle Dienstbereitschaft wird mit `check()` geprüft. +Berechtigungsfehler und Verbindungsprobleme bleiben gesonderte Fehler. + +## Queue-Profile und RPC in Containern (API-Entwurf) + +[11-queue-options.php](../examples/api-draft/11-queue-options.php) zeigt +`QueueOptions::workQueue()`, `::rpc()` und `::broadcast()` sowie `#[Queue]` am DTO. +Globale `ConnectionOptions::queueDefaults` füllen offene Werte. Feste DTO-Werte +und explizite Subscription-Werte müssen übereinstimmen. Unvereinbare vorhandene +Konfiguration führt zu `QueueConfigurationConflictException`, nötige Neuerstellung +zu `QueueMigrationRequiredException`; nicht unterstützte Optionskombinationen +zu `UnsupportedQueueOptionException`. Kein automatisches Policy-Update, auch +nicht allein wegen einer höheren Revision. Diese Regeln gehören zur geplanten +Library; das kleine setup.php legt weiterhin nur seine dokumentierten dauerhaften +Basis-/Fehlerqueues an und verarbeitet keine neuen Profil- oder Revisionsfelder. + +Work/RPC speichern Aufträge dauerhaft; maxAttempts zählt den Erstversuch mit, +retryDelaySeconds ist die feste Wartezeit zwischen regulären Retries (Default +vier Versuche, jeweils zehn Sekunden). Flüchtiger Broadcast verteilt an aktuell +registrierte Verbindungen ohne Offline-Garantie. Mit positiver Retention erhält +jede stabile Empfängergruppe eine dauerhafte Subscription; Ack entfernt ihre +Kopie früher. maxInFlight begrenzt offene Zustellungen je Consumer, startet keine +zusätzlichen Worker. Beispiele verwenden weiterhin dieselbe zentrale Verbindung. + +[05-rpc.php](../examples/api-draft/05-rpc.php) erklärt Publisher und Subscriber +als getrennte Container. Vor dem Request wird pro Publisher-Verbindung eine +private Reply-Queue samt Consumer eingerichtet; Request-ID und replyTo ordnen +Antworten zu. Mehrere Publisher teilen diese Queue nicht. Ein Timeout beendet +nur das Warten. close bzw. erkannter Verbindungsverlust entfernt die private +Queue samt Binding; die gemeinsame interne Exchange bleibt bestehen. +Ein neuer Container bekommt einen neuen Rückkanal und übernimmt keine offenen +Aufrufe. Für das Wiederaufnehmen schreibender Operationen müssen Vorgangs-ID und +Ergebnis außerhalb des Containers atomar gespeichert werden. Vollständige +Fehlerfenster und Cleanup-Limits stehen im Proposal §§ 13.2 und 13.6. + +Container im Compose-Netz verwenden den Dienstnamen rabbitmq statt 127.0.0.1; +die zentrale Connection muss dann `amqp://demo:demo@rabbitmq:5672/demo` und +managementUrl `http://rabbitmq:15672` enthalten. Der lokale setup.php-Aufruf läuft +weiter auf dem Host mit seiner Host-Konfiguration. Es werden nur zwei einzelne +Ports veröffentlicht, keine Port-Range: 5672 für AMQP und 15672 für Management. + +[Internes Docker-Deployment ohne Management-Plugin, optional SASL ANONYMOUS](../deployment/rabbitmq/HOWTO.md): Compose-Netzwerk und Datenvolume werden automatisch angelegt; das How-to erklärt vHost, Ports und die noch fehlende Kompatibilität mit der vollständigen PhoreMQ-Topologieprüfung. + +[Anwendungsbeispiele mit Ablauf- und Objektübersicht](../examples/api-draft/README.md): Sender/Worker getrennt, RPC über `request($dto)->await()`, Sendekonfiguration einheitlich in `PublishOptions`. Antwortfrist: `replyTimeoutSeconds` beim Versand; lokales Warten: `timeoutSeconds` bei `await`. diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php new file mode 100644 index 0000000..6bea049 --- /dev/null +++ b/examples/api-draft/01-connect.php @@ -0,0 +1,67 @@ +connect(...demoConnection()); +} + +// Aufrufer besitzt die hier zurückgegebene Verbindung und ruft close() in finally. +// Pro Prozess einmal erzeugen und weiterreichen. Kein Singleton. +// Konstruktor/Factory verbinden sofort; Fehler sind Exceptions, kein Lazy-Connect. +// Der Adapter gehört genau einem PhoreMQ; close() schließt seine Ressourcen. +// DTO-/Handler-Attribute bleiben erhalten; Verbindungsattribute entfallen. +// In der isolierten Demo ist unsigned ausdrücklich konfiguriert; kein automatisch +// erzeugtes HMAC-Secret. Produktion nutzt eigene Credentials, TLS und Security-Policy. +// Eine erfolgreiche Verbindung bestätigt noch keine Bereitschaft fremder Handler. diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php new file mode 100644 index 0000000..0e918ea --- /dev/null +++ b/examples/api-draft/02-programmatic.php @@ -0,0 +1,122 @@ +=8.5; keine ausführbare MQ-Library. Proposal §§ 5–6, 11. +// Anwendung: zuerst runUserWorker() als Worker starten; danach publishUserCreated() +// im HTTP-Backend. Beide verwenden dieselbe zentrale Verbindungskonfiguration. +final class LocalUserCreated +{ + public string $userId; + public string $email; +} + +function runUserWorker(): void +{ + $registry = new MessageRegistry(); + $registry->register('user.created.v1', LocalUserCreated::class, topic: 'users'); + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(registry: $registry))); + try { + $mq->subscribe('users', 'audit-users', static function (array $event, MessageContext $context): void { + printf("Audit: %s / %s\n", $context->messageId, $event['userId']); + }, new SubscriptionOptions(type: 'user.created.v1')); + + $mq->subscribe('users', 'billing-users', static function (LocalUserCreated $event): void { + printf("Billing: %s / %s\n", $event->userId, $event->email); + }, new SubscriptionOptions(type: 'user.created.v1')); + // Beide Gruppen erhalten eine Kopie. DTO-Hydration nutzt den Parameter-Typ; + // der Sender darf ein Array oder eine andere strukturell passende Klasse senden. + $mq->run(); // Erst jetzt Callbacks ausführen; Erfolgsrückkehr bestätigt die Kopie. + } finally { + $mq->close(); + } +} + +function publishUserCreated(string $userId, string $email): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->publish('users', 'user.created.v1', ['userId' => $userId, 'email' => $email], options: new PublishOptions(reply: false)); + // Rückkehr bestätigt Broker-Annahme; beide Worker können noch arbeiten. + } catch (QueueConfigurationMissingException $error) { + printf("Queue-Konfiguration fehlt für %s / %s (%s).\n", + $error->topic, $error->messageType, $error->reason); + // HTTP-Backend bildet diesen Zustand auf seine Fehlerantwort ab. + // Keine automatische Anlage durch den Publisher; kein stiller Erfolg. + throw $error; + } finally { + $mq->close(); + } +} + +// Alternative Sendeseite mit Schema-Prüfung; injizierte MQ wurde wie im Worker +// mit Registry konfiguriert. Diese Funktion besitzt/schließt die Connection nicht. +function publishValidatedUser(MessageQueueInterface $mq, string $userId, string $email): void +{ + $event = new LocalUserCreated(); + $event->userId = $userId; + $event->email = $email; + $mq->publish($event, options: new PublishOptions(reply: false)); // Registry liefert Topic/Typ; erforderliche Felder prüfen, dann senden. +} + +function demonstrateValidationFailure(MessageQueueInterface $mq): void +{ + try { + $mq->publish('users', 'user.created.v1', ['userId' => 'missing-email'], options: new PublishOptions(reply: false)); + } catch (MessageValidationException $error) { + // Nur mit registriertem Contract: user.created.v1 / $.email / required. + // Validierung vor Publish; diese ungültige Nachricht wurde nicht gesendet. + printf("Ungültige Nachricht: %s\n", $error->getMessage()); + } +} + +// Weitere unabhängige Subscription ohne DTO/Schema; zuerst registrieren, dann run. +function runTelemetryWorker(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->subscribe('telemetry', 'audit-telemetry', static function (array $event): void { + printf("Telemetry: %s\n", json_encode($event, JSON_THROW_ON_ERROR)); + }); // Kein type-Filter: Handler muss alle Typen des Topics verarbeiten können. + $mq->run(); + } finally { + $mq->close(); + } +} + +// Separate Laufzeitvariante für eine bereits registrierte, injizierte Connection. +function processBatch(MessageQueueInterface $mq): void +{ + $mq->run(maxMessages: 100, maxSeconds: 30, idleTimeoutSeconds: 2); + // maxMessages: höchstens 100 abgeschlossene Zustellversuche über alle Gruppen, + // inklusive Retry/Reject; nicht 100 erfolgreiche/eindeutige Geschäftsoperationen. + // maxSeconds: 30 s Gesamtbudget inklusive Warten und Handlerlaufzeit. + // idleTimeoutSeconds: Ende nach 2 s Warten ohne fachliche Zustellung. + // Erstes Limit gewinnt; normale Rückkehr auch bei weniger/keinen Nachrichten. + // Ein synchroner Handler darf fertig werden und das Zeitbudget überschreiten. + // Kein minMessages; eine erforderliche Anzahl prüft die Anwendung selbst. + // Ohne Limits läuft run() bis stop() oder Infrastrukturfehler. +} + +function processBatchWithOptions(MessageQueueInterface $mq, RunOptions $options): void +{ + $mq->run($options, maxMessages: 100); // Direkter Wert ersetzt nur dieses Optionsfeld. + // Alternatives Beispiel, nicht zusätzlich processBatch() aufrufen. + // Optionsobjekte bleiben unverändert; alle gesetzten Limits müssen positiv sein. +} diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php new file mode 100644 index 0000000..d350929 --- /dev/null +++ b/examples/api-draft/03-attributes.php @@ -0,0 +1,185 @@ +messageId, $user->email); + // Erfolgreiche Rückkehr bestätigt automatisch. + } + + // Auch Attribute erzwingen keine Typisierung: hier unveränderte Array-Daten. + #[Subscribe(topic: 'users', subscription: 'raw-users', type: 'user.created.v1')] + public function onRawCreated(array $data): void + { + printf("Array: %s\n", json_encode($data, JSON_THROW_ON_ERROR)); + } +} + +function publishUserCreated(MessageQueueInterface $mq): void +{ + $user = new T_UserCreated(); + $user->userId = 'u-789'; + $user->email = 'sdk-user@example.org'; + + $mq->publish($user, options: new PublishOptions(reply: false)); // Liest MessageType, validiert und serialisiert. + +} + +// Separate Alternative: exakt ein Event, ohne SDK-Objekt. +function publishUserCreatedAsArray(MessageQueueInterface $mq): void +{ + $mq->publish('users', 'user.created.v1', [ + 'userId' => 'u-790', + 'email' => 'manual@example.org', + ], options: new PublishOptions(reply: false)); +} + +function runAttributeWorker(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. + $mq->registerHandlers(new UserHandlers()); + $mq->run(); // Erst hier werden die registrierten Handler aufgerufen. + } finally { + $mq->close(); + } +} + +// Getrennte Prozesse: Empfänger legt/bindet Subscriptions vor dem ersten Senden +// an und ruft run() auf. Sender ruft danach publishUserCreated() auf seiner eigenen Connection +// auf. Beide verwenden denselben RabbitMQ-Namespace (Vorgaben in 01-connect.php). + +// Alternative zur Attributregistrierung: nur den Callback übergeben. +// Auf einer eigenen MQ-Instanz statt runAttributeWorker()/registerHandlers() ausführen. +function runCallbackWorker(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->subscribe(function (T_UserCreated $user, MessageContext $context): void { + printf("Callback: %s / %s\n", $context->messageId, $user->email); + }); // users + sdk-users + user.created.v1; automatische Hydration. + $mq->run(); // Alternative zum Attribut-Worker, gleicher Empfangsvertrag. + } finally { + $mq->close(); + } +} + +// Alternativ ist auch $mq->subscribe([$handlers, 'onCreated']) möglich. +// NICHT zusätzlich dieselbe Methode über registerHandlers() registrieren. + +// Dieser Contract ist topic- und gruppenunabhängig; der Wire-Typ steht nur hier. +#[MessageType('audit.entry.v1')] +final class T_AuditEntry +{ + public string $text; +} + +final class AuditHandlers +{ + // Nur offene Angaben ergänzen; type ergibt sich aus T_AuditEntry. + #[Subscribe(topic: 'audit.users', subscription: 'audit-reader')] + public function onEntry(T_AuditEntry $entry): void + { + printf("Audit: %s\n", $entry->text); + } +} + +function demoMultipleTopics(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $handler = static function (T_AuditEntry $entry): void { + printf("Audit: %s\n", $entry->text); + }; + foreach (['audit.users', 'audit.billing'] as $topic) { + $mq->subscribe($handler, options: new SubscriptionOptions( + topic: $topic, subscription: 'audit-reader', + )); + } + // Alternative für audit.users: registerHandlers(new AuditHandlers()). + // Die Alternative ersetzt dessen obige Registrierung, nicht zusätzlich aufrufen. + $entry = new T_AuditEntry(); + $entry->text = 'Ein Vorgang wurde abgeschlossen.'; + $mq->publish('audit.users', 'audit.entry.v1', $entry, options: new PublishOptions(reply: false)); + $mq->publish('audit.billing', 'audit.entry.v1', $entry, options: new PublishOptions(reply: false)); + // publish($entry) wäre MAPPING_INCOMPLETE: kein festes Topic auf diesem DTO. + // Höchstens 2 Zustellversuche insgesamt oder 10 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $mq->run(maxMessages: 2, maxSeconds: 10); + } finally { + $mq->close(); + } +} + +// Separates Fehlerbeispiel; kein Workerstart, kein Senden erforderlich. +function demonstrateConflicts(MessageQueueInterface $mq): void +{ + $typed = static function (T_UserCreated $event): void {}; + try { + $mq->subscribe($typed, options: new SubscriptionOptions(topic: 'other.users')); + } catch (MessageMappingException $error) { + printf("Mappingkonflikt: %s\n", $error->getMessage()); + // MAPPING_CONFLICT: field=topic, MessageType=users, options=other.users. + // Ablehnung vor Broker-Binding; kein stilles Überschreiben. + } + + try { + $mq->subscribe(static function (T_AuditEntry $entry): void {}); + } catch (MessageMappingException $error) { + printf("Mapping unvollständig: %s\n", $error->getMessage()); + // MAPPING_INCOMPLETE: missingFields=[topic, subscription]. + } + + $subscription = $mq->subscribe($typed); + try { + try { + $mq->subscribe($typed); + } catch (InvalidHandlerException $error) { + printf("Doppelte Registrierung: %s\n", $error->getMessage()); + // DUPLICATE_SUBSCRIPTION: users/sdk-users bereits lokal registriert. + // Derselbe Gruppenname in einem anderen Workerprozess ist dagegen erlaubt. + } + } finally { + $subscription->cancel(); // Lokalen Consumer abmelden; Broker-Binding und dauerhafte Queue bleiben. + } +} diff --git a/examples/api-draft/04-files-and-local.php b/examples/api-draft/04-files-and-local.php new file mode 100644 index 0000000..9369416 --- /dev/null +++ b/examples/api-draft/04-files-and-local.php @@ -0,0 +1,53 @@ +subscribe('exports', 'archive-importer', function (array $data, MessageContext $context) use ($outputPath): void { + // Verifiziert Größe und Digest vor Freigabe des Ziels; kein Entpacken. + // Ziel kommt aus lokaler Konfiguration, niemals aus dem Dateinamen. + $context->attachment('archive')->copyTo($outputPath); + printf("Export %s liegt verifiziert bereit.\n", $data['exportId']); + }, new SubscriptionOptions(type: 'export.ready.v1')); + + // Dieser Aufruf speichert die Datei und sendet anschließend die Referenz. + $mq->publish('exports', 'export.ready.v1', ['exportId' => 'export-42'], new PublishOptions( + reply: false, + attachments: [ + 'archive' => Attachment::fromPath($zipPath, contentType: 'application/zip'), + ], + )); + // Höchstens 1 Zustellversuche insgesamt oder 30 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $mq->run(maxMessages: 1, maxSeconds: 30); + } finally { + $mq->close(); + } +} diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php new file mode 100644 index 0000000..8c2e681 --- /dev/null +++ b/examples/api-draft/05-rpc.php @@ -0,0 +1,239 @@ +notify(new Notice( + level: 'warning', + code: 'SMALL_DIVISOR', + message: 'Der Divisor ist kleiner als eins.', + )); + } + + $request->setReplyMetadata(['app.worker' => 'calculator-v1']); + return ['quotient' => $params['a'] / $params['b']]; + } +} + +// Worker-Entrypoint: erst Handler registrieren, dann blockierend empfangen. +function runServer(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->respond('calculator', 'calculator-workers', [new DivideHandler(), 'divide'], + new SubscriptionOptions(type: 'math.divide.v1')); + // Alternative zur Zeile oben: $mq->registerHandlers(new DivideHandler()); + $mq->run(); // return aus divide() wird zum Reply; danach Request-Ack. + } finally { + $mq->close(); + } +} + +// HTTP-/Anwendungsschicht: ein Command, ein Ergebnis. +// await wirft RequestTimeoutException (Ausgang unbekannt) oder RemoteCommandException +// (sicherer fachlicher Fehler). Die Anwendung darüber entscheidet über ihre Fehlerantwort. +function divideFromApplication(float $a, float $b): float +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $reply = $mq->request(new Divide($a, $b))->await(timeoutSeconds: 5); + return $reply->payload['quotient']; + } finally { + $mq->close(); // Diese Funktion besitzt ihre Connection, auch im Fehlerfall. + } +} + +// Erweiterter Aufruf: Wire-Deadline beim Senden, Auswertung erst beim Warten. +function divideWithNotices(): array +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $sent = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0.5], + new PublishOptions( + replyTimeoutSeconds: 15, // Antwortfrist ab Senden; enthält Queue-Wartezeit. + metadata: ['app.traceId' => 'trace-demo-42', 'app.locale' => 'de-DE'], + )); // Schon gesendet, kein Builder. request erzwingt den Rückkanal vor Versand. + + $reply = $sent->await( + timeoutSeconds: 5, // Nur dieser lokale Warteaufruf, längstens bis Wire-Deadline. + onNotice: static function (Notice $notice): void { + printf("%s [%s]: %s\n", $notice->level, $notice->code, $notice->message); + }, + ); + // Warnings sind Zwischenmeldungen, kein Abbruch und keine Fristverlängerung. + // Ergebnis-Payload, Metadaten und finale Notice-Zusammenfassung bleiben getrennt. + // onNotice wurde schon ausgeführt; reply->notices nicht nochmals ausgeben. + return ['quotient' => $reply->payload['quotient'], 'worker' => $reply->metadata['app.worker']]; + } finally { + $mq->close(); // Exceptions bleiben für die aufrufende Anwendung sichtbar. + } +} + +// Lokale Exception ausdrücklich freigeben; keine entfernte PHP-Deserialisierung. +function divideWithTypedError(float $a, float $b): float +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $reply = $mq->request(new Divide($a, $b))->await( + timeoutSeconds: 5, errorTypes: [DivisionByZero::class], + ); + return $reply->payload['quotient']; + } catch (DivisionByZero $error) { + printf("Eingabe korrigieren: %s\n", $error->getMessage()); + throw $error; // Freigegebene SDK-Klasse und sichere Servermeldung. + } catch (RemoteCommandException $error) { + throw $error; // Andere Remote-Fehler behalten den generischen Typ. + } finally { + $mq->close(); + } +} + +// Gleichwertige Sendemethode: publish mit explizit aktivierter Antwortfähigkeit. +function divideUsingPublish(float $a, float $b): float +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $sent = $mq->publish(new Divide($a, $b), options: new PublishOptions(reply: true)); + return $sent->await(timeoutSeconds: 5)->payload['quotient']; + // Mit rpc.enabled in der Connection darf reply:true auch entfallen. + // request($dto) drückt die RPC-Absicht bereits im Methodennamen aus. + // PublishOptions(reply:false) sendet ein Event; späteres await ist dann ein Fehler. + } finally { + $mq->close(); + } +} + +// Nach lokalem Timeout denselben Handle weiterverwenden, solange Wire-Frist läuft. +function divideWithSecondWait(float $a, float $b): float +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $sent = $mq->request(new Divide($a, $b), options: new PublishOptions(replyTimeoutSeconds: 15)); + try { + $reply = $sent->await(timeoutSeconds: 2); + } catch (RequestTimeoutException) { + $reply = $sent->await(timeoutSeconds: 5); // Kein zweiter Request. + } + return $reply->payload['quotient']; + } finally { + $mq->close(); + } +} + +// Ergebnis-Darstellung gehört zum lokalen Empfänger, unabhängig von der Serverklasse. +final class LocalDivisionResult +{ + public float $quotient; +} + +function divideAsLocalDto(float $a, float $b): LocalDivisionResult +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $reply = $mq->request(new Divide($a, $b))->await( + timeoutSeconds: 5, responseClass: LocalDivisionResult::class, + ); + return $reply->payload; // Struktur prüfen/hydrieren; keine Server-FQCN vergleichen. + } finally { + $mq->close(); + } +} + +// Alternativ await(new AwaitOptions(timeoutSeconds: 5)); direkte Werte überschreiben +// dasselbe Feld. responseClass gehört nur zu await und hydriert das lokale Ergebnis. +// await prüft Optionen NACH dem Senden: falsche Klasse/ungültiger Timeout kann den +// bereits veröffentlichten Request nicht zurücknehmen. DTOs brauchen die Schema-Bridge. +// ConnectionException/PublishException entstehen beim Transport und werden nicht als +// RemoteCommandException umgedeutet. Bei Abbruch können alte Handles nicht auf eine +// neue Connection übertragen werden. Die Anwendung entscheidet über Wiederanlauf. diff --git a/examples/api-draft/06-metadata-middleware.php b/examples/api-draft/06-metadata-middleware.php new file mode 100644 index 0000000..f2bebbc --- /dev/null +++ b/examples/api-draft/06-metadata-middleware.php @@ -0,0 +1,125 @@ +withMetadata(['app.traceId' => $traceId])); + }, + ], + handleMiddleware: [ + // $next: callable(mixed, MessageContext): mixed + static function (mixed $payload, MessageContext $context, callable $next) use ($diagnostics): mixed { + try { + // Rückgabe durchreichen: funktioniert auch um RPC-Responder. + return $next($payload, $context); + } catch (\Throwable $original) { + try { + $diagnostics->publish('diagnostics', 'diagnostic.v1', [ + 'level' => 'error', + 'code' => 'HANDLER_FAILED', + 'message' => 'Eine Nachricht konnte nicht verarbeitet werden.', + ], new PublishOptions( + reply: false, + correlationId: $context->correlationId ?? $context->messageId, + metadata: [ + 'app.traceId' => $context->metadata['app.traceId'] ?? 'unknown', + 'app.sourceTopic' => $context->topic, + ], + )); + } catch (\Throwable) { + // Lokaler, redigierter Fallback; keine rekursive MQ-Meldung. + error_log('Die MQ-Diagnosemeldung konnte nicht versendet werden.'); + } + // Ursprüngliche Ausnahme bewahren: kein fälschliches Erfolgs-Ack. + throw $original; + } + }, + ], + ))); + + try { + $diagnostics->subscribe('diagnostics', 'diagnostic-viewer', static function (array $notice, MessageContext $context): void { + printf("%s [%s], Bezug %s\n", $notice['level'], $notice['code'], $context->correlationId); + }, new SubscriptionOptions(type: 'diagnostic.v1')); + + $mq->subscribe('orders', 'order-workers', static function (array $order, MessageContext $context): void { + // Separat zugänglich, nicht in $order integriert. + printf("Auftrag %s, Sprache %s, Trace %s\n", + $order['orderId'], + $context->metadata['app.locale'] ?? 'en', + $context->metadata['app.traceId']); + + if (($order['mode'] ?? '') === 'reject') { + throw new RejectMessageException('Der Beispielauftrag wurde abgelehnt.'); + } + }, new SubscriptionOptions(type: 'order.submit.v1')); + + // Einfacher Event-Aufruf mit frei gewählten, begrenzten Anwendungsmetadaten. + $mq->publish('orders', 'order.submit.v1', ['orderId' => 'order-42'], new PublishOptions( + reply: false, + correlationId: 'request-42', + metadata: ['app.locale' => 'de-DE'], + )); + // Höchstens 1 Zustellversuche insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $mq->run(maxMessages: 1, maxSeconds: 5); + + // Eine Warning direkt als gewöhnliches Event versenden: keine neue API nötig. + $diagnostics->publish('diagnostics', 'diagnostic.v1', [ + 'level' => 'warning', + 'code' => 'OPTIONAL_DATA_MISSING', + 'message' => 'Optionale Auftragsdaten fehlen.', + ], new PublishOptions( + reply: false, + correlationId: 'request-42', + metadata: ['app.traceId' => $traceId], + )); + // Höchstens 1 Zustellversuche insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $diagnostics->run(maxMessages: 1, maxSeconds: 5); + + // Zweite Aktion: permanenter Fehler, Middleware veröffentlicht Diagnose. + $mq->publish('orders', 'order.submit.v1', ['orderId' => 'order-43', 'mode' => 'reject'], options: new PublishOptions(reply: false)); + $mq->run(maxMessages: 1, maxSeconds: 5); // Reject wird sicher abgelegt. + $diagnostics->run(maxMessages: 1, maxSeconds: 5); // Diagnose tatsächlich anzeigen. + // run wirft hier nicht den Reject: die Runtime hat ihn bereits behandelt. + // RPC-Begleitmeldungen am Rückkanal zeigt zusätzlich Beispiel 05. + } finally { + $mq->close(); + } + } finally { + $diagnostics->close(); + } +} diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php new file mode 100644 index 0000000..9e2a74a --- /dev/null +++ b/examples/api-draft/07-broadcast-locking.php @@ -0,0 +1,176 @@ +subscribe('maintenance.locks', 'locks-' . $participantId, + static function (array $command, MessageContext $context) use ($mq, $participantId, $locks): void { + if (!in_array($context->type, ['lock.acquire.v1', 'lock.release.v1'], true)) { + return; + } + if (!is_string($command['roundId'] ?? null) + || ($command['resource'] ?? null) !== 'search-index' + || !is_array($command['participants'] ?? null) + || !is_int($command['acquireBy'] ?? null) + || !is_int($command['leaseUntil'] ?? null) + || $command['leaseUntil'] <= $command['acquireBy'] + || $command['leaseUntil'] > time() + 30 + || $context->correlationId !== $command['roundId']) { + throw new RejectMessageException('Ungültige Lock-Koordinationsnachricht.'); + } + if (!in_array($participantId, $command['participants'], true)) { + return; // Neue Teilnehmer gehören erst zum nächsten Snapshot. + } + + if ($context->type === 'lock.release.v1') { + $locks->release($command['resource'], $command['roundId'], $command['leaseUntil']); + return; + } + + $held = time() < $command['acquireBy'] && $locks->tryAcquire( + $command['resource'], $command['roundId'], $command['leaseUntil'], + ); + // Fester Rückkanal; keine URL/DSN aus der Nachricht verwenden. + $mq->publish('maintenance.replies.coordinator-demo', 'lock.state.v1', [ + 'roundId' => $command['roundId'], + 'participant' => $participantId, + 'held' => $held, + 'leaseUntil' => $command['leaseUntil'], + ], new PublishOptions(reply: false, correlationId: $command['roundId'])); + // Erst erfolgreiche Rückkehr bestätigt Acquire; bei Retry bleibt + // tryAcquire mit derselben Runde idempotent. + }); + $mq->run(); + } finally { + $mq->close(); // Die injizierte Lease-Verwaltung sichert den Ablauf selbst. + } +} + +/** + * @param list $participants Fester Membership-Snapshot, keine Subscriber-Zählung. + * @param callable(string, int): void $criticalSection Runde und sichere Ablaufzeit. + * Die Anwendung muss Ablauf/Fencing AN DER ZIELRESSOURCE durchsetzen; ein + * PHP-Zeitvergleich allein schützt nicht vor Prozesspausen/Lease-Verlust. + */ +function withAllLocks(array $participants, callable $criticalSection): void +{ + foreach ($participants as $participant) { + if (!is_string($participant) || $participant === '') { + throw new \InvalidArgumentException('Teilnehmer-IDs müssen nicht leere Strings sein.'); + } + } + if ($participants === [] || count(array_unique($participants)) !== count($participants)) { + throw new \InvalidArgumentException('Eine eindeutige, nicht leere Teilnehmerliste ist erforderlich.'); + } + + $roundId = bin2hex(random_bytes(16)); // Korrelations-/Ownership-ID, KEIN Fencing-Token. + $acquireBy = time() + 5; + $leaseUntil = $acquireBy + 10; + $safeUntil = $leaseUntil - 2; // Nur Demo-Reserve; Produktion benötigt begründete Grenzen. + $states = []; + $rejected = false; + $command = [ + 'roundId' => $roundId, + 'resource' => 'search-index', + 'participants' => $participants, + 'acquireBy' => $acquireBy, + 'leaseUntil' => $leaseUntil, + ]; + + $mq = new PhoreMQ(...demoConnection()); + try { + // Vor dem Broadcast binden, damit auch sofortige Antworten erfasst werden. + $mq->subscribe('maintenance.replies.coordinator-demo', 'lock-coordinator', + static function (array $state, MessageContext $context) use ( + $mq, $roundId, $participants, $leaseUntil, &$states, &$rejected, + ): void { + if (($state['roundId'] ?? null) !== $roundId || $context->correlationId !== $roundId) { + return; // Alte/fremde Runde, nicht mitzählen. + } + $participant = $state['participant'] ?? null; + if (!is_string($participant) || !in_array($participant, $participants, true) + || !is_bool($state['held'] ?? null) || ($state['leaseUntil'] ?? null) !== $leaseUntil) { + throw new RejectMessageException('Ungültige Lock-Bestätigung.'); + } + // In Produktion hier zusätzlich verifizierte Teilnehmeridentität + // bzw. pro Teilnehmer geschützten Rückkanal prüfen, nicht nur den Text. + $states[$participant] = $state['held']; // Duplikate zählen nicht doppelt. + if (!$state['held']) { + $rejected = true; + } + if ($rejected || count($states) === count($participants)) { + $mq->stop(); // Beendet den Loop NACH diesem Handler; Ack folgt noch. + } + }, new SubscriptionOptions(type: 'lock.state.v1')); + + // EIN Publish erreicht ALLE benannten Teilnehmer-Subscriptions. + $mq->publish('maintenance.locks', 'lock.acquire.v1', $command, + new PublishOptions(reply: false, correlationId: $roundId)); + $remaining = $acquireBy - time(); + if ($remaining > 0) { + // Nur das verbleibende Zeitbudget dieser Runde; kein neuer voller Timeout. + // run kehrt bei Ablauf normal zurück. Ob alle geantwortet haben, prüfen wir + // unten selbst; maxSeconds garantiert weder Teilnehmerzahl noch Lock-Erfolg. + $mq->run(maxSeconds: $remaining); + } + + if ($rejected || count($states) !== count($participants) + || time() >= $acquireBy || time() >= $safeUntil) { + throw new \RuntimeException('Nicht alle Teilnehmer haben ihre Lease rechtzeitig bestätigt.'); + } + + // Alle bekannten Teilnehmer haben geantwortet. Das ist eine Barriere, + // kein eigenständiger globaler Lock und keine unbegrenzte Gültigkeit. + $criticalSection($roundId, $safeUntil); + } finally { + try { + // Auch bei negativer Antwort/Timeout teilweise erworbene Locks freigeben. + $mq->publish('maintenance.locks', 'lock.release.v1', $command, + new PublishOptions(reply: false, correlationId: $roundId)); + } catch (\Throwable) { + // Keine falsche Freigabegarantie: Leases müssen unabhängig ablaufen. + error_log('Lock-Release nicht bestätigt; automatische Lease-Abläufe bleiben erforderlich.'); + } finally { + $mq->close(); + } + } +} diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php new file mode 100644 index 0000000..fb721e5 --- /dev/null +++ b/examples/api-draft/08-processing-workers.php @@ -0,0 +1,91 @@ +respond('jobs.text', 'text-processors', + static function (array $job, RequestContext $request) use ($workerId): array { + if (!is_string($job['text'] ?? null)) { + throw new CommandFailedException( + errorCode: 'INVALID_TEXT', + publicMessage: 'Der Job benötigt das String-Feld text.', + ); + } + $text = trim($job['text']); + $request->setReplyMetadata(['app.workerId' => $workerId]); + + // Reine, wiederholbare Verarbeitung ohne externe Seiteneffekte. + return ['normalized' => $text, 'bytes' => strlen($text), 'sha256' => hash('sha256', $text)]; + }, new SubscriptionOptions(type: 'text.process.v1')); + $mq->run(); + } finally { + $mq->close(); + } +} + +function submitJobs(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $pending = []; + foreach ([' erster Job ', ' zweiter Job ', ' dritter Job '] as $text) { + // Keine Worker-Adresse: der Broker wählt einen verfügbaren Consumer. + $pending[] = $mq->request('jobs.text', 'text.process.v1', ['text' => $text], + // Ursprüngliche Antwortfrist: 15 s ab request(), nicht ab await(). + new PublishOptions(replyTimeoutSeconds: 15)); + } + + foreach ($pending as $call) { + // Ohne lokalen Timeout wartet await nur bis zur ursprünglichen Deadline. + // Die Fristen laufen seit dem Senden PARALLEL, nicht je weitere 15 s pro await. + // Ohne rechtzeitiges finales Ergebnis: RequestTimeoutException (catch in 05). + // Kein erneutes Senden, kein Abbruch des entfernten Workers durch den Timeout. + try { + $reply = $call->await(); // Antwort zu genau diesem bereits gesendeten Auftrag. + } catch (RequestTimeoutException | RemoteCommandException $error) { + printf("Auftrag ohne Erfolgsergebnis: %s\n", $error->getMessage()); + continue; // Andere Aufträge sind bereits gesendet und werden weiter abgeholt. + } + printf("%s: %s (%d Bytes), SHA-256 %s\n", + $reply->metadata['app.workerId'], + $reply->payload['normalized'], + $reply->payload['bytes'], + $reply->payload['sha256']); + } + // Jede erfolgreiche Antwort stammt von einem Worker. Es ist zulässig, dass ein + // Worker mehrere Jobs erhält: Gleichverteilung/Zufall wird nicht garantiert. + } finally { + $mq->close(); + } +} + +// Crash/Verbindungsabbruch/Ack-Verlust können eine erneute Zustellung verursachen. +// Für schreibende Jobs zusätzlich Idempotenz/Fencing einsetzen; eine Gruppe +// allein garantiert keine Exactly-once-Ausführung. Timeout/Remote-Fehler wie in 05 behandeln. diff --git a/examples/api-draft/09-system-check.php b/examples/api-draft/09-system-check.php new file mode 100644 index 0000000..653e89e --- /dev/null +++ b/examples/api-draft/09-system-check.php @@ -0,0 +1,141 @@ +set('output.writable', + is_dir($outputDirectory) && is_writable($outputDirectory) + ? HealthFinding::healthy() + : $denied, + topic: 'jobs.export', type: 'export.create.v1', + ); + }; + $state->set('output.writable', HealthFinding::unknown( + code: 'DEPENDENCY_UNCHECKED', publicMessage: 'Ausgabeziel noch nicht geprüft.', + action: 'Nächste Prüfung abwarten.', + ), topic: 'jobs.export', type: 'export.create.v1'); + $refresh($state); + + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(health: new HealthOptions( + state: $state, + refresh: $refresh, + refreshIntervalSeconds: 5, + statusTtlSeconds: 20, + // Opt-in: Host nur an autorisierte Betreiber, nicht ungefiltert an Browser. + diagnostics: static fn (): array => [ + 'host' => $host, + 'processMemoryBytes' => memory_get_usage(true), + 'processPeakMemoryBytes' => memory_get_peak_usage(true), + ], + )))); + try { + $mq->respond('jobs.export', 'export-workers', + static function (array $parameters) use ($processExport, $state, $denied): array { + try { + return $processExport($parameters); // Anwendung prüft/idempotent verarbeitet. + } catch (OutputPermissionException $error) { + // Gleicher Zustand für Push, Ping und Consume-Pause; kein Container-Abbruch. + $state->set('output.writable', $denied, + topic: 'jobs.export', type: 'export.create.v1'); + throw new RetryableMessageException('Export temporarily unavailable', 0, $error); + } + }, new SubscriptionOptions(type: 'export.create.v1')); + // Loop bedient Health auch während einer Bereitschaftspause und prüft Recovery. + // Ein beliebig blockierender Handler benötigt ein separates Laufzeitmodell (§ 16.4). + $mq->run(); + } finally { + $mq->close(); + } +} + +function frontendConnection(string $instanceId): MessageQueueInterface +{ + return new PhoreMQ(...demoConnection(new ConnectionOptions(health: new HealthOptions( + state: new HealthState(serviceId: 'frontend-backend', instanceId: $instanceId), + requirements: [ + // Mein System BRAUCHT diesen Nachrichtentyp, mit mindestens einem Worker. + new ReadinessRequirement( + topic: 'jobs.export', type: 'export.create.v1', + subscriptions: ['export-workers'], minReadyPerSubscription: 1, + ), + // Broadcast: Jede dieser beiden Gruppen muss wenigstens einmal bereit sein. + new ReadinessRequirement( + topic: 'users.events', type: 'user.created.v1', + subscriptions: ['audit-service', 'mail-service'], + ), + ], + )))); +} + +function checkAtLogin(MessageQueueInterface $mq): array +{ + // Eine Netzwerkprüfung für genau die Funktion, die der Browser anbieten soll. + $export = $mq->check('jobs.export', 'export.create.v1', + new CheckOptions(timeoutSeconds: 3))->toArray(); + + // Beispiel: Nur erlaubte Felder zum Browser. Interne Host-/Prozessdaten bleiben im Backend. + return ['features' => ['export' => [ + 'ready' => $export['ready'], + 'status' => $export['status'], + 'expiresAt' => $export['expiresAt'], + 'issues' => array_map(static fn (array $issue): array => [ + 'code' => $issue['code'], 'message' => $issue['publicMessage'], + ], $export['issues']), + ]]]; +} + +// Alternative für Betreiber: alle deklarierten Abhängigkeiten in einem Check. +// Dieses Ergebnis enthält interne Diagnosefelder, nicht ungefiltert zum Browser geben. +function checkAllDependencies(MessageQueueInterface $mq): array +{ + return $mq->check(options: new CheckOptions(requireDeclared: true, timeoutSeconds: 3))->toArray(); +} + +// Nur Brokerverbindung prüfen: $mq->check(); das sagt nichts über Export-Worker aus. +// Die injizierte Connection bleibt Eigentum des Aufrufers; diese Funktionen schließen sie nicht. +function observeStatus(MessageQueueInterface $monitor, callable $acceptVerifiedSnapshot): void +{ + $monitor->subscribe('_phore.health.status', 'operations-dashboard', + static function (array $snapshot) use ($acceptVerifiedSnapshot): void { + // Collector prüft Identität, generation/sequence und TTL (§ 16.6), + // speichert aktuellen Zustand und löst deduplizierte Alarme/Recovery aus. + $acceptVerifiedSnapshot($snapshot); + }, new SubscriptionOptions(type: 'system.health.v1')); + $monitor->run(); // Auf separater Connection, nicht im Login-Request. +} diff --git a/examples/api-draft/10-callback-errors.php b/examples/api-draft/10-callback-errors.php new file mode 100644 index 0000000..c9773cb --- /dev/null +++ b/examples/api-draft/10-callback-errors.php @@ -0,0 +1,85 @@ +subscribe('jobs.demo', 'demo-workers', function (array $job, MessageContext $context): void { + if (!is_string($job['mode'] ?? null)) { + // Dauerhaft ungültige Eingabe: keine Wiederholung; sichere Fehlerablage. + throw new RejectMessageException('Das Pflichtfeld mode fehlt.'); + } + if (!in_array($job['mode'], ['temporary', 'unexpected'], true)) { + throw new RejectMessageException('Unbekannter Verarbeitungsmodus.'); + } + if ($job['mode'] === 'temporary' && $context->attempt < 3) { + // Simulierte temporäre Störung. Versuch 1 und 2 scheitern, 3 gelingt. + throw new RetryableMessageException('Der Beispieldienst ist vorübergehend belegt.'); + } + if ($job['mode'] === 'unexpected') { + // Auch normale unbehandelte Exceptions werden begrenzt wiederholt. + throw new \RuntimeException('Simulierter unerwarteter Handlerfehler.'); + } + printf("Job %s erfolgreich, Versuch %d\n", $context->messageId, $context->attempt); + // Erst erfolgreiche Rückkehr führt bei Auto-Ack zur Bestätigung. + // Exception NICHT nur loggen und anschließend normal zurückkehren: + // das würde die fehlgeschlagene Verarbeitung fälschlich bestätigen. + }, new SubscriptionOptions(type: 'demo.process.v1', queue: QueueOptions::workQueue( + maxAttempts: 4, retryDelaySeconds: 2, // Erstversuch zählt mit; jeweils 2 s warten. + ))); + + $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'temporary'], options: new PublishOptions(reply: false)); + $mq->publish('jobs.demo', 'demo.process.v1', [], options: new PublishOptions(reply: false)); + $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'unexpected'], options: new PublishOptions(reply: false)); + + // Hier: 1 erster Versuch + höchstens 3 Wiederholungen, + // mit jeweils 2 Sekunden Verzögerung; Profildefault wäre 10 s, ohne Jitter. Kein enger Requeue-Loop. + // RetryableMessageException hebt die Obergrenze NICHT auf. + // temporary: Erfolg im Versuch 3; fehlendes mode: sofort Fehlerablage; + // unexpected: nach Versuch 4 Fehlerablage. Insgesamt 8 Zustellversuche. + // Das ist keine Reihenfolgegarantie. Verarbeitung anderer Jobs läuft weiter. + // maxSeconds beendet normal; es garantiert nicht, dass alle Retries fertig sind. + $mq->run(maxMessages: 8, maxSeconds: 30); + // Der RabbitMQ-Adapter speichert endgültige Fehler bestätigt in der Fehlerqueue. + // In Produktion nach Ursachenbehebung gezielt redriven, nicht blind neu senden. + } catch (FailureStoreException | ConnectionException $infrastructureError) { + // Infrastrukturfehler sind anders als behandelte Callback-Fehler: + // run wirft zum Prozessbetreiber zurück; fehlgeschlagene Ablage => KEIN Ack. + // Lokal redigiert protokollieren und den Supervisor kontrolliert reagieren lassen. + error_log('Queue-Infrastruktur nicht verfügbar; Ursache lokal untersuchen.'); + throw $infrastructureError; + } finally { + $mq->close(); + } +} + +// RPC: respond() + CommandFailedException erzeugt eine sichere finale Fehlerantwort; +// await() wirft dann RemoteCommandException. Callback-Retry bleibt davon getrennt. +// Nach erschöpften technischen Retries: generischer sicherer HANDLER_FAILED-Fehler, +// soweit der Rückkanal vor Deadline erreichbar ist; andernfalls ggf. Client-Timeout. +// Vollständiger RPC-Code samt RequestTimeoutException: 05-rpc.php. +// Middleware zum Protokollieren und WEITERWERFEN der Originalexception: 06-metadata-middleware.php. +// Nach manuellem ack() kann eine später geworfene Exception dieses Ack nicht rückgängig machen. diff --git a/examples/api-draft/11-queue-options.php b/examples/api-draft/11-queue-options.php new file mode 100644 index 0000000..15c3dda --- /dev/null +++ b/examples/api-draft/11-queue-options.php @@ -0,0 +1,148 @@ +=8.5; keine dieser Library-Klassen ist implementiert. +// Optionen gehören der Subscription; publish provisioniert auch bei DTOs nichts. +#[MessageType('text.normalize.v1', topic: 'text', subscription: 'text-workers')] +#[Queue(profile: QueueProfile::Rpc, revision: 1, retentionSeconds: 3600, + maxAttempts: 4, retryDelaySeconds: 15)] +final class NormalizeText +{ + public function __construct(public string $text) {} +} + +final class TextHandler +{ + // Topic, Subscription, Typ UND Queue-Vorgaben aus dem ersten DTO-Parameter. + // Strukturelle Hydration benötigt die Schema-Bridge; keine doppelten Angaben. + #[Respond] + public function normalize(NormalizeText $command): array + { + if ($command->text === '') { + // Permanent: Eingabe korrigieren. Keine Retry-Runde. + // Client-await wirft RemoteCommandException mit dieser sicheren Meldung. + throw new CommandFailedException(errorCode: 'EMPTY_TEXT', publicMessage: 'Der Text darf nicht leer sein.'); + } + return ['text' => trim($command->text)]; + } +} + +function runTypedWorker(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->registerHandlers(new TextHandler()); + // Gleichwertige Alternative, nicht zusätzlich registrieren: + // $mq->respond([new TextHandler(), 'normalize']); + $mq->run(); // Empfangsschleife bis stop()/Infrastrukturfehler, sendet nichts. + } finally { + $mq->close(); + } +} + +function runProgrammaticWorker(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + // Alternative zum typisierten Worker, identischer gemeinsamer Queue-Contract. + // Fehlender/falscher text-Typ würde dort bereits bei der Hydration scheitern. + $mq->respond('text', 'text-workers', static function (array $command): array { + if (!is_string($command['text'] ?? null)) { + throw new CommandFailedException(errorCode: 'INVALID_ARGUMENT', publicMessage: 'text muss ein String sein.'); + } + if ($command['text'] === '') { + throw new CommandFailedException(errorCode: 'EMPTY_TEXT', publicMessage: 'Der Text darf nicht leer sein.'); + } + return ['text' => trim($command['text'])]; + }, new SubscriptionOptions(type: 'text.normalize.v1', queue: QueueOptions::rpc( + revision: 1, + retentionSeconds: 3600, // Maximal 1 h wartend; Ack entfernt früher, kein Handler-Timeout. + maxInFlight: 1, // Ein offener Auftrag je Consumer, keine erzeugten Threads. + maxAttempts: 4, // Erstversuch + maximal drei reguläre Handler-Retries. + retryDelaySeconds: 15, // Mindestens 15 s bis erneut verfügbar; keine Ausführungsgarantie exakt dann. + ))); + $mq->run(); + } catch (QueueConfigurationConflictException $error) { + // Auch QueueMigrationRequiredException fällt hierunter. + // Beispielsweise: option=profile, actual=broadcast, requested=rpc. + // Keine automatische Löschung oder Änderung existierender Queues. + printf("Queue %s/%s: Option %s ist inkompatibel (%s -> %s)\n", + $error->topic, $error->subscription, $error->option, + json_encode($error->actual, JSON_THROW_ON_ERROR), + json_encode($error->requested, JSON_THROW_ON_ERROR)); + throw $error; // Kein scheinbar erfolgreich gestarteter Listener. + } finally { + $mq->close(); + } +} + +function runLiveScreen(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->subscribe('live', 'screen', static function (array $event): void { + echo $event['text']; + }, new SubscriptionOptions(queue: QueueOptions::broadcast())); + // Jede registrierte Connection bekommt eine eigene flüchtige Kopie. + // Keine Offline-Aufbewahrung, keine Handler-Retries; nicht für Pflicht-Jobs. + // Dauerhafte Gruppe mit Offline-Puffer: runRetainedScreen() unten. + $mq->run(); + } finally { + $mq->close(); + } +} + +function runWithDefaults(): void +{ + // Dieses Beispiel zeigt ausdrücklich globale OPTIONS, siehe Verbindung in 01. + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions( + queueDefaults: new QueueOptions(maxAttempts: 4, retryDelaySeconds: 10), + ))); + try { + $mq->subscribe('tasks', 'processors', static function (array $job): void { + // Nur Demo einer temporären Störung; nach vier Runden Fehlerablage. + throw new RetryableMessageException('Ressource ist vorübergehend gesperrt.'); + }, new SubscriptionOptions(queue: QueueOptions::workQueue())); + // Defaults füllen offene Felder. Feste DTO-Werte dürfen nicht widersprüchlich + // überschrieben werden. Broadcast benötigt maxAttempts=1, daher hier keine + // globalen Work-Retrywerte unbesehen auf Broadcast anwenden. + $mq->run(maxMessages: 4, maxSeconds: 60); + // Höchstens vier Zustellversuche oder 60 s Gesamtbudget, normale Rückkehr. + // Keine Mindestanzahl; spontane Redeliveries können dasselbe attempt wiederholen. + } finally { + $mq->close(); + } +} + +// Separate Alternative mit stabilem Namen: erst provisionieren, dann Events senden. +// Ein Profilwechsel für eine bestehende Subscription ist keine automatische Migration. +function runRetainedScreen(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->subscribe('live', 'screen-history', static function (array $event): void { + echo $event['text']; + }, new SubscriptionOptions(queue: QueueOptions::broadcast(retentionSeconds: 300))); + $mq->run(); // Max. 5 min wartende Nachrichten; Ack entfernt die eigene Kopie früher. + } finally { + $mq->close(); + } +} diff --git a/examples/api-draft/README.md b/examples/api-draft/README.md new file mode 100644 index 0000000..14b7be3 --- /dev/null +++ b/examples/api-draft/README.md @@ -0,0 +1,82 @@ +# PhoreMQ im Anwendungscode + +**API-Entwurf für PHP >=8.5.** Diese Dateien zeigen die geplante Nutzung; die +importierten MQ-Klassen sind noch nicht implementiert. Funktionen mit Namen wie +runUserWorker sind Worker-Entrypoints, divideFromApplication gehört in den +Anwendungs-/HTTP-Prozess. Die Dateien werden nicht gemeinsam als Skript ausgeführt. +Die Alternativen werden bewusst einzeln gewählt. + +## Der Ablauf, den ein Reviewer erkennen soll + +- `new PhoreMQ(...)` verbindet sofort. `demoConnection()` liefert nur Konfiguration. +- `subscribe(...)` / `respond(...)` richten den Empfangsvertrag ein; `run()` führt ihn aus. +- `publish(...)` sendet sofort. Bei reinen Events steht `reply: false` ausdrücklich dabei. +- `request(...)` sendet sofort mit Rückkanal. `await(...)` wartet auf genau diesen Auftrag. +- Der Callback erhält die Payload; `MessageContext` bzw. `RequestContext` gehört zu dieser Zustellung. +- `finally { $mq->close(); }` schließt eine selbst erzeugte Connection. Injizierte Connections bleiben beim Aufrufer. + +Der Worker muss seine Subscription vor dem ersten Publisher eingerichtet haben. +Eine Broker-Bestätigung beweist keine Verarbeitung. Bei mehreren Prozessen teilen +sich Worker einer dauerhaften Subscription die Arbeit; unterschiedliche Gruppen +erhalten eigene Kopien. Ein Callback läuft erst in run, ein RPC-Ergebnis wird in +await gelesen. Auf derselben Connection erst zu warten und danach den benötigten +Responder zu starten funktioniert nicht. + +## Die Beispiele einzeln + +| Datei | Anwendungseinstieg / Reihenfolge | Nutzerfrage und Änderung nach Review | +|---|---|---| +| [01-connect.php](01-connect.php) | connectDemo, connectConfigured oder connectDirectly auswählen | „Ist das schon verbunden?“ Ja; der Aufrufer besitzt und schließt die zurückgegebene Connection. | +| [02-programmatic.php](02-programmatic.php) | runUserWorker zuerst, dann publishUserCreated | „Registrieren oder verarbeiten?“ Sender und Worker sind getrennt; Batch-Limits stehen in einer separaten Variante. | +| [03-attributes.php](03-attributes.php) | runAttributeWorker ODER runCallbackWorker, dann publishUserCreated | „Woher kommt das Routing?“ Aus dem DTO/Registry; es wird nicht doppelt registriert. cancel entfernt keinen Broker-Vertrag. | +| [04-files-and-local.php](04-files-and-local.php) | transferArchive mit injiziertem Speicher | „Gehen ZIP-Bytes durch RabbitMQ?“ publish speichert die Datei und sendet die Referenz; copyTo verifiziert beim Empfang. Ein Prozess dient hier nur der Vorführung. | +| [05-rpc.php](05-rpc.php) | runServer zuerst, dann eine divide*-Funktion | „Wann wird gesendet, was wirft?“ Kurzer Hauptpfad; Notices, typisierte Fehler und zweites Warten sind getrennte Anwendungsfälle. | +| [06-metadata-middleware.php](06-metadata-middleware.php) | processOrderWithDiagnostics | „Ist der Fehler verarbeitet oder verschluckt?“ Middleware reicht return/Exception weiter; der konkrete Reject-Pfad und die Diagnose werden ausgeführt. | +| [07-broadcast-locking.php](07-broadcast-locking.php) | je Teilnehmer runParticipant; ein withAllLocks-Koordinator | „Antworten wirklich alle?“ Eigene Subscription je Teilnehmer und expliziter Snapshot; lokale Leases/Fencing sind Anwendungsabhängigkeiten. | +| [08-processing-workers.php](08-processing-workers.php) | mehrere runWorker mit gleicher Gruppe; submitJobs | „Wer bekommt den Job?“ Ein verfügbarer Worker; Fehler je Auftrag abfangen, übrige bereits gesendete Aufträge weiter auswerten. | +| [09-system-check.php](09-system-check.php) | runExportWorker; checkAtLogin im Backend | „Prüfe ich Broker oder Funktion?“ Ein gezielter Login-Check; Gesamtcheck und Statusbeobachtung sind eigene Funktionen. | +| [10-callback-errors.php](10-callback-errors.php) | processFailureScenarios auf frischem Namespace | „Retry oder Ende?“ Temporär, permanent und unerwartet sind explizit; unbekannter Modus wird nicht versehentlich als Erfolg bestätigt. | +| [11-queue-options.php](11-queue-options.php) | typisierten ODER programmatischen Worker wählen | „An welchem Objekt ändere ich die Queue?“ QueueOptions am Subscriber/DTO; Live-Broadcast und dauerhafte Retention haben getrennte Einstiege und Namen. | + +## Welches Objekt ändere ich? + +| Absicht | Stelle | +|---|---| +| Verbindung, Security, Middleware oder globale Defaults | ConnectionOptions beim Erzeugen der PhoreMQ-Instanz | +| Wire-Typ und DTO-Struktur | MessageType-Attribut / MessageRegistry, vor Registrierung bzw. Konstruktor | +| Eigene Queue, Retention und Retry-Abstand | SubscriptionOptions.queue / Queue-Attribut | +| Parameter, Metadaten, Attachments, Antwortfrist senden | Payload und PublishOptions bei publish/request | +| Antwort hydrieren, Remote-Fehler zuordnen, lokal warten | AwaitOptions oder direkte Parameter bei SendResult.await | +| Eine Zustellung quittieren oder Begleitmeldung senden | MessageContext / RequestContext im Handler | +| Einen Handler lokal entfernen | Zurückgegebenes SubscriptionHandle.cancel | +| Dienstproblem melden | Das injizierte HealthState-Objekt | + +`PublishOptions(replyTimeoutSeconds: 15)` setzt die Antwortfrist **ab Versand**. +`await(timeoutSeconds: 5)` wartet lokal höchstens fünf Sekunden und nie über diese +Antwortfrist hinaus. Ein zweites await desselben SendResult sendet nichts erneut. +Vier Retry-Versuche mit zehn Sekunden Abstand passen nicht garantiert in eine +15-Sekunden-Frist. Timeout bedeutet unbekannter Ausgang, keine Rücknahme. + +Die generische Form publish(...)->await bleibt möglich, wenn vorher ein Rückkanal +aktiviert wurde. Die Demo-Konfiguration aktiviert RPC global; ohne diesen Kontext +ist allein publish(...)->await nicht selbsterklärend. Die Hauptbeispiele benutzen +deshalb request für RPC und reply:false für reine Events. Beide Sendemethoden +verwenden PublishOptions; es gibt keine zusätzliche RequestOptions-Klasse. + +## Fehler und Grenzen beim Lesen + +Ein erfolgreich zurückgekehrter subscribe-Callback bestätigt die Verarbeitung. +Sein Rückgabewert wird nicht zum Reply; dafür verwendet man respond. Retryable- +und Reject-Exceptions verarbeitet die Runtime nach den QueueOptions; sie müssen +nicht aus run herausgeworfen werden. Transport-/Fehlerablageprobleme bleiben +sichtbare Infrastruktur-Exceptions. Ein RemoteCommandException entsteht beim +await; eine RequestTimeoutException kann auch nach erfolgreicher Geschäftsaktion +auftreten. Sichere Wiederholung schreibender Aufträge braucht persistente Idempotenz. + +Die Beispiele 02–03 benötigen bei DTOs die Schema-Bridge. Der manuelle Array-Pfad +hat nur dort automatische Strukturprüfung, wo ein Contract registriert wurde. +Produktionsabhängigkeiten wie Dateispeicher und Lease-Verwaltung sind ausdrücklich +injiziert und werden nicht durch wirkungslose Attrappen ersetzt. + +[Verbindung und Docker-Setup](../../docs/setup.md) · +[Vollständiger API-Vertrag](../../docs/proposals/2026-09-12-message-queue-api.md) diff --git a/examples/api-draft/connection.php b/examples/api-draft/connection.php new file mode 100644 index 0000000..2dcc5ee --- /dev/null +++ b/examples/api-draft/connection.php @@ -0,0 +1,32 @@ +