From 93c2f80e876867a0fb72b68e2d44d2afc1eb087d Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 06:50:55 +0200 Subject: [PATCH 01/16] docs: propose universal message queue API with usage examples --- .ai-usage-info.md | 17 +- README.md | 23 +- .../proposals/2026-09-12-message-queue-api.md | 499 ++++++++++++++++++ examples/api-draft/01-connect.php | 84 +++ examples/api-draft/02-programmatic.php | 95 ++++ examples/api-draft/03-attributes.php | 91 ++++ examples/api-draft/04-files-and-local.php | 115 ++++ 7 files changed, 917 insertions(+), 7 deletions(-) create mode 100644 docs/proposals/2026-09-12-message-queue-api.md create mode 100644 examples/api-draft/01-connect.php create mode 100644 examples/api-draft/02-programmatic.php create mode 100644 examples/api-draft/03-attributes.php create mode 100644 examples/api-draft/04-files-and-local.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index b2bf077..1ff197c 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -2,13 +2,22 @@ ## Sinn der Library -[hier einfügen] +`phore/message-queue` ist derzeit eine Projektvorlage mit einem +[API-Proposal](docs/proposals/2026-09-12-message-queue-api.md), keine implementierte +Queue-Library. Geplant: Redis Streams, austauschbare Konnektoren, Topics und +Subscriptions, optionale `phore/schema`-Hydration, HMAC-Signierung und Dateireferenzen. +`Phore\MessageQueue` ist der vorgeschlagene Namespace; Composer-Name und +Autoloading sind noch unveränderte Template-Werte. ## Beispiele -[hier beispiele in ./examples/ verlinken] +Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: -## Globale Funktionen +- [Verbindungen](examples/api-draft/01-connect.php) +- [Programmatische Nutzung und eigene DTOs](examples/api-draft/02-programmatic.php) +- [PHP-Attribute für SDK-Typen und Handler](examples/api-draft/03-attributes.php) +- [Dateien, In-Memory und Unix-Socket](examples/api-draft/04-files-and-local.php) -[hier links auf beispiele von speziellen funktionen einfügen] +## Globale Funktionen +Keine implementierten globalen Funktionen vorhanden. diff --git a/README.md b/README.md index e2c1ff0..a7e8f14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ -# phore-project-template -Template Repository for phore library projects +# Phore Message Queue + +Geplante universelle PHP-Library für Message Queues mit austauschbaren +Konnektoren. Redis Streams bildet die erste Implementierung; eine gemeinsame +API verbindet Topics, dauerhafte Subscriptions und konkurrierende Worker. +Optionale strukturelle Validierung und DTO-Hydration verwenden `phore/schema` +aus dem Repository `phore/phore-schema`. Nachrichtentypen und Handler lassen +sich programmatisch oder über PHP-Attribute zuordnen. Eine austauschbare +Sicherheitsschicht signiert Nachrichten transparent mit HMAC-SHA-256; +große Dateien werden über verifizierte Speicherreferenzen transportiert. + +**Status: API-Entwurf, noch keine Queue-Implementierung.** Composer-Metadaten +und Autoloading stammen weiterhin aus der Projektvorlage. Die folgenden +PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. + +- [API-Entwurf, Konnektorvergleich und Paketgrenzen](docs/proposals/2026-09-12-message-queue-api.md) +- [Verbinden: URL, Konnektor und Attribute](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 und lokale Entwicklung](examples/api-draft/04-files-and-local.php) ## Git Submodules @@ -16,4 +34,3 @@ git submodule update --init --recursive git submodule update --remote --merge ``` - 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..c332d3f --- /dev/null +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -0,0 +1,499 @@ +# Phore Message Queue: API- und Architekturentwurf + +| Datum | Benutzername | Kurzbeschreibung | +|---|---|---| +| 2026-09-12 | dermatthes | §§ 1–12: Proposal mit API-Beispielen, Konnektorvergleich und Paketgrenzen angelegt | + +## § 1 Abstract und Lieferumfang + +Eine frameworkunabhängige PHP-Library stellt eine gemeinsame Zugriffsschicht +für Topics, dauerhafte Subscriptions und Worker bereit. Redis Streams ist der +erste produktive Konnektor. URL-Factory und direkte Konnektor-Injektion sind +gleichwertig. Message-Typen besitzen stabile fachliche Namen; ihre PHP-Klassen +dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und +hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die +programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. + +**Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das +Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder +`test/`-Implementierung und keine eigene `SKILLS.md`. Als Namespace ist +`Phore\MessageQueue` vorgesehen; Composer-Name/Autoloading bleiben in diesem PR +unverändert. Beispiele verwenden PHP >=8.3, passend zur aktuellen Vorlage. + +| Ausbaustufe | Geplanter Inhalt | +|---|---| +| Erste Umsetzung | Factory, Registry, JSON-Envelope, Topic/Subscription-API, Redis Streams, In-Memory, Callback-Worker, Ack/Retry/Dead Letter, Exceptions, HMAC, optionale Schema-Bridge und Attribute | +| Anschlussphase | Attachment-/PayloadStore-Vertrag mit lokalem Dateispeicher; separater Unix-Entwicklungsbroker mit Konnektor | +| Weitere Adapter | SQS für Arbeitsqueues, SNS+SQS für Fan-out, Azure Service Bus, RabbitMQ | +| Spätere Erweiterungen | PGP-Provider, S3/Blob-PayloadStore, Batch, Delay, Filter, Replay, Telemetrie, optionale Outbox-/Inbox-Integration | + +Die Beispiele illustrieren auch die Anschlussphase, ausdrücklich ohne sie in +diesem PR zu implementieren. Vor Umsetzung werden Konnektorabhängigkeiten und +unterstützte Serverversionen festgelegt; für Redis ist >=6.2 wegen `XAUTOCLAIM` +der vorgeschlagene Mindeststand. Provider-spezifische Erweiterungen dürfen +nicht stillschweigend auf schwächere Semantik zurückfallen. + +## § 2 Begriffe und Zustellvertrag + +| Begriff | Bedeutung und Beispiel | +|---|---| +| Topic | Logischer Nachrichtenkanal, etwa `users`; unabhängig vom Backendnamen | +| Message type | Fachlicher Vertrag, etwa `user.created.v1`; unabhängig von Namespace und Composer-Paket | +| Subscription | Dauerhafte benannte Sicht auf ein Topic, etwa `billing-users` | +| 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. + +Der Grundvertrag lautet **at least once innerhalb der konfigurierten +Aufbewahrung und Verfügbarkeit**. Doppelte Zustellungen sind möglich, ebenso +eine unklare Publish-Bestätigung bei Verbindungsabbruch. `PublishReceipt` +bestätigt Backend-Annahme, keine Verarbeitung durch Empfänger. Es gibt keine +backendübergreifende Exactly-once-Garantie und keine globale Reihenfolge. +Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. + +`subscribe()` bindet eine benannte Subscription und prüft ihre Konfiguration. +Neue Subscriptions beginnen standardmäßig bei `StartPosition::Latest` zum +Zeitpunkt ihrer Anlage; bestehende behalten ihren Cursor. Ein späterer +Worker-Neustart setzt ihn niemals zurück. `Beginning` ist eine explizite +Replay-Capability und umfasst nur noch aufbewahrte Einträge. In Produktion +werden Topics und Subscriptions vorab provisioniert; nur eine ausdrücklich +aktivierte `autoCreate`-Option darf Ressourcen anlegen. `cancel()` löst die +lokale Bindung, löscht aber weder Subscription noch Rückstand. + +## § 3 Abstraktionsschichten und Erweiterungspunkte + +| Baustein | Verantwortung | +|---|---| +| `ConnectionFactory` / `ConnectionOptions` | DSN auswerten, installierten Adapter wählen, konfigurierte Dienste verbinden | +| `MessageQueueInterface` | `emit`, `publish`, `subscribe`, `registerHandlers`, `run`, `stop`, `close` | +| `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` | Bytes publizieren/empfangen, Receipt bestätigen/freigeben, Fähigkeiten melden | +| `PayloadStoreInterface` | Streams ablegen, Referenzen auflösen, Lebensdauer verwalten | +| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen | + +Sendepfad: Typ/Topic auflösen → Daten normalisieren und ggf. validieren → +Dateien ablegen → Envelope kodieren → signieren → Größen-/Capability-Prüfung +→ Konnektor. Empfangspfad: begrenzten Transportframe lesen → Signatur und +Zeit-/Zielbindung prüfen → Envelope dekodieren → optional Dateien verifizieren +→ lokale Struktur prüfen/hydrieren → Handler ausführen → Ack. +Dateiinhalte werden erst bei Zugriff geladen, bleiben aber vor Nutzung zu prüfen. + +Der Konnektor kennt keine Anwendungs-DTOnamen oder Callbacks. Seine +vorgeschlagenen primitiven Operationen sind `capabilities(): CapabilitySet`, +`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 + +[Vollständige Beispiele: 01-connect.php](../../examples/api-draft/01-connect.php). +Der vorgeschlagene Einstieg lautet: + +```php +$factory = new ConnectionFactory(); +$mq = $factory->connect('redis://localhost:6379/0', $options); +$mq = $factory->fromConnector(new RedisStreamsConnector($redisConfig), $options); +$mq = $factory->fromAttributes(LocalConnection::class, $options); +``` + +Alle drei Methoden liefern `MessageQueueInterface`. `fromAttributes` liest +genau eine lokal angegebene Klasse mit `#[QueueConnection(dsn: ...)]`; kein +automatisches Scannen des Dateisystems. Die DSN-Auswertung verwendet eine +Schema-Allowlist und erzeugt niemals beliebige PHP-Klassen aus URL-Inhalten. +Provider können explizit über `registerConnectorFactory(scheme, factory)` +registriert werden. Unbekannte Schemes/Optionen werden abgelehnt. + +| Vorgeschlagene DSN | Bedeutung | +|---|---| +| `redis://user:password@host:6379/0?prefix=app` | Redis Streams, ACL-Zugang; `/0` ist Datenbank | +| `rediss://user:password@host:6380/0` | Redis über TLS mit Zertifikatsprüfung | +| `redis://:password@host:6379/0` | Redis-Passwort ohne ACL-Benutzer | +| `redis+unix:///run/redis/redis.sock?db=0` | Redis-Server über Unix-Socket, weiterhin Redis-Protokoll | +| `memory://` | Isolierter In-Memory-Broker je Factory-Verbindung | +| `unix:///run/user/1000/phore-mq.sock` | Eigenes lokales MQ-Protokoll, benötigt separaten Dev-Broker | +| `sqs://eu-central-1/123456789012` | Geplanter Queue-Adapter; logische Topics per Routingtabelle auf Queue-URLs abbilden | +| `sns+sqs://eu-central-1/123456789012` | Geplanter Topic-Fan-out; SNS-ARNs und Subscription-Queues aus Routingtabelle | +| `azure-servicebus://namespace.servicebus.windows.net` | Geplanter Service-Bus-Adapter mit Topic-/Subscription-Bindings | +| `amqp://user:password@host:5672/vhost` | Geplanter RabbitMQ-Adapter; TLS über `amqps` | + +Diese Schemes sind Library-Konventionen, keine Zusage bereits vorhandener +Treiber. Benutzername, Passwort und Token vor `@` werden einmal percent-dekodiert; +`@` im Passwort muss `%40` sein. Port, IPv6, Pfad, doppelte Query-Parameter +und Optionswerte werden strikt geprüft. Fehler/Logs redigieren Credentials. +Ein einzelner Key lässt sich für passende Anbieter als Passwort transportieren; +Cloud-Adapter bevorzugen explizit injizierte Credential-Provider für temporäre +Tokens und Managed Identity. Kein implizites Lesen von Environment-Variablen. +Broker-Zugangsdaten und HMAC-Shared-Secret sind getrennte Einstellungen. + +Attribute enthalten höchstens lokale Beispiel-DSNs oder Verbindungsnamen, +keine produktiven Secrets. Für produktive Deployment-Konfiguration ist die +programmatische Factory vorzuziehen. + +## § 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 $topic, string $type, array|object $payload, + ?PublishOptions $options = null): PublishReceipt; +emit(object $message, ?PublishOptions $options = null): PublishReceipt; +subscribe(string $topic, string $subscription, callable $handler, + ?SubscriptionOptions $options = null): SubscriptionHandle; +registerHandlers(object $handler): void; +run(?RunOptions $options = null): void; +stop(): void; +close(): void; +``` + +`publish` benennt Topic und Typ ausdrücklich; `emit` liest sie aus Registry +oder Attribut der lokalen Sendeklasse. Ein fehlendes oder widersprüchliches +Mapping wirft `MessageMappingException`. Explizites Mapping hat Vorrang vor +Attributen; mehrfache programmatische Registrierung desselben Sendetyps wird +abgelehnt. `PublishOptions` kann eine stabile `messageId`, `expiresAt`, +`correlationId` und Attachments tragen. Ein Retry eines unklar bestätigten +Publishes verwendet dieselbe ID und denselben fachlichen Inhalt. + +`SubscriptionOptions` enthält optional `type` als exakten Filter, +`payloadClass` als lokale Zielklasse, `startAt`, `ackMode`, `retryPolicy` und +`durability` (Default `Durability::Durable`). Memory/Unix-Tests wählen explizit +`Durability::Volatile`; damit wird keine Haltbarkeit über Prozessneustarts +versprochen. Fehlende angeforderte Haltbarkeit ist ein Capability-Fehler. +Ohne Typfilter muss der Array-Handler alle +Nachrichtentypen des Topics verarbeiten können. Nicht passende Typen werden +für diese Subscription bewusst übersprungen und bestätigt; ein separater +Handler darf nicht dieselbe Subscription mit anderem Filter übernehmen. +Filteränderungen benötigen eine neue Subscription oder explizite Migration. + +`subscribe` registriert und bindet, `run` startet den blockierenden Empfang. +Vorgesehen: `RunOptions(maxMessages, maxSeconds, idleTimeoutSeconds)`; +`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. + +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. +`context->extendLease(seconds: ...)` ist capabilityabhängig; lange synchrone +Handler müssen aktiv verlängern oder eine ausreichende Lease konfigurieren. + +Retry kann durch native Redelivery/Visibility oder Adapterlogik erfolgen. +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')]` auf DTOs und +`#[Subscribe(topic: 'users', subscription: 'billing-users', +type: 'user.created.v1')]` auf öffentlichen Methoden. 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. + +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`. + +## § 7 Redis-Standard und Konnektorvergleich + +Die folgende Bewertung ist eine Designableitung aus den verlinkten +Primärquellen, keine Aussage über bereits implementierte Adapter. + +| Kandidat | Relevante Fähigkeiten | Konsequenz für dieses Paket | +|---|---|---| +| Redis Streams | Log, Consumer Groups, Pending-Liste, Ack, Claim verwaister Nachrichten | Standard: Stream pro Topic, Gruppe pro Subscription, eindeutiger Consumer pro Worker | +| Redis Pub/Sub | Flüchtige Broadcasts und Patterns; at most once | Optionaler eigener Modus, kein Ersatz für dauerhafte Subscriptions | +| Amazon SQS | Arbeitsqueue; Consumer teilen Nachrichten | `sqs` meldet nur konkurrierende Queue-Verarbeitung; zweite unabhängige Fan-out-Subscription wird abgelehnt | +| Amazon SNS + SQS | Topic-Fan-out in getrennte Queues | Vollständiges Subscription-Modell über SNS-Topic und Queue je Subscription | +| Azure Service Bus | Queues, Topics, dauerhafte Subscriptions und Filter | Geeigneter Cloud-Adapter; Credential-/PHP-Client-Auswahl noch prüfen | +| RabbitMQ | Exchanges/Bindings, Queues, Consumer-Ack und Publisher Confirms | Topic auf Exchange, Subscription auf Queue; AMQP-Protokollversion ausdrücklich festlegen | +| NATS JetStream | Persistente Streams, langlebige Consumer, Ack und Redelivery | Späterer Adapter, Core NATS nicht mit JetStream gleichsetzen | +| In-Memory | Prozessinterne kontrollierte Zustellung | Frühes Testwerkzeug, kein Ersatz für Brokerintegrationstests | +| Unix-Socket | Lokaler Byte-Transport | Benötigt Dev-Broker für Routing, Gruppen und Receipts; keine Queue allein durch Socket/Semaphore | + +Redis benötigt getrennte Empfangs-/Publish-Verbindungen, begrenztes Blocking +und eindeutige Consumer-IDs. `XREADGROUP` liefert neue Nachrichten; Pending- +Recovery über `XAUTOCLAIM` und Ack über `XACK`. Ein Ack darf den Stream-Eintrag +nicht global löschen, solange andere Subscriptions ihn brauchen. +Aufbewahrungsregeln berücksichtigen langsame Gruppen und Pending-Einträge; +aggressives `MAXLEN` kann noch benötigte Daten entfernen. Redis-Persistenz, +Replikation und Eviction-Policy sind Betriebsentscheidungen und bestimmen +die tatsächliche Haltbarkeit. Der Adapter muss verlorene/ge-trimmte Pending- +Einträge sichtbar melden und darf sie nicht als erfolgreich verarbeitet werten. + +`capabilities()` beschreibt mindestens durableSubscriptions, competingConsumers, +acknowledgements, retry, deadLetter, leaseExtension, replay, delayedPublish, +ordering, filtering und maxFrameBytes. Zusätzliche Optionen werden nur bei +Unterstützung akzeptiert; etwa Delay, Priorität, FIFO und Transaktionen sind +keine universellen Versprechen. Transportgrößen werden inklusive Envelope, +Signatur, Encoding und Anbieter-Metadaten bewertet, nicht allein am Payload. + +### § 7.1 Was andere PHP-Abstraktionen bereits vorsehen + +Symfony Messenger zeigt DSN-Transports, Handler-Attribute, Envelopes/Middleware, +Retry/Failure-Transports, Worker-Limits, In-Memory-Tests und optionale +Message-Signierung. PHP Enqueue zeigt Connection-Factory, Context, +Producer/Consumer und explizite Acknowledgements. Daraus übernehmen wir eine +kleine öffentliche API, separate Transportverträge und einen klaren +Fehler-/Worker-Lebenszyklus. Das Paket wird dadurch kein Framework und +benötigt weder Symfony-Servicecontainer noch automatische Handler-Suche. + +## § 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; kein generiertes, +fest eingebautes 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. +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, Retry- und Replay-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 Redis-Deduplication. + +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. Dieses +Claim-Check-Verfahren ist auch bei AWS/Azure beschrieben. Binärdaten werden +nicht unbeschränkt base64-kodiert in Redis/SQS geschoben. + +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 lokale FileStore funktioniert nur bei gemeinsam zugänglichem Dateisystem; +für mehrere Hosts braucht es etwa S3 oder Azure Blob. 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: Memory, Redis-Socket und Dev-Broker + +`memory://` durchläuft denselben Codec, dieselbe Signierung und dieselbe +Schema-Bridge. Es kopiert serialisierte Nachrichten, keine veränderbaren +Objektreferenzen. Zwei unabhängig erstellte Memory-Verbindungen teilen keinen +Broker; für Sender/Empfänger innerhalb eines Tests wird derselbe explizite +`InMemoryBroker` an zwei Konnektoren injiziert. Deterministische Clock und +kontrollierte Redelivery sind nützliche spätere Test-Hooks. + +`redis+unix://` ist die einfache lokale Variante mit echter Redis-Semantik. +`unix://` ist dagegen ein eigener Konnektor: Ein separat gestarteter +`UnixDevBroker` verwaltet Topics, Subscriptions, konkurrierende Consumer und +volatile Pending-Receipts. Vorgeschlagenes Protokoll: begrenzte längenpräfixierte +Frames, Version, Request-ID, Publish, Subscribe, Delivery, Ack und Release; +partielle Reads/Writes, Backpressure und Disconnect müssen behandelt werden. +Nach Disconnect wird nicht bestätigte Arbeit erneut angeboten, solange der +Broker lebt; nach Broker-Neustart ist dessen Arbeitsspeicher verloren. + +Eine Semaphore koordiniert Zugriffe oder signalisiert Zustände, speichert +aber weder Nachrichten noch Abonnements. Sie ist höchstens ein internes +Hilfsmittel. Socketdatei und Elternverzeichnis brauchen passende Zugriffsrechte; +kein weltbeschreibbarer gemeinsamer Pfad, kein Überschreiben fremder Sockets. +Windows-Unterstützung, persistentes Spooling, Clustering und ein eigener +produktiver Broker gehören nicht zur ersten lokalen Implementierung. + +## § 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 | +| `UnsupportedConnectorException` / `MissingDependencyException` | Treiber oder Schema-Bridge fehlt; vor Workerstart abbrechen | +| `UnsupportedCapabilityException` | Dauerhafter Fan-out mit reinem SQS oder Replay ohne Unterstützung | +| `ConnectionException` / `AuthenticationException` | Netzwerkproblem retrybar; falsche Credentials nicht endlos wiederholen | +| `PublishException` | Annahme fehlgeschlagen oder unbekannt; `outcome` = rejected/unknown | +| `MessageMappingException` / `InvalidHandlerException` | Fehlender Typname, Konflikt oder mehrdeutige Reflection | +| `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` / `LeaseLostException` | Ack fehlgeschlagen/Lease verloren; Duplikate berücksichtigen | +| `FailureStoreException` | Sichere Fehlerablage fehlgeschlagen; kein Ack, Worker abbrechen | + +Validierung meldet konkrete Pfade und erwartete Typen, aber keine sensiblen +Istwerte. Ein unbekannter Typ wird bei explizitem Filter übersprungen; +trifft er einen Handler, der ein registriertes Schema verlangt, ist dies ein +Mappingfehler. Nicht explizit klassifizierte Handler-Exceptions werden +begrenzt wiederholt und anschließend abgelegt. Syntax-/Konfigurationsfehler +sind keine Nachrichten-Retries. + +## § 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. Provider-SDKs werden über optionale Adapterpakete +eingebunden; welche davon als eigene Composer-Pakete erscheinen, wird bei +der Implementierungsplanung entschieden. SDK-Verträge lassen sich unabhängig +von Brokerinstallationen verteilen. + +Nicht in den Kern gehören fachliche DTOs, Business-Workflows, vollständige +Job-Scheduler, RPC-Ergebnisverwaltung, Broker-Provisionierung über Cloud-IAM, +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. + +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 Socket-Teilverarbeitung. Dieser +Entwurfs-PR fügt keine Laufzeitimplementierung oder Tests dafür hinzu. + +Primärquellen, abgerufen am 2026-09-12: + +- §§ 1, 7: [Redis Pub/Sub und Zustellgarantien](https://redis.io/docs/latest/develop/pubsub/), [XREADGROUP](https://redis.io/docs/latest/commands/xreadgroup/), [XAUTOCLAIM](https://redis.io/docs/latest/commands/xautoclaim/). +- §§ 3, 5, 7.1, 8: [Symfony Messenger: Transports, Retry, Attribute und Signierung](https://symfony.com/doc/current/messenger.html), [PHP Enqueue Quick Tour](https://php-enqueue.github.io/quick_tour/). +- § 7: [SNS-Fan-out an SQS](https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html), [Azure Service Bus: Queues, Topics, Subscriptions](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-queues-topics-subscriptions). +- § 7: [RabbitMQ Exchanges](https://www.rabbitmq.com/docs/exchanges), [Acknowledgements und Publisher Confirms](https://www.rabbitmq.com/docs/confirms), [NATS JetStream Consumers](https://docs.nats.io/learn/jetstream/pull-consumers). +- § 9: [AWS SQS Extended Client und S3](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-managing-large-messages.html), [Azure Claim-Check Pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/claim-check). +- § 10: [PHP stream_socket_server](https://www.php.net/manual/en/function.stream-socket-server.php). +- § 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). diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php new file mode 100644 index 0000000..1d8855c --- /dev/null +++ b/examples/api-draft/01-connect.php @@ -0,0 +1,84 @@ +connect($dsn, optionsForDevelopment($sharedSecret)); +} + +// 2. Direkter Konnektor: dieselbe gemeinsame API und Sicherheitskette. +function connectDirectly(string $username, string $password, string $sharedSecret): MessageQueueInterface +{ + $connector = new RedisStreamsConnector(new RedisConnection( + host: '127.0.0.1', + port: 6379, + database: 0, + username: $username, + password: $password, + prefix: 'demo', + )); + + return (new ConnectionFactory())->fromConnector($connector, optionsForDevelopment($sharedSecret)); +} + +// 3. Attribut-Konfiguration einer lokalen Verbindung; keine Secrets im Attribut. +#[QueueConnection(dsn: 'redis://127.0.0.1:6379/0?prefix=demo')] +final class LocalConnection +{ +} + +function connectWithAttribute(string $sharedSecret): MessageQueueInterface +{ + return (new ConnectionFactory())->fromAttributes( + LocalConnection::class, + optionsForDevelopment($sharedSecret), + ); +} + +// 4. Austauschbarer Security-Provider: z. B. später eigener PGP-Provider. +// Der Provider muss Topic/Audience, Keyring und Zeitprüfung implementieren. +function connectWithSecurityProvider(string $dsn, MessageSecurityInterface $security): MessageQueueInterface +{ + return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( + security: $security, + )); +} + +// Jede verwendete Connection anschließend mit $mq->close() freigeben. diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php new file mode 100644 index 0000000..c2f27df --- /dev/null +++ b/examples/api-draft/02-programmatic.php @@ -0,0 +1,95 @@ +register('user.created.v1', LocalUserCreated::class, topic: 'users'); + + $mq = (new ConnectionFactory())->connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $sharedSecret, + keyId: 'development-1', + audience: 'user-services-development', + ), + registry: $registry, + schemaMapper: new PhoreSchemaMapper(), + autoCreate: true, + )); + + try { + // Array: für diesen Typ validiert der registrierte Contract die Struktur. + $mq->subscribe('users', 'audit-users', function (array $data, MessageContext $context): void { + printf("Audit: %s / %s\n", $context->messageId, $data['userId']); + }, new SubscriptionOptions(type: 'user.created.v1')); + + // Eigene lokale DTO-Klasse: Reflection erkennt den ersten Parameter. + // Sender darf andere Klasse/anderen Namespace oder ein Array verwenden. + $mq->subscribe('users', 'billing-users', function (LocalUserCreated $user): void { + printf("Billing: %s / %s\n", $user->userId, $user->email); + }, new SubscriptionOptions(type: 'user.created.v1')); + + // Weiteres Topic im selben Worker, völlig ohne Schema/DTO-Zuordnung. + $mq->subscribe('telemetry', 'audit-telemetry', function (array $data): void { + printf("Telemetry: %s\n", json_encode($data, JSON_THROW_ON_ERROR)); + }); + + // Manuelles Topic und fachlicher Typ; zusätzlicher Schlüssel ist kompatibel. + $mq->publish('users', 'user.created.v1', [ + 'userId' => 'u-123', + 'email' => 'user@example.org', + 'displayName' => 'Optionales neues Feld', + ]); + + // Zwei unabhängige Subscriptions verarbeiten je dieselbe Nachricht. + $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + + // Typobjekt ohne Attribute: emit löst das programmatische Mapping auf. + $user = new LocalUserCreated(); + $user->userId = 'u-456'; + $user->email = 'other@example.org'; + $mq->emit($user); + $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + + // Ohne Contract registrierter Typ: JSON-Daten, keine Schema-Hydration. + $mq->publish('telemetry', 'heartbeat.v1', ['service' => 'billing']); + $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 10)); + + // Aussagekräftiger lokaler Fehler, bevor die Nachricht versendet wird. + try { + $mq->publish('users', 'user.created.v1', ['userId' => 'missing-email']); + } catch (MessageValidationException $exception) { + // Erwartet: user.created.v1: $.email: required property is missing + printf("Ungültige Nachricht: %s\n", $exception->getMessage()); + } + } finally { + $mq->close(); + } +} diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php new file mode 100644 index 0000000..8a68049 --- /dev/null +++ b/examples/api-draft/03-attributes.php @@ -0,0 +1,91 @@ +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 createConnection(string $dsn, string $sharedSecret): MessageQueueInterface +{ + return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $sharedSecret, + keyId: 'development-1', + audience: 'user-services-development', + ), + schemaMapper: new PhoreSchemaMapper(), + autoCreate: true, + )); +} + +function send(MessageQueueInterface $mq): void +{ + $user = new T_UserCreated(); + $user->userId = 'u-789'; + $user->email = 'sdk-user@example.org'; + + $mq->emit($user); // Liest MessageType, validiert und serialisiert. + + // Gleichwertige explizite API, etwa für eine andere Anwendung ohne SDK: + $mq->publish('users', 'user.created.v1', [ + 'userId' => 'u-790', + 'email' => 'manual@example.org', + ]); +} + +function demo(string $dsn, string $sharedSecret): void +{ + $mq = createConnection($dsn, $sharedSecret); + try { + // Explizite Registrierung, keine Magie und kein Container erforderlich. + $mq->registerHandlers(new UserHandlers()); + send($mq); + $mq->run(new RunOptions(maxMessages: 4, maxSeconds: 10)); + } finally { + $mq->close(); + } +} + +// Getrennte Prozesse: Empfänger legt/bindet Subscriptions vor dem ersten Senden +// an und ruft run() auf. Sender ruft danach send() auf seiner eigenen Connection +// auf. Beide verwenden denselben Broker-Prefix, HMAC-Key und dieselbe Audience. 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..870a800 --- /dev/null +++ b/examples/api-draft/04-files-and-local.php @@ -0,0 +1,115 @@ +connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $sharedSecret, + keyId: 'development-1', + audience: 'export-services-development', + ), + payloadStore: new LocalPayloadStore(directory: $storeDirectory), + autoCreate: true, + )); + + try { + $mq->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')); + + $mq->publish('exports', 'export.ready.v1', ['exportId' => 'export-42'], new PublishOptions( + attachments: [ + 'archive' => Attachment::fromPath($zipPath, contentType: 'application/zip'), + ], + )); + $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 30)); + } finally { + $mq->close(); + } +} + +// In einem Test teilen zwei Connections denselben prozessinternen Broker. +// Auch dieser Konnektor nutzt Codec/Envelope statt PHP-Objektreferenzen. +function inMemoryDemo(): void +{ + $broker = new InMemoryBroker(); + $factory = new ConnectionFactory(); + $options = new ConnectionOptions(security: new UnsignedSecurity(), autoCreate: true); + $sender = $factory->fromConnector(new InMemoryConnector($broker), $options); + $receiver = $factory->fromConnector(new InMemoryConnector($broker), $options); + + try { + $receiver->subscribe('users', 'local-users', function (array $data): void { + printf("Memory: %s\n", $data['userId']); + }, new SubscriptionOptions(durability: Durability::Volatile)); + $sender->publish('users', 'user.created.v1', ['userId' => 'local-1']); + $receiver->run(new RunOptions(maxMessages: 1, maxSeconds: 1)); + } finally { + $sender->close(); + $receiver->close(); + } +} + +// Eigener Prozess A: Dev-Broker starten. Elternverzeichnis muss privat sein. +// Volatil: Neustart des Brokers verliert gespeicherte Nachrichten/Subscriptions. +function runUnixBroker(string $socketPath): void +{ + $broker = new UnixDevBroker(socketPath: $socketPath, socketMode: 0600); + try { + $broker->run(); + } finally { + $broker->close(); + } +} + +// Prozess B (Receiver) und C (Sender) können dieselbe lokale DSN verwenden. +// Der Receiver muss seine Subscription anlegen, bevor der Sender publiziert. +function unixClientDemo(string $socketDsn): void +{ + // Beispiel: unix:///run/user/1000/phore-mq.sock + // Bewusst unsigniert nur für isolierte lokale Entwicklung. + $mq = (new ConnectionFactory())->connect($socketDsn, new ConnectionOptions( + security: new UnsignedSecurity(), + autoCreate: true, + )); + try { + $mq->subscribe('local', 'local-worker', function (array $data): void { + printf("Unix: %s\n", $data['value']); + }, new SubscriptionOptions(durability: Durability::Volatile)); + $mq->publish('local', 'ping.v1', ['value' => 'hello']); + $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); + } finally { + $mq->close(); + } +} + +// Alternative mit echter Redis-Semantik, ohne eigenen Dev-Broker: +// $factory->connect('redis+unix:///run/redis/redis.sock?db=0', $options); From 31824c522c091fd9ebb126d732327be4f08cc801 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 07:02:01 +0200 Subject: [PATCH 02/16] docs: add compact request-reply API and metadata middleware examples --- .ai-usage-info.md | 6 + README.md | 12 +- .../proposals/2026-09-12-message-queue-api.md | 275 +++++++++++++++++- examples/api-draft/05-rpc.php | 135 +++++++++ examples/api-draft/06-metadata-middleware.php | 125 ++++++++ 5 files changed, 542 insertions(+), 11 deletions(-) create mode 100644 examples/api-draft/05-rpc.php create mode 100644 examples/api-draft/06-metadata-middleware.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 1ff197c..b33db0a 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -17,6 +17,12 @@ Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht aus - [Programmatische Nutzung und eigene DTOs](examples/api-draft/02-programmatic.php) - [PHP-Attribute für SDK-Typen und Handler](examples/api-draft/03-attributes.php) - [Dateien, In-Memory und Unix-Socket](examples/api-draft/04-files-and-local.php) +- [RPC mit Rückgabewerten und Begleitmeldungen](examples/api-draft/05-rpc.php) +- [Getrennte Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) + +Für RPC sind `request()->await()` und `respond()` vorgesehen; Metadaten bleiben +außerhalb des Payloads. Middleware erhält zwei klar getrennte Hooks für Senden +und Handler-Ausführung. Auch diese Ergänzungen sind ausschließlich Entwurf. ## Globale Funktionen diff --git a/README.md b/README.md index a7e8f14..2d43583 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ aus dem Repository `phore/phore-schema`. Nachrichtentypen und Handler lassen sich programmatisch oder über PHP-Attribute zuordnen. Eine austauschbare Sicherheitsschicht signiert Nachrichten transparent mit HMAC-SHA-256; große Dateien werden über verifizierte Speicherreferenzen transportiert. +Der Entwurf umfasst außerdem Request/Reply für RPC, getrennte Metadaten +und zwei optionale Middleware-Hooks für Senden und Handler-Ausführung. **Status: API-Entwurf, noch keine Queue-Implementierung.** Composer-Metadaten und Autoloading stammen weiterhin aus der Projektvorlage. Die folgenden @@ -18,6 +20,15 @@ PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. - [Programmatisch senden und empfangen](examples/api-draft/02-programmatic.php) - [SDK-Typen und Handler mit Attributen](examples/api-draft/03-attributes.php) - [ZIP-Dateien und lokale Entwicklung](examples/api-draft/04-files-and-local.php) +- [RPC: Command, Ergebnis, Warnings und Fehler](examples/api-draft/05-rpc.php) +- [Metadaten und Diagnose-Middleware](examples/api-draft/06-metadata-middleware.php) + +Die Alltags-API bleibt klein: `publish()` sendet ein Event, `subscribe()` +empfängt Events, `request()->await()` erwartet eine Antwort, `respond()` +registriert einen Command-Handler und `run()` verarbeitet Nachrichten. +`emit($dto)` ist die kurze Variante für bereits zugeordnete SDK-Typen. +Der [Frameworkvergleich und die API-Entscheidung in § 14.1](docs/proposals/2026-09-12-message-queue-api.md) +begründen diesen Ansatz. ## Git Submodules @@ -33,4 +44,3 @@ Nachträglich initialisieren oder aktualisieren: git submodule update --init --recursive git submodule update --remote --merge ``` - diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index c332d3f..da8e5a4 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -3,6 +3,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -13,6 +14,8 @@ gleichwertig. Message-Typen besitzen stabile fachliche Namen; ihre PHP-Klassen dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. +Eine optionale Request/Reply-Schicht ergänzt RPC mit Rückgabewerten und +Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. [geändert] **Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder @@ -24,6 +27,7 @@ unverändert. Beispiele verwenden PHP >=8.3, passend zur aktuellen Vorlage. |---|---| | Erste Umsetzung | Factory, Registry, JSON-Envelope, Topic/Subscription-API, Redis Streams, In-Memory, Callback-Worker, Ack/Retry/Dead Letter, Exceptions, HMAC, optionale Schema-Bridge und Attribute | | Anschlussphase | Attachment-/PayloadStore-Vertrag mit lokalem Dateispeicher; separater Unix-Entwicklungsbroker mit Konnektor | +| Optionale RPC-Erweiterung | `request`/`respond`, Rückkanal, Ergebnis/Fehler/Warnings und Middleware aus §§ 13–14; baut auf der Queue-API auf [neu] | | Weitere Adapter | SQS für Arbeitsqueues, SNS+SQS für Fan-out, Azure Service Bus, RabbitMQ | | Spätere Erweiterungen | PGP-Provider, S3/Blob-PayloadStore, Batch, Delay, Filter, Replay, Telemetrie, optionale Outbox-/Inbox-Integration | @@ -33,6 +37,34 @@ unterstützte Serverversionen festgelegt; für Redis ist >=6.2 wegen `XAUTOCLAIM der vorgeschlagene Mindeststand. Provider-spezifische Erweiterungen dürfen nicht stillschweigend auf schwächere Semantik zurückfallen. +### § 1.1 Kleine API auf einen Blick + +Die Empfehlung ist eine einzige Queue-Fassade mit **fünf alltäglichen +Operationen**. Event, Request und Antwort-Handler sind am Verb erkennbar; +Broker, Routing, Schema und Middleware werden einmal konfiguriert. [neu] + +```php +$mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); +$mq->subscribe('users', 'billing-users', function (array $event): void { /* ... */ }); + +$reply = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3])->await(); +echo $reply->payload['quotient']; // 4; wartet ausdrücklich auf eine entfernte Antwort. + +$mq->respond('calculator', 'calculator-workers', function (array $params): array { + return ['quotient' => $params['a'] / $params['b']]; // Kurzform; vollständige Fehlerprüfung in Beispiel 05. +}, new SubscriptionOptions(type: 'math.divide.v1')); +$mq->run(); +``` + +Diese Zeilen illustrieren getrennte Sender-/Empfängerprozesse, kein sequenziell +ausführbares Skript; der Responder muss vor dem Request laufen. Factory und +`close()` gehören zum Verbindungslebenszyklus. `emit($dto)` ist ausschließlich +der Komfortaufruf für `publish` mit Mapping; Attribute registrieren dieselben +Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, +keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen +kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und +Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. [neu] + ## § 2 Begriffe und Zustellvertrag | Begriff | Bedeutung und Beispiel | @@ -73,7 +105,7 @@ lokale Bindung, löscht aber weder Subscription noch Rückstand. | Baustein | Verantwortung | |---|---| | `ConnectionFactory` / `ConnectionOptions` | DSN auswerten, installierten Adapter wählen, konfigurierte Dienste verbinden | -| `MessageQueueInterface` | `emit`, `publish`, `subscribe`, `registerHandlers`, `run`, `stop`, `close` | +| `MessageQueueInterface` | `publish`, `subscribe`, `request`, `respond`, `run`; Mapping-Komfort und Lebenszyklus gemäß § 1.1 [geändert] | | `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 | @@ -82,12 +114,14 @@ lokale Bindung, löscht aber weder Subscription noch Rückstand. | `PayloadStoreInterface` | Streams ablegen, Referenzen auflösen, Lebensdauer verwalten | | `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen | -Sendepfad: Typ/Topic auflösen → Daten normalisieren und ggf. validieren → -Dateien ablegen → Envelope kodieren → signieren → Größen-/Capability-Prüfung +Sendepfad: Typ/Topic auflösen → Send-Middleware ausführen → Daten normalisieren +und ggf. validieren → Dateien ablegen → Envelope kodieren → signieren → Größen-/Capability-Prüfung → Konnektor. Empfangspfad: begrenzten Transportframe lesen → Signatur und Zeit-/Zielbindung prüfen → Envelope dekodieren → optional Dateien verifizieren -→ lokale Struktur prüfen/hydrieren → Handler ausführen → Ack. -Dateiinhalte werden erst bei Zugriff geladen, bleiben aber vor Nutzung zu prüfen. +→ 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. [geändert] Der Konnektor kennt keine Anwendungs-DTOnamen oder Callbacks. Seine vorgeschlagenen primitiven Operationen sind `capabilities(): CapabilitySet`, @@ -160,6 +194,10 @@ publish(string $topic, string $type, array|object $payload, emit(object $message, ?PublishOptions $options = null): PublishReceipt; subscribe(string $topic, string $subscription, callable $handler, ?SubscriptionOptions $options = null): SubscriptionHandle; +request(string $topic, string $type, array|object $params, + ?RequestOptions $options = null): PendingReply; +respond(string $topic, string $subscription, callable $handler, + ?SubscriptionOptions $options = null): SubscriptionHandle; registerHandlers(object $handler): void; run(?RunOptions $options = null): void; stop(): void; @@ -171,8 +209,10 @@ oder Attribut der lokalen Sendeklasse. Ein fehlendes oder widersprüchliches Mapping wirft `MessageMappingException`. Explizites Mapping hat Vorrang vor Attributen; mehrfache programmatische Registrierung desselben Sendetyps wird abgelehnt. `PublishOptions` kann eine stabile `messageId`, `expiresAt`, -`correlationId` und Attachments tragen. Ein Retry eines unklar bestätigten -Publishes verwendet dieselbe ID und denselben fachlichen Inhalt. +`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. [geändert] `SubscriptionOptions` enthält optional `type` als exakten Filter, `payloadClass` als lokale Zielklasse, `startAt`, `ackMode`, `retryPolicy` und @@ -336,12 +376,14 @@ 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. +Testvektoren fixiert werden. [geändert] Vor Hydration, Callback oder externem Dateiabruf wird mit lokal erlaubtem Algorithmus/Key geprüft, konstantzeitlich verglichen und das Topic/Audience @@ -464,6 +506,13 @@ 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. [neu] + ## § 12 Paketgrenzen, spätere Prüfungen und Quellen In die Library gehören Transportvertrag, Registry, Worker-Lebenszyklus, @@ -474,11 +523,12 @@ der Implementierungsplanung entschieden. SDK-Verträge lassen sich unabhängig von Brokerinstallationen verteilen. Nicht in den Kern gehören fachliche DTOs, Business-Workflows, vollständige -Job-Scheduler, RPC-Ergebnisverwaltung, Broker-Provisionierung über Cloud-IAM, +Job-Scheduler, langfristige Workflow-/RPC-Ergebnisarchive, Broker-Provisionierung über Cloud-IAM, 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. +Transaktionen, Prioritäten oder Exactly-once-Zusagen. Der nun beauftragte +RPC-Umfang bleibt eine optionale Request/Reply-Erweiterung gemäß § 13. [geändert] Für die spätere Umsetzung sind fokussierte Contract-Tests vorgesehen: unabhängige Subscriptions versus Worker-Gruppe, Redelivery nach Crash, @@ -497,3 +547,208 @@ Primärquellen, abgerufen am 2026-09-12: - § 9: [AWS SQS Extended Client und S3](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-managing-large-messages.html), [Azure Claim-Check Pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/claim-check). - § 10: [PHP stream_socket_server](https://www.php.net/manual/en/function.stream-socket-server.php). - § 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. `request` veröffentlicht sofort und gibt `PendingReply` zurück; +erst `await()` blockiert. Der Responder liefert mit `return` ein Array oder +DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. [neu] + +### § 13.1 Einmalige Konfiguration und Aufruf + +`ConnectionOptions::rpc` nimmt `RpcConnectionOptions` entgegen. Der Client +konfiguriert `replyTopic` und `replySubscription` einmal pro aktiver +Client-Instanz; der Server konfiguriert eine `allowedReplyTopics`-Allowlist. +Im lokalen Beispiel dürfen beide auf derselben HMAC-Audience arbeiten. +Anwendungen verwenden eigene Reply-Topics je Instanz, oder einen expliziten +zentralen Demultiplexer; konkurrierende Client-Prozesse dürfen nicht denselben +Reply-Consumer teilen und fremde Antworten wegkonsumieren. Die Rückkanal- +Subscription wird vor Veröffentlichung des ersten Requests bestätigt. [neu] + +`RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, +nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. +`PendingReply::await(): 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. [neu] + +`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. [neu] + +### § 13.2 Nachrichtenvertrag und Zustellverhalten + +| Nachrichtenart | Body | Geschützte Metadaten | +|---|---|---| +| Request | Command-Parameter | `requestId` (= Request-`messageId`), `replyTo`, Deadline, `kind=request`, optionale fachliche `correlationId` [neu] | +| Result (`rpc.result.v1`) | Rückgabedaten | Ursprüngliche `requestId`, `kind=result`, eigene `messageId`, Antwortmetadaten und gesammelte Notices [neu] | +| Error (`rpc.error.v1`) | Sicheres Fehlerobjekt mit `code`, `message`, begrenzten `details` | `requestId`, `kind=error`, eigene `messageId`, gesammelte Notices [neu] | +| Notice (`rpc.notice.v1`) | `level`, `code`, `message`, begrenzte `details` | `requestId`, `noticeId`, eigene `messageId`, `kind=notice` [neu] | + +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. [neu] + +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. [neu] + +Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack +des Requests. 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. [neu] + +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. [neu] + +### § 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. [neu] + +`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. [neu] + +`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 `INTERNAL_ERROR` 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. [neu] + +## § 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. [neu] + +### § 14.1 Frameworkvergleich und API-Entscheidung + +| Framework / Library | Recherchierter Ansatz | Entscheidung für diese API | +|---|---|---| +| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich `request()->await()` nennen [neu] | +| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Request/Reply übernehmen; keine boolesche Option, die die Bedeutung eines normalen Sends verändert [neu] | +| RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren [neu] | +| NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen [neu] | +| MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden [neu] | +| Laravel Queues | Job-Middleware um Handler-Ausführung mit Fortsetzungs-Callback | Kleinen Callable-Hook übernehmen, ohne Laravel-Job-Basisklasse und Container [neu] | + +**Empfehlung für dieses Paket:** explizite Verben auf einer Queue-Instanz, +kleine Callbacks und optionale DTOs. Der alltägliche RPC-Aufruf benötigt nur +`request(...)->await()`, der Dienst nur `respond(...); run()`; das einmalige +Setup verwaltet Rückkanal und Policies. Zwei klare Methoden sind hier +verständlicher als ein `send` mit Mode-Flags oder ein generisches +Middleware-/Stamp-System für jeden einzelnen Aufruf. Das ist eine +Designabwägung für die beschriebenen Anforderungen, kein objektiver +Leistungsvergleich der Frameworks. [neu] + +### § 14.2 Metadaten außerhalb des fachlichen Payloads + +`PublishOptions(metadata: [...])` und `RequestOptions(metadata: [...])` +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. [neu] + +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. [neu] + +### § 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. [neu] + +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. [neu] + +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. [neu] + +### § 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. [neu] + +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. [neu] + +Quellen für §§ 13–14, abgerufen am 2026-09-12: + +- [RabbitMQ: RPC mit PHP](https://www.rabbitmq.com/tutorials/tutorial-six-php). [neu] +- [PHP Enqueue: Commands, Replies und Promise](https://php-enqueue.github.io/quick_tour/). [neu] +- [Symfony Messenger: Envelopes, Middleware und Handler-Ergebnisse](https://symfony.com/doc/current/messenger.html). [neu] +- [NATS .NET: Request/Reply und Queue-Gruppen](https://nats.io/blog/nats-dotnet-v2-alpha-release/). [neu] +- [MassTransit: Requests, Faults und Timeouts](https://masstransit.massient.com/concepts/requests). [neu] +- [Laravel 12: Job-Middleware](https://laravel.com/framework/docs/12.x/queues#job-middleware). [neu] diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php new file mode 100644 index 0000000..65117ec --- /dev/null +++ b/examples/api-draft/05-rpc.php @@ -0,0 +1,135 @@ +connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $secret, + keyId: 'development-1', + audience: 'rpc-development', + ), + rpc: new RpcConnectionOptions( + replyTopic: $client ? 'rpc.replies.client-demo' : null, + replySubscription: $client ? 'client-demo' : null, + allowedReplyTopics: ['rpc.replies.client-demo'], + ), + autoCreate: true, // Produktion: Ressourcen und ACLs vorher provisionieren. + )); +} + +final class DivideHandler +{ + // Alternative zur programmatischen Registrierung unten: registerHandlers(). + #[Respond(topic: 'calculator', subscription: 'calculator-workers', type: 'math.divide.v1')] + public function divide(array $params, RequestContext $request): array + { + // Array-Modus: fachliche Parameterprüfung explizit im Handler. + // Optional kann hier stattdessen ein lokales Parameter-DTO stehen. + foreach (['a', 'b'] as $name) { + if (!isset($params[$name]) || (!is_int($params[$name]) && !is_float($params[$name]))) { + throw new CommandFailedException( + errorCode: 'INVALID_ARGUMENT', + publicMessage: 'Die Parameter a und b müssen Zahlen sein.', + ); + } + } + + if ((float) $params['b'] === 0.0) { + // Erzeugt eine terminale, sichere Fehlerantwort am Rückkanal. + throw new CommandFailedException( + errorCode: 'DIVIDE_BY_ZERO', + publicMessage: 'Division durch null ist nicht möglich.', + ); + } + + if (abs($params['b']) < 1) { + // Sofortige Begleitmeldung; Rückgabewert bleibt davon unabhängig. + $request->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']]; + } +} + +function runServer(string $dsn, string $secret): void +{ + $mq = connect($dsn, $secret, client: false); + try { + $mq->respond('calculator', 'calculator-workers', [new DivideHandler(), 'divide'], + new SubscriptionOptions(type: 'math.divide.v1')); + + // Gleichwertige Alternative mit Attributen, NICHT zusätzlich registrieren: + // $mq->registerHandlers(new DivideHandler()); + $mq->run(); + } finally { + $mq->close(); + } +} + +function runClient(string $dsn, string $secret): void +{ + $mq = connect($dsn, $secret, client: true); + try { + // Kleiner Standardaufruf: Parameter senden und ausdrücklich auf Antwort warten. + $reply = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3])->await(); + printf("Ergebnis: %s\n", $reply->payload['quotient']); // 4 + + // Erweiterter Aufruf: Metadaten außerhalb der Parameter, Warnings live. + $pending = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0.5], new RequestOptions( + timeoutSeconds: 5, + metadata: ['app.traceId' => 'trace-demo-42', 'app.locale' => 'de-DE'], + onNotice: static function (Notice $notice): void { + printf("%s [%s]: %s\n", $notice->level, $notice->code, $notice->message); + }, + )); + // Hier kann der Client andere Arbeit erledigen; erst await() blockiert. + $reply = $pending->await(); + printf("Ergebnis: %s, Worker: %s\n", $reply->payload['quotient'], $reply->metadata['app.worker']); + // $reply->notices enthält die finale Zusammenfassung; nicht doppelt ausgeben. + // Optional: RequestOptions(responseClass: LocalResult::class), wenn die + // Connection eine PhoreSchemaMapper-Bridge verwendet; gleiches Strukturprinzip. + + try { + $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0])->await(); + } catch (RemoteCommandException $error) { + printf("Command fehlgeschlagen [%s]: %s\n", $error->errorCode, $error->getMessage()); + // Erwartet: DIVIDE_BY_ZERO; keine entfernten PHP-Stacks/Objekte. + } + } catch (RequestTimeoutException $timeout) { + // Ein Timeout stoppt das entfernte Command NICHT und sendet es nicht erneut. + printf("Keine rechtzeitige Antwort für Request %s\n", $timeout->requestId); + } finally { + $mq->close(); + } +} diff --git a/examples/api-draft/06-metadata-middleware.php b/examples/api-draft/06-metadata-middleware.php new file mode 100644 index 0000000..c6468c3 --- /dev/null +++ b/examples/api-draft/06-metadata-middleware.php @@ -0,0 +1,125 @@ +connect($dsn, new ConnectionOptions( + security: $security, + autoCreate: true, + )); + + try { + $mq = $factory->connect($dsn, new ConnectionOptions( + security: $security, + autoCreate: true, + sendMiddleware: [ + // $next: callable(OutgoingMessage): PublishReceipt + static function (OutgoingMessage $message, callable $next) use ($traceId): PublishReceipt { + // Ergänzt nur app.*-Metadaten; Payload bleibt fachlich unverändert. + // Signierung erfolgt nach diesem Hook. + return $next($message->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( + 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( + correlationId: 'request-42', + metadata: ['app.locale' => 'de-DE'], + )); + $mq->run(new RunOptions(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( + correlationId: 'request-42', + metadata: ['app.traceId' => $traceId], + )); + $diagnostics->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); + + // Für den Fehlerpfad oben: payload ['orderId' => 'order-43', 'mode' => 'reject']. + // Der Handlerfehler bleibt erhalten; die Middleware sendet separat level=error. + // RPC-Begleitmeldungen am Rückkanal zeigt zusätzlich Beispiel 05. + } finally { + $mq->close(); + } + } finally { + $diagnostics->close(); + } +} From b567dc893be2b7aa91ad1439bf9764160bdef537 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 07:10:53 +0200 Subject: [PATCH 03/16] docs: demonstrate broadcast lock coordination and competing RPC workers --- .ai-usage-info.md | 6 + README.md | 7 + .../proposals/2026-09-12-message-queue-api.md | 213 ++++++++++++++---- examples/api-draft/07-broadcast-locking.php | 185 +++++++++++++++ examples/api-draft/08-processing-workers.php | 96 ++++++++ 5 files changed, 460 insertions(+), 47 deletions(-) create mode 100644 examples/api-draft/07-broadcast-locking.php create mode 100644 examples/api-draft/08-processing-workers.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index b33db0a..e2f5f11 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -19,6 +19,12 @@ Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht aus - [Dateien, In-Memory und Unix-Socket](examples/api-draft/04-files-and-local.php) - [RPC mit Rückgabewerten und Begleitmeldungen](examples/api-draft/05-rpc.php) - [Getrennte Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) +- [Broadcast und Einsammeln aller Lock-Antworten](examples/api-draft/07-broadcast-locking.php) +- [Processing-Queue mit konkurrierenden Workern und Ergebnis](examples/api-draft/08-processing-workers.php) + +Unterschiedliche Subscription-Namen erzeugen Fan-out; identische Namen +verteilen Arbeit innerhalb einer Gruppe. Lock-Koordination zählt bestätigte +Teilnehmer einer festen Liste, kein universelles verteiltes Lock über die Queue. Für RPC sind `request()->await()` und `respond()` vorgesehen; Metadaten bleiben außerhalb des Payloads. Middleware erhält zwei klar getrennte Hooks für Senden diff --git a/README.md b/README.md index 2d43583..4b007f3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. - [ZIP-Dateien und lokale Entwicklung](examples/api-draft/04-files-and-local.php) - [RPC: Command, Ergebnis, Warnings und Fehler](examples/api-draft/05-rpc.php) - [Metadaten und Diagnose-Middleware](examples/api-draft/06-metadata-middleware.php) +- [Broadcast und Antworten aller Lock-Teilnehmer](examples/api-draft/07-broadcast-locking.php) +- [Processing-Queue: ein verfügbarer Worker und ein Ergebnis](examples/api-draft/08-processing-workers.php) + +**An alle:** pro Empfänger eine eigene Subscription. **An einen:** alle Worker +verwenden dieselbe Subscription. Das Backend verteilt die Zustellungen an +verfügbare Worker; gleichmäßiger Zufall oder Exactly-once-Ausführung werden +nicht vorausgesetzt. Die Alltags-API bleibt klein: `publish()` sendet ein Event, `subscribe()` empfängt Events, `request()->await()` erwartet eine Antwort, `respond()` diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index da8e5a4..d7aa07b 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -4,6 +4,7 @@ |---|---|---| | 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 | ## § 1 Abstract und Lieferumfang @@ -15,7 +16,7 @@ dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. Eine optionale Request/Reply-Schicht ergänzt RPC mit Rückgabewerten und -Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. [geändert] +Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. **Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder @@ -27,7 +28,7 @@ unverändert. Beispiele verwenden PHP >=8.3, passend zur aktuellen Vorlage. |---|---| | Erste Umsetzung | Factory, Registry, JSON-Envelope, Topic/Subscription-API, Redis Streams, In-Memory, Callback-Worker, Ack/Retry/Dead Letter, Exceptions, HMAC, optionale Schema-Bridge und Attribute | | Anschlussphase | Attachment-/PayloadStore-Vertrag mit lokalem Dateispeicher; separater Unix-Entwicklungsbroker mit Konnektor | -| Optionale RPC-Erweiterung | `request`/`respond`, Rückkanal, Ergebnis/Fehler/Warnings und Middleware aus §§ 13–14; baut auf der Queue-API auf [neu] | +| Optionale RPC-Erweiterung | `request`/`respond`, Rückkanal, Ergebnis/Fehler/Warnings und Middleware aus §§ 13–14; baut auf der Queue-API auf | | Weitere Adapter | SQS für Arbeitsqueues, SNS+SQS für Fan-out, Azure Service Bus, RabbitMQ | | Spätere Erweiterungen | PGP-Provider, S3/Blob-PayloadStore, Batch, Delay, Filter, Replay, Telemetrie, optionale Outbox-/Inbox-Integration | @@ -41,7 +42,7 @@ nicht stillschweigend auf schwächere Semantik zurückfallen. Die Empfehlung ist eine einzige Queue-Fassade mit **fünf alltäglichen Operationen**. Event, Request und Antwort-Handler sind am Verb erkennbar; -Broker, Routing, Schema und Middleware werden einmal konfiguriert. [neu] +Broker, Routing, Schema und Middleware werden einmal konfiguriert. ```php $mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); @@ -63,7 +64,7 @@ der Komfortaufruf für `publish` mit Mapping; Attribute registrieren dieselben Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und -Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. [neu] +Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. ## § 2 Begriffe und Zustellvertrag @@ -82,7 +83,10 @@ 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. +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. [geändert] Der Grundvertrag lautet **at least once innerhalb der konfigurierten Aufbewahrung und Verfügbarkeit**. Doppelte Zustellungen sind möglich, ebenso @@ -105,7 +109,7 @@ lokale Bindung, löscht aber weder Subscription noch Rückstand. | Baustein | Verantwortung | |---|---| | `ConnectionFactory` / `ConnectionOptions` | DSN auswerten, installierten Adapter wählen, konfigurierte Dienste verbinden | -| `MessageQueueInterface` | `publish`, `subscribe`, `request`, `respond`, `run`; Mapping-Komfort und Lebenszyklus gemäß § 1.1 [geändert] | +| `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 | @@ -121,7 +125,7 @@ Zeit-/Zielbindung prüfen → Envelope dekodieren → optional Dateien verifizie → 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. [geändert] +die Signaturprüfung noch Settlement umgehen; Details in § 14.3. Der Konnektor kennt keine Anwendungs-DTOnamen oder Callbacks. Seine vorgeschlagenen primitiven Operationen sind `capabilities(): CapabilitySet`, @@ -212,7 +216,7 @@ 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. [geändert] +`subscribe` sendet niemals automatisch einen Rückgabewert. `SubscriptionOptions` enthält optional `type` als exakten Filter, `payloadClass` als lokale Zielklasse, `startAt`, `ackMode`, `retryPolicy` und @@ -383,7 +387,7 @@ 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. [geändert] +Testvektoren fixiert werden. Vor Hydration, Callback oder externem Dateiabruf wird mit lokal erlaubtem Algorithmus/Key geprüft, konstantzeitlich verglichen und das Topic/Audience @@ -511,7 +515,7 @@ RPC ergänzt `RequestTimeoutException`, `RemoteCommandException`, 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. [neu] +Command-Fehler und ändert die Settlement-Entscheidung nicht. ## § 12 Paketgrenzen, spätere Prüfungen und Quellen @@ -528,7 +532,12 @@ 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. [geändert] +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. [neu] Für die spätere Umsetzung sind fokussierte Contract-Tests vorgesehen: unabhängige Subscriptions versus Worker-Gruppe, Redelivery nach Crash, @@ -555,7 +564,7 @@ programmatischen Responder, alternativ denselben Handler per `#[Respond]`, Parameterübergabe, Ergebnis, Warning und Fehlerbehandlung in getrennten Prozessen. `request` veröffentlicht sofort und gibt `PendingReply` zurück; erst `await()` blockiert. Der Responder liefert mit `return` ein Array oder -DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. [neu] +DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. ### § 13.1 Einmalige Konfiguration und Aufruf @@ -566,7 +575,7 @@ Im lokalen Beispiel dürfen beide auf derselben HMAC-Audience arbeiten. Anwendungen verwenden eigene Reply-Topics je Instanz, oder einen expliziten zentralen Demultiplexer; konkurrierende Client-Prozesse dürfen nicht denselben Reply-Consumer teilen und fremde Antworten wegkonsumieren. Die Rückkanal- -Subscription wird vor Veröffentlichung des ersten Requests bestätigt. [neu] +Subscription wird vor Veröffentlichung des ersten Requests bestätigt. `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. @@ -575,37 +584,42 @@ 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. [neu] +und einen unabhängig laufenden Responder verwenden. `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. [neu] +`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` [neu] | -| Result (`rpc.result.v1`) | Rückgabedaten | Ursprüngliche `requestId`, `kind=result`, eigene `messageId`, Antwortmetadaten und gesammelte Notices [neu] | -| Error (`rpc.error.v1`) | Sicheres Fehlerobjekt mit `code`, `message`, begrenzten `details` | `requestId`, `kind=error`, eigene `messageId`, gesammelte Notices [neu] | -| Notice (`rpc.notice.v1`) | `level`, `code`, `message`, begrenzte `details` | `requestId`, `noticeId`, eigene `messageId`, `kind=notice` [neu] | +| 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` | `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. [neu] +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. [neu] +eine Korrelations-ID allein ist keine Authentifizierung. + +`request()->await()` wartet 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. [neu] Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack des Requests. Bei unklarer Antwortannahme bleibt der Request wiederholbar. @@ -614,7 +628,7 @@ 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. [neu] +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 @@ -622,7 +636,7 @@ 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. [neu] +noch einen rechtzeitig ankommenden Remote-Fehler versprechen zu können. ### § 13.3 Warnings, Fehler und Rückgabe-Metadaten @@ -632,7 +646,7 @@ von `MessageContext`. Er stellt genau zwei zusätzliche Operationen bereit: `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. [neu] +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 @@ -641,7 +655,7 @@ 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. [neu] +Zusammenfassung, keine zusätzlich ungefiltert auszugebende Ereignisliste. `CommandFailedException` erzeugt eine terminale Fehlerantwort mit freigegebenem Code und Text; `await` wirft daraus lokal `RemoteCommandException` mit @@ -651,25 +665,25 @@ Infrastrukturfehler folgen zunächst der begrenzten Retry-Policy; endgültige unbekannte Fehler werden als neutraler `INTERNAL_ERROR` 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. [neu] +Empfang und Deadline-Verarbeitung fort. ## § 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. [neu] +Das ist sowohl mit normalen Events als auch mit RPC nutzbar. ### § 14.1 Frameworkvergleich und API-Entscheidung | Framework / Library | Recherchierter Ansatz | Entscheidung für diese API | |---|---|---| -| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich `request()->await()` nennen [neu] | -| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Request/Reply übernehmen; keine boolesche Option, die die Bedeutung eines normalen Sends verändert [neu] | -| RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren [neu] | -| NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen [neu] | -| MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden [neu] | -| Laravel Queues | Job-Middleware um Handler-Ausführung mit Fortsetzungs-Callback | Kleinen Callable-Hook übernehmen, ohne Laravel-Job-Basisklasse und Container [neu] | +| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich `request()->await()` nennen | +| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Request/Reply übernehmen; keine boolesche Option, die die Bedeutung eines normalen Sends verändert | +| RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren | +| NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen | +| MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden | +| Laravel Queues | Job-Middleware um Handler-Ausführung mit Fortsetzungs-Callback | Kleinen Callable-Hook übernehmen, ohne Laravel-Job-Basisklasse und Container | **Empfehlung für dieses Paket:** explizite Verben auf einer Queue-Instanz, kleine Callbacks und optionale DTOs. Der alltägliche RPC-Aufruf benötigt nur @@ -678,7 +692,7 @@ Setup verwaltet Rückkanal und Policies. Zwei klare Methoden sind hier verständlicher als ein `send` mit Mode-Flags oder ein generisches Middleware-/Stamp-System für jeden einzelnen Aufruf. Das ist eine Designabwägung für die beschriebenen Anforderungen, kein objektiver -Leistungsvergleich der Frameworks. [neu] +Leistungsvergleich der Frameworks. ### § 14.2 Metadaten außerhalb des fachlichen Payloads @@ -688,7 +702,7 @@ 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. [neu] +für Schlüsselzahl, Tiefe und Bytes werden vor Versand geprüft. Metadaten werden mit signiert; ihre Integrität ist damit geschützt, sie sind aber nicht automatisch autorisiert. Tenant-/Benutzerangaben müssen gegen die authentifizierte @@ -697,7 +711,7 @@ 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. [neu] +Es gibt dafür keine zusätzliche Spezialmethode auf der Queue-Fassade. ### § 14.3 Genau zwei optionale Middleware-Hooks @@ -707,7 +721,7 @@ 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. [neu] +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 @@ -716,7 +730,7 @@ 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. [neu] +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 @@ -725,7 +739,7 @@ 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. [neu] +wegen eines Telemetriefehlers wiederholt. ### § 14.4 Sicherheitsgrenzen und spätere Prüfung @@ -735,20 +749,125 @@ 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. [neu] +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. [neu] +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). [neu] -- [PHP Enqueue: Commands, Replies und Promise](https://php-enqueue.github.io/quick_tour/). [neu] -- [Symfony Messenger: Envelopes, Middleware und Handler-Ergebnisse](https://symfony.com/doc/current/messenger.html). [neu] -- [NATS .NET: Request/Reply und Queue-Gruppen](https://nats.io/blog/nats-dotnet-v2-alpha-release/). [neu] -- [MassTransit: Requests, Faults und Timeouts](https://masstransit.massient.com/concepts/requests). [neu] -- [Laravel 12: Job-Middleware](https://laravel.com/framework/docs/12.x/queues#job-middleware). [neu] +- [RabbitMQ: RPC mit PHP](https://www.rabbitmq.com/tutorials/tutorial-six-php). +- [PHP Enqueue: Commands, Replies und Promise](https://php-enqueue.github.io/quick_tour/). +- [Symfony Messenger: Envelopes, Middleware und Handler-Ergebnisse](https://symfony.com/doc/current/messenger.html). +- [NATS .NET: Request/Reply und Queue-Gruppen](https://nats.io/blog/nats-dotnet-v2-alpha-release/). +- [MassTransit: Requests, Faults und Timeouts](https://masstransit.massient.com/concepts/requests). +- [Laravel 12: Job-Middleware](https://laravel.com/framework/docs/12.x/queues#job-middleware). + +## § 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 [neu] | +| Alle konkreten Instanzen informieren | Je Instanz ein stabiler eigener Name, etwa `locks-instance-17` | Jede erwartete Instanz erhält ihre eigene Kopie [neu] | +| Einen Job verteilen | Alle Worker: `text-processors` | Ein verfügbarer Consumer erhält die konkrete Zustellung zur Bearbeitung [neu] | + +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 Fan-out-Capability: Redis-Gruppen, RabbitMQ-Queues oder SNS+SQS passen, +ein einzelnes SQS-Queue-Backend kann nicht allen Gruppen Kopien liefern. [neu] + +### § 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. [neu] + +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. [neu] + +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. [neu] + +„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. [neu] + +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. [neu] + +### § 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.** Die Factory +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. [neu] + +Die Auswahl erfolgt brokerabhängig anhand verfügbarer Consumer, Credits, +Prefetch und Polling. „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. [neu] + +Pro Zustellversuch wird ein Consumer ausgewählt; ein normaler Job wird +nicht an alle Worker kopiert. Bei Crash, verlorenem Ack oder Lease-Ablauf +kann derselbe Job dennoch erneut zugestellt werden. Ein pausierter alter +Worker kann nach Lease-Verlust sogar noch weiterlaufen, während ein neuer +übernimmt. „Nur ein Worker“ ist daher keine Exactly-once-/Seiteneffektgarantie: +lange Verarbeitung braucht Lease-Pflege, kritische Aktionen benötigen +Idempotenz oder ressourcenseitiges Fencing. Das Beispiel verarbeitet reinen +Text ohne externe Seiteneffekte; Ergebnis-Publish erfolgt gemäß § 13 vor +Request-Ack. [neu] + +### § 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 Lease-Verlust. +Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. [neu] + +- [Redis XREADGROUP: Verteilung innerhalb von Consumer-Gruppen](https://redis.io/docs/latest/commands/xreadgroup/). [neu] +- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). [neu] +- [Redis: begrenzte Lock-Gültigkeit, Ownership und Fencing-Hinweise](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/). [neu] + +Abruf: 2026-09-12; die konkrete API und die Barrierenlogik sind der +hier vorgeschlagene Anwendungsentwurf. [neu] diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php new file mode 100644 index 0000000..d1d3fb9 --- /dev/null +++ b/examples/api-draft/07-broadcast-locking.php @@ -0,0 +1,185 @@ +connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $secret, + keyId: 'development-1', + audience: 'lock-demo', + ), + autoCreate: true, + )); +} + +function runParticipant(string $dsn, string $secret, string $participantId, LocalLeaseManager $locks): void +{ + $mq = connect($dsn, $secret); + try { + // ENTSCHEIDEND: Jede erwartete Instanz hat einen ANDEREN Subscription-Namen. + $mq->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(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(string $dsn, string $secret, 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 = connect($dsn, $secret); + 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(); + } + }, new SubscriptionOptions(type: 'lock.state.v1')); + + // EIN Publish erreicht ALLE benannten Teilnehmer-Subscriptions. + $mq->publish('maintenance.locks', 'lock.acquire.v1', $command, + new PublishOptions(correlationId: $roundId)); + $remaining = $acquireBy - time(); + if ($remaining > 0) { + $mq->run(new RunOptions(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(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..2e3159b --- /dev/null +++ b/examples/api-draft/08-processing-workers.php @@ -0,0 +1,96 @@ +connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $secret, + keyId: 'development-1', + audience: 'processing-demo', + ), + rpc: new RpcConnectionOptions( + replyTopic: $client ? 'jobs.replies.client-demo' : null, + replySubscription: $client ? 'processing-client-demo' : null, + allowedReplyTopics: ['jobs.replies.client-demo'], + ), + autoCreate: true, + )); +} + +function runWorker(string $dsn, string $secret, string $workerId): void +{ + $mq = connect($dsn, $secret, client: false); + try { + // ENTSCHEIDEND: Alle Worker verwenden exakt dieselbe Subscription. + // $workerId NICHT an 'text-processors' anhängen, sonst entsteht Fan-out! + // Transport-Consumer-IDs vergibt die Connection unabhängig voneinander. + $mq->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(string $dsn, string $secret): void +{ + $mq = connect($dsn, $secret, client: true); + 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], + new RequestOptions(timeoutSeconds: 15)); + } + + foreach ($pending as $call) { + $reply = $call->await(); // Dispatcher ordnet auch frühere Antworten korrekt zu. + printf("%s: %s (%d Bytes), SHA-256 %s\n", + $reply->metadata['app.workerId'], + $reply->payload['normalized'], + $reply->payload['bytes'], + $reply->payload['sha256']); + } + // Jeder Job hat ein Ergebnis eines Workers. Es ist zulässig, dass ein + // Worker mehrere Jobs erhält: Gleichverteilung/Zufall wird nicht garantiert. + } finally { + $mq->close(); + } +} + +// Crash/Lease-Ablauf/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. From 2af5e569d863e51f0fecf1c3f96643fe258d3fb8 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 07:49:19 +0200 Subject: [PATCH 04/16] docs: design standard readiness checks and listener diagnostics --- .ai-usage-info.md | 11 + README.md | 10 + .../proposals/2026-09-12-message-queue-api.md | 388 +++++++++++++++++- examples/api-draft/09-system-check.php | 149 +++++++ 4 files changed, 538 insertions(+), 20 deletions(-) create mode 100644 examples/api-draft/09-system-check.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index e2f5f11..bdd2d29 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -21,6 +21,12 @@ Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht aus - [Getrennte Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) - [Broadcast und Einsammeln aller Lock-Antworten](examples/api-draft/07-broadcast-locking.php) - [Processing-Queue mit konkurrierenden Workern und Ergebnis](examples/api-draft/08-processing-workers.php) +- [Standardisierter Systemcheck und aktiver Dienststatus](examples/api-draft/09-system-check.php) + +Geplant: `check()` für Verbindung und gezielte Consumer-Bereitschaft, +`HealthState::set()` für Dienstprobleme und Wiederherstellung. Aktive +Statusereignisse und Probe-Antworten verwenden denselben versionierten Vertrag; +unbekannte/veraltete Pflichtzustände gelten nicht als bereit. Unterschiedliche Subscription-Namen erzeugen Fan-out; identische Namen verteilen Arbeit innerhalb einer Gruppe. Lock-Koordination zählt bestätigte @@ -33,3 +39,8 @@ und Handler-Ausführung. Auch diese Ergänzungen sind ausschließlich Entwurf. ## Globale Funktionen Keine implementierten globalen Funktionen vorhanden. + +Deklarierte Nachrichtenabhängigkeiten lassen sich gemeinsam mit +`check(options: new CheckOptions(requireDeclared: true))` prüfen. Der Bericht +zeigt Listener, Bereitschaft, Ursachen und optional Host-/Speicherdiagnose; +fehlende Antworten gelten als unbekannt statt als sicher fehlender Listener. diff --git a/README.md b/README.md index 4b007f3..5db009d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. - [Metadaten und Diagnose-Middleware](examples/api-draft/06-metadata-middleware.php) - [Broadcast und Antworten aller Lock-Teilnehmer](examples/api-draft/07-broadcast-locking.php) - [Processing-Queue: ein verfügbarer Worker und ein Ergebnis](examples/api-draft/08-processing-workers.php) +- [Systemcheck, Dienststatus und frühzeitige Fehlermeldungen](examples/api-draft/09-system-check.php) **An alle:** pro Empfänger eine eigene Subscription. **An einen:** alle Worker verwenden dieselbe Subscription. Das Backend verteilt die Zustellungen an @@ -34,6 +35,10 @@ Die Alltags-API bleibt klein: `publish()` sendet ein Event, `subscribe()` empfängt Events, `request()->await()` erwartet eine Antwort, `respond()` registriert einen Command-Handler und `run()` verarbeitet Nachrichten. `emit($dto)` ist die kurze Variante für bereits zugeordnete SDK-Typen. +Für Diagnose ergänzt `check()` einen standardisierten Bericht über Verbindung +und Consumer-Bereitschaft. Dienste können über einen gemeinsamen `HealthState` +Probleme aktiv melden und betroffene Verarbeitung pausieren; Frontend und +Monitoring nutzen dasselbe Statusformat. Der [Frameworkvergleich und die API-Entscheidung in § 14.1](docs/proposals/2026-09-12-message-queue-api.md) begründen diesen Ansatz. @@ -51,3 +56,8 @@ Nachträglich initialisieren oder aktualisieren: git submodule update --init --recursive git submodule update --remote --merge ``` + +Deklarierte Nachrichtenabhängigkeiten lassen sich gemeinsam mit +`check(options: new CheckOptions(requireDeclared: true))` prüfen. Der Bericht +zeigt Listener, Bereitschaft, Ursachen und optional Host-/Speicherdiagnose; +fehlende Antworten gelten als unbekannt statt als sicher fehlender Listener. diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index d7aa07b..07d6506 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -5,6 +5,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -66,6 +67,12 @@ keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterunge kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. +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. [neu] + + ## § 2 Begriffe und Zustellvertrag | Begriff | Bedeutung und Beispiel | @@ -86,7 +93,7 @@ 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. [geändert] +Verhalten, kein zusätzlicher Broadcast-Schalter beim Senden. Beispiele in § 15. Der Grundvertrag lautet **at least once innerhalb der konfigurierten Aufbewahrung und Verfügbarkeit**. Doppelte Zustellungen sind möglich, ebenso @@ -537,7 +544,7 @@ 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. [neu] +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, @@ -619,7 +626,7 @@ eine Korrelations-ID allein ist keine Authentifizierung. `request()->await()` wartet 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. [neu] +`request(all: true)`-Option oder zusätzliche Queue-Methode eingeführt. Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack des Requests. Bei unklarer Antwortannahme bleibt der Request wiederholbar. @@ -773,9 +780,9 @@ Quellen für §§ 13–14, abgerufen am 2026-09-12: | 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 [neu] | -| Alle konkreten Instanzen informieren | Je Instanz ein stabiler eigener Name, etwa `locks-instance-17` | Jede erwartete Instanz erhält ihre eigene Kopie [neu] | -| Einen Job verteilen | Alle Worker: `text-processors` | Ein verfügbarer Consumer erhält die konkrete Zustellung zur Bearbeitung [neu] | +| 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 @@ -783,7 +790,7 @@ 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 Fan-out-Capability: Redis-Gruppen, RabbitMQ-Queues oder SNS+SQS passen, -ein einzelnes SQS-Queue-Backend kann nicht allen Gruppen Kopien liefern. [neu] +ein einzelnes SQS-Queue-Backend kann nicht allen Gruppen Kopien liefern. ### § 15.2 Lock-Koordination: alle bekannten Teilnehmer antworten @@ -793,7 +800,7 @@ 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. [neu] +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 @@ -802,7 +809,7 @@ 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. [neu] +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 @@ -810,7 +817,7 @@ 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. [neu] +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 @@ -821,14 +828,14 @@ 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. [neu] +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. [neu] +beweist nicht, welcher Teilnehmer tatsächlich den Lock besitzt. ### § 15.3 Processing-Queue: ein Worker verarbeitet und antwortet @@ -838,14 +845,14 @@ mehrere Prozesse mit `respond('jobs.text', 'text-processors', ...)`. 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. [neu] +Payload und die Kennung des verarbeitenden Workers zurück. Die Auswahl erfolgt brokerabhängig anhand verfügbarer Consumer, Credits, Prefetch und Polling. „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. [neu] +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 Lease-Ablauf @@ -855,7 +862,7 @@ Worker kann nach Lease-Verlust sogar noch weiterlaufen, während ein neuer lange Verarbeitung braucht Lease-Pflege, kritische Aktionen benötigen Idempotenz oder ressourcenseitiges Fencing. Das Beispiel verarbeitet reinen Text ohne externe Seiteneffekte; Ergebnis-Publish erfolgt gemäß § 13 vor -Request-Ack. [neu] +Request-Ack. ### § 15.4 Spätere Prüfungen und Quellen @@ -863,11 +870,352 @@ 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 Lease-Verlust. -Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. [neu] +Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. -- [Redis XREADGROUP: Verteilung innerhalb von Consumer-Gruppen](https://redis.io/docs/latest/commands/xreadgroup/). [neu] -- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). [neu] -- [Redis: begrenzte Lock-Gültigkeit, Ownership und Fencing-Hinweise](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/). [neu] +- [Redis XREADGROUP: Verteilung innerhalb von Consumer-Gruppen](https://redis.io/docs/latest/commands/xreadgroup/). +- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). +- [Redis: begrenzte Lock-Gültigkeit, Ownership und Fencing-Hinweise](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/). Abruf: 2026-09-12; die konkrete API und die Barrierenlogik sind der -hier vorgeschlagene Anwendungsentwurf. [neu] +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. [neu] + +```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. [neu] + +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. [neu] + +### § 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 [neu] | +| Lokale Konfiguration | Mapping, installierter Connector, Schema-Metadaten und Security-Konfiguration sind auflösbar | Keine Ausführung eines DTO-Konstruktors/Business-Handlers als Probe [neu] | +| Ziel-Topologie | Topic/Subscription/Binding existieren, soweit der Adapter sie prüfen darf | Ohne Capability/Rechte unknown, niemals erfundener Erfolg [neu] | +| Consumer-Bereitschaft | Aktuelle Antworten der erwarteten Gruppen/Instanzen, registrierter Handler für Typ, aktive Consume-Bindung und keine blockierende Störung | Consumer-Zähler oder veraltete Redis-Gruppen allein reichen nicht [neu] | +| Anwendungsabhängigkeiten | Benannte Prüfungen melden z. B. Datenbank, Ausgabeverzeichnis oder Fremddienst bereit | Nur tatsächlich geprüfte Abhängigkeiten; Probe muss seiteneffektfrei sein [neu] | +| 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 [neu] | + +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. [neu] + +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. [neu] + +### § 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. [neu] + +`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. [neu] + +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. [neu] + +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. [neu] + +Die Runtime fragt für pausierte Ziele keine neuen Jobs ab. Bereits zugestellte +Nachrichten werden nach der Lease-/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. [neu] + +### § 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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +### § 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. [neu] + +| Status | Bedeutung | Einfluss auf `ready` | +|---|---|---| +| `healthy` | Alle angeforderten Pflichtprüfungen aktuell positiv, keine bekannten Zusatzprobleme | true für den ausgewiesenen Scope [neu] | +| `degraded` | Pflichtanforderungen erfüllt, aber Warnung, optionale Messlücke oder reduzierte Redundanz | true, solange keine blockierende Schwelle überschritten ist [neu] | +| `unhealthy` | Mindestens eine Pflichtanforderung nachweislich verletzt | false [neu] | +| `unknown` | Mindestens eine Pflichtanforderung ungeprüft, nicht beantwortet oder veraltet | false [neu] | + +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. [neu] + +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`. [neu] + +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`. [neu] + +### § 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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +### § 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. [neu] + +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. [neu] + +Primärquellen: [Redis PING](https://redis.io/docs/latest/commands/ping/) für +den eng begrenzten Verbindungsnachweis und [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. [neu] + + +### § 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. [neu] + +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. [neu] + +`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. [neu] + +`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. [neu] + +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. [neu] + +Beispiel einer Consumer-Zeile im standardisierten Bericht (synthetische Werte; +der vollständige Bericht hat zusätzlich die Felder aus § 16.5): [neu] + +```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. [neu] diff --git a/examples/api-draft/09-system-check.php b/examples/api-draft/09-system-check.php new file mode 100644 index 0000000..38f5ba3 --- /dev/null +++ b/examples/api-draft/09-system-check.php @@ -0,0 +1,149 @@ +connect($dsn, new ConnectionOptions( + security: new HmacSecurity( + sharedSecret: $secret, keyId: 'development-1', audience: 'health-demo', + ), + rpc: new RpcConnectionOptions(allowedReplyTopics: [ + 'jobs.replies.frontend-demo', // Explizit provisionierter RPC-Rückkanal. + ]), + health: $health, + autoCreate: false, + )); +} + +// Beispiel eines definierten Anwendungsfehlers aus dem injizierten Exporter. +final class OutputPermissionException extends \RuntimeException {} + +function runExportWorker( + string $dsn, string $secret, string $instanceId, string $host, + string $outputDirectory, callable $processExport, +): void { + $state = new HealthState(serviceId: 'export-service', instanceId: $instanceId); + $denied = HealthFinding::unhealthy( + code: 'OUTPUT_PERMISSION_DENIED', + publicMessage: 'Der Exportdienst kann sein Ausgabeziel nicht beschreiben.', + action: 'Berechtigungen des Ausgabeziels prüfen.', + ); + $refresh = static function (HealthState $state) use ($outputDirectory, $denied): void { + // Nur eine indikative, lesende Prüfung; ein späterer Schreibzugriff kann scheitern. + clearstatcache(true, $outputDirectory); + $state->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 = connect($dsn, $secret, 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 $dsn, string $secret, string $instanceId): MessageQueueInterface +{ + return connect($dsn, $secret, 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 +{ + $connection = $mq->check(); // Ausschließlich Broker/lokale Konfiguration. + // Alle deklarierten Anforderungen in EINEM begrenzten Check, pro Ziel mit Listenerliste. + $system = $mq->check(options: new CheckOptions(requireDeclared: true, timeoutSeconds: 3)); + // Alternativ nur die Exportfunktion; Anforderungen werden aus Konfiguration übernommen: + $export = $mq->check('jobs.export', 'export.create.v1')->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']), + ]]]; + // $system->toArray()['targets'] enthält zusätzlich jedes benötigte Topic/Typ-Paar. + // Jeder Zielbericht enthält consumers samt readiness, issues und optional diagnostics. + // Im echten Loginpfad einen passenden Check wählen; alle drei dienen hier dem API-Vergleich. +} + +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. +} From 8449ef96a2cddecbefbac4ed126e830d4ec2e1d2 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 07:57:29 +0200 Subject: [PATCH 05/16] docs: introduce PhoreMQ constructor alongside connection factory --- .ai-usage-info.md | 7 + README.md | 7 + .../proposals/2026-09-12-message-queue-api.md | 215 ++++++++++++------ examples/api-draft/01-connect.php | 40 +++- 4 files changed, 192 insertions(+), 77 deletions(-) diff --git a/.ai-usage-info.md b/.ai-usage-info.md index bdd2d29..2d30506 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -9,6 +9,13 @@ Subscriptions, optionale `phore/schema`-Hydration, HMAC-Signierung und Dateirefe `Phore\MessageQueue` ist der vorgeschlagene Namespace; Composer-Name und Autoloading sind noch unveränderte Template-Werte. +Das zentrale Objekt ist `Phore\MessageQueue\PhoreMQ`: +`$mq = new PhoreMQ($dsn, $options)` oder `new PhoreMQ($connector, $options)`. +Die optionalen `ConnectionOptions` bündeln die gesamte weitere Konfiguration. +Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das +`MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, +wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. + ## Beispiele Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: diff --git a/README.md b/README.md index 5db009d..40ada6b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ Monitoring nutzen dasselbe Statusformat. Der [Frameworkvergleich und die API-Entscheidung in § 14.1](docs/proposals/2026-09-12-message-queue-api.md) begründen diesen Ansatz. +Das zentrale Objekt ist `Phore\MessageQueue\PhoreMQ`: +`$mq = new PhoreMQ($dsn, $options)` oder `new PhoreMQ($connector, $options)`. +Die optionalen `ConnectionOptions` bündeln die gesamte weitere Konfiguration. +Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das +`MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, +wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. + ## Git Submodules Beim Klonen direkt mit auschecken: diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 07d6506..cdf0b73 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -6,18 +6,20 @@ | 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 | ## § 1 Abstract und Lieferumfang Eine frameworkunabhängige PHP-Library stellt eine gemeinsame Zugriffsschicht für Topics, dauerhafte Subscriptions und Worker bereit. Redis Streams ist der -erste produktive Konnektor. URL-Factory und direkte Konnektor-Injektion sind -gleichwertig. Message-Typen besitzen stabile fachliche Namen; ihre PHP-Klassen +erste produktive Konnektor. Das zentrale Objekt `PhoreMQ` wird direkt mit +DSN oder Konnektor und `ConnectionOptions` erzeugt; alternativ liefert die +Connection-Factory dasselbe Objekt. Message-Typen besitzen stabile fachliche Namen; ihre PHP-Klassen dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. Eine optionale Request/Reply-Schicht ergänzt RPC mit Rückgabewerten und -Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. +Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. [geändert] **Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder @@ -41,11 +43,13 @@ nicht stillschweigend auf schwächere Semantik zurückfallen. ### § 1.1 Kleine API auf einen Blick -Die Empfehlung ist eine einzige Queue-Fassade mit **fünf alltäglichen -Operationen**. Event, Request und Antwort-Handler sind am Verb erkennbar; -Broker, Routing, Schema und Middleware werden einmal konfiguriert. +Die Empfehlung ist die konkrete Queue-Fassade `PhoreMQ`, die +`MessageQueueInterface` implementiert, mit **fünf alltäglichen Operationen**. +Event, Request und Antwort-Handler sind am Verb erkennbar; Broker, Routing, +Schema und Middleware werden einmal am Objekt konfiguriert. [geändert] ```php +$mq = new PhoreMQ($dsn, $options); // Einmal erzeugen; DSN oder Connector. $mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); $mq->subscribe('users', 'billing-users', function (array $event): void { /* ... */ }); @@ -59,18 +63,18 @@ $mq->run(); ``` Diese Zeilen illustrieren getrennte Sender-/Empfängerprozesse, kein sequenziell -ausführbares Skript; der Responder muss vor dem Request laufen. Factory und -`close()` gehören zum Verbindungslebenszyklus. `emit($dto)` ist ausschließlich +ausführbares Skript; der Responder muss vor dem Request laufen. Konstruktor +bzw. Factory und `close()` gehören zum Verbindungslebenszyklus. `emit($dto)` ist ausschließlich der Komfortaufruf für `publish` mit Mapping; Attribute registrieren dieselben Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und -Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. +Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. [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. [neu] +verwaltet die Library. Details und standardisierter Vertrag in § 16. ## § 2 Begriffe und Zustellvertrag @@ -115,7 +119,8 @@ lokale Bindung, löscht aber weder Subscription noch Rückstand. | Baustein | Verantwortung | |---|---| -| `ConnectionFactory` / `ConnectionOptions` | DSN auswerten, installierten Adapter wählen, konfigurierte Dienste verbinden | +| `ConnectionFactory` / `ConnectionOptions` | Alternative Erzeugung von `PhoreMQ` und gemeinsame Konfiguration; dieselbe DSN-Auflösung wie im Konstruktor [geändert] | +| `PhoreMQ` | Zentrales Objekt; akzeptiert DSN oder Connector und Optionen, implementiert `MessageQueueInterface` und verwaltet den Lebenszyklus [neu] | | `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 | @@ -152,21 +157,99 @@ Ein Provider kann auch verschlüsseln; HMAC allein tut dies nicht. ## § 4 Verbinden und DSN-Factory [Vollständige Beispiele: 01-connect.php](../../examples/api-draft/01-connect.php). -Der vorgeschlagene Einstieg lautet: +Der normale Einstieg erzeugt unmittelbar das zentrale Objekt; die Varianten +sind Alternativen, nicht mehrere benötigte Verbindungen: [geändert] ```php +use Phore\MessageQueue\PhoreMQ; + +$mq = new PhoreMQ('redis://localhost:6379/0', $options); +// Oder einen bereits konfigurierten Connector injizieren: +$mq = new PhoreMQ($connector, $options); +// Auch mit benannten Argumenten: +$mq = new PhoreMQ(connection: $dsn, options: $options); + +// Gleichwertige Alternative, etwa im DI-Bootstrap: $factory = new ConnectionFactory(); -$mq = $factory->connect('redis://localhost:6379/0', $options); -$mq = $factory->fromConnector(new RedisStreamsConnector($redisConfig), $options); -$mq = $factory->fromAttributes(LocalConnection::class, $options); +$mq = $factory->connect($dsn, $options); // PhoreMQ +$mq = $factory->fromConnector($connector, $options); // PhoreMQ +$mq = $factory->fromAttributes(LocalConnection::class, $options); // PhoreMQ +``` + +Vorgeschlagene öffentliche Erzeugungssignaturen (Deklarationsauszug, +keine Implementierung): [neu] + +```php +// Phore\MessageQueue\PhoreMQ implements MessageQueueInterface +public function __construct( + string|ConnectorInterface $connection, + ?ConnectionOptions $options = null, +); + +// ConnectionFactory +public function connect(string $dsn, ?ConnectionOptions $options = null): PhoreMQ; +public function fromConnector(ConnectorInterface $connector, ?ConnectionOptions $options = null): PhoreMQ; +public function fromAttributes(string $class, ?ConnectionOptions $options = null): PhoreMQ; ``` -Alle drei Methoden liefern `MessageQueueInterface`. `fromAttributes` liest -genau eine lokal angegebene Klasse mit `#[QueueConnection(dsn: ...)]`; kein -automatisches Scannen des Dateisystems. Die DSN-Auswertung verwendet eine -Schema-Allowlist und erzeugt niemals beliebige PHP-Klassen aus URL-Inhalten. -Provider können explizit über `registerConnectorFactory(scheme, factory)` -registriert werden. Unbekannte Schemes/Optionen werden abgelehnt. +`ConnectorInterface` liegt unter `Phore\MessageQueue\ConnectorInterface`. +Es gibt genau eine Verbindungsangabe: String bedeutet DSN, ein Objekt muss +das Connector-Interface implementieren. Host, Port und Broker-Credentials +kommen aus DSN oder Connector-Konfiguration; Schema, Security, Routing, +Middleware, RPC, Health und Dateispeicher aus `ConnectionOptions`. Sämtliche +Einstellungen werden damit beim Erzeugen übergeben. Es gibt keine parallelen +DSN-/Connector-Felder im Optionsobjekt, keine später notwendigen Setter und +kein zusätzliches `connect()` auf dem MQ-Objekt. [neu] + +`null` bedeutet ein frisches Optionsobjekt mit denselben dokumentierten +Defaults für alle Erzeugungswege, kein implizites Lesen von Environment oder +Secrets. Erforderliche Security-/Provider-Konfiguration muss weiterhin +explizit vorliegen; fehlende Konfiguration wird nicht durch unsichere Defaults +ersetzt. Konfiguration wird beim Erzeugen validiert und als Snapshot verwendet; +spätere Mutation des Optionsobjekts ändert das laufende MQ nicht. Explizit +zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. [neu] + +Konstruktor und Factory bauen die Verbindung sofort mit begrenztem +Verbindungstimeout auf. Erfolgreiche Rückkehr liefert ein verwendbares +`PhoreMQ`; sie bestätigt noch keine fremden Listener oder nachrichtenspezifische +Bereitschaft (dafür `check`). Beide Wege werfen dieselben Konfigurations-, +DSN-, Verbindungs- und Auth-Exceptions aus § 11. Teilweise geöffnete eigene +Ressourcen werden bei einem Fehler freigegeben. Kein verstecktes Lazy-Connect +mit erst beim ersten Publish auftretendem initialem Verbindungsfehler. [neu] + +Eine interne gemeinsame Initialisierung löst DSNs auf, validiert Optionen +und bindet Connector und Dienste genau einmal. Die Factory delegiert an +diesen Erzeugungsweg; der Konstruktor ruft nicht rekursiv die öffentliche +Factory auf. Direkte Connector-Injektion umgeht ausschließlich die DSN- +Auflösung, niemals Security, Codec, Middleware oder Capability-Prüfungen. +Die Factory gibt das `PhoreMQ` selbst zurück, keinen zusätzlichen Wrapper. +Anwendungscode kann für austauschbare Abhängigkeiten weiterhin gegen +`MessageQueueInterface` typisieren. [neu] + +Ein MQ-Objekt wird einmal je Verbindung und Prozess erzeugt und für alle +zugehörigen Topics, Registrierungen, RPC und Checks wiederverwendet; kein +globaler Singleton. `close()` ist idempotent und schließt die zugehörigen +Transportressourcen, `stop()` beendet nur den Worker-Loop. Ein an `PhoreMQ` +übergebener Connector steht exklusiv unter dessen Lebenszyklusverwaltung, +auch beim gescheiterten Aufbau; er darf nicht gleichzeitig in ein zweites +MQ-Objekt injiziert werden. Für geteilte In-Memory-Daten erhält jedes MQ einen +eigenen Connector am selben `InMemoryBroker`. Separate RPC-/Health-Verbindungen +bleiben bei den in §§ 13 und 16 beschriebenen Laufzeitanforderungen nötig. [neu] + +`fromAttributes` liest genau eine lokal angegebene Klasse mit +`#[QueueConnection(dsn: ...)]`; kein automatisches Scannen des Dateisystems. +Die gemeinsame DSN-Auswertung verwendet eine Schema-Allowlist und erzeugt +niemals beliebige PHP-Klassen aus URL-Inhalten. Eigene Provider können lokal +an der Factory über `registerConnectorFactory(scheme, factory)` registriert +werden. Diese Registrierung verändert keine globale Registry: der einfache +Konstruktor kennt nur die freigegebenen Standard-Schemes; für eigene Schemes +nutzt man die konfigurierte Factory oder injiziert den Connector direkt. +Unbekannte Schemes/Optionen werden in beiden Wegen abgelehnt. [geändert] + +Vorgesehene spätere Contract-Tests: gleicher konkreter Rückgabetyp und +Funktionsumfang, gleiche Defaults/Exceptions/Sicherheitskette, einmaliger +Verbindungsaufbau, Ressourcenfreigabe bei Teilfehlern, exklusives Connector- +Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. [neu] | Vorgeschlagene DSN | Bedeutung | |---|---| @@ -174,7 +257,7 @@ registriert werden. Unbekannte Schemes/Optionen werden abgelehnt. | `rediss://user:password@host:6380/0` | Redis über TLS mit Zertifikatsprüfung | | `redis://:password@host:6379/0` | Redis-Passwort ohne ACL-Benutzer | | `redis+unix:///run/redis/redis.sock?db=0` | Redis-Server über Unix-Socket, weiterhin Redis-Protokoll | -| `memory://` | Isolierter In-Memory-Broker je Factory-Verbindung | +| `memory://` | Isolierter In-Memory-Broker je MQ-Erzeugung [geändert] | | `unix:///run/user/1000/phore-mq.sock` | Eigenes lokales MQ-Protokoll, benötigt separaten Dev-Broker | | `sqs://eu-central-1/123456789012` | Geplanter Queue-Adapter; logische Topics per Routingtabelle auf Queue-URLs abbilden | | `sns+sqs://eu-central-1/123456789012` | Geplanter Topic-Fan-out; SNS-ARNs und Subscription-Queues aus Routingtabelle | @@ -192,7 +275,7 @@ Broker-Zugangsdaten und HMAC-Shared-Secret sind getrennte Einstellungen. Attribute enthalten höchstens lokale Beispiel-DSNs oder Verbindungsnamen, keine produktiven Secrets. Für produktive Deployment-Konfiguration ist die -programmatische Factory vorzuziehen. +programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. [geändert] ## § 5 Senden, empfangen und Worker-Lebenszyklus @@ -888,7 +971,7 @@ 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. [neu] +Laufzeitfehler oder einen Ausfall unmittelbar nach der Prüfung nicht ausschließen. ```php $connection = $mq->check(); // Verbindung + lokale Konfiguration, keine Consumer-Zusage. @@ -912,7 +995,7 @@ Anforderungsliste bei `requireDeclared: true` ist ein Konfigurationsfehler. `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. [neu] +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 @@ -920,25 +1003,25 @@ 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. [neu] +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 [neu] | -| Lokale Konfiguration | Mapping, installierter Connector, Schema-Metadaten und Security-Konfiguration sind auflösbar | Keine Ausführung eines DTO-Konstruktors/Business-Handlers als Probe [neu] | -| Ziel-Topologie | Topic/Subscription/Binding existieren, soweit der Adapter sie prüfen darf | Ohne Capability/Rechte unknown, niemals erfundener Erfolg [neu] | -| Consumer-Bereitschaft | Aktuelle Antworten der erwarteten Gruppen/Instanzen, registrierter Handler für Typ, aktive Consume-Bindung und keine blockierende Störung | Consumer-Zähler oder veraltete Redis-Gruppen allein reichen nicht [neu] | -| Anwendungsabhängigkeiten | Benannte Prüfungen melden z. B. Datenbank, Ausgabeverzeichnis oder Fremddienst bereit | Nur tatsächlich geprüfte Abhängigkeiten; Probe muss seiteneffektfrei sein [neu] | -| 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 [neu] | +| 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 Capability/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 oder veraltete Redis-Gruppen 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. [neu] +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 @@ -946,7 +1029,7 @@ 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. [neu] +degraded-Bericht mit `ready=true` erzeugen. ### § 16.3 Gemeinsamer Dienstzustand: aktiv melden und auf Ping antworten @@ -958,7 +1041,7 @@ 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. [neu] +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 @@ -971,14 +1054,14 @@ 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. [neu] +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. [neu] +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 @@ -986,13 +1069,13 @@ 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. [neu] +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 Lease-/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. [neu] +und die bestehenden Retry-Grenzen bleiben wirksam. ### § 16.4 Health-Kanal, Ausfälle und Authentifizierung @@ -1003,7 +1086,7 @@ 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. [neu] +keine fachlichen Handler oder rekursiv meldende Diagnose-Middleware. Die Standardeinstellungen benutzen einen reservierten konfigurierbaren Namespace, etwa `_phore.health.probes`, `_phore.health.status` und @@ -1011,7 +1094,7 @@ Namespace, etwa `_phore.health.probes`, `_phore.health.status` und 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. [neu] +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 @@ -1019,7 +1102,7 @@ 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. [neu] +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. @@ -1028,7 +1111,7 @@ 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. [neu] +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 @@ -1037,7 +1120,7 @@ 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. [neu] +Connection sind ungültig. ### § 16.5 Versioniertes Ergebnis und stabile Fehlercodes @@ -1052,21 +1135,21 @@ 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. [neu] +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 [neu] | -| `degraded` | Pflichtanforderungen erfüllt, aber Warnung, optionale Messlücke oder reduzierte Redundanz | true, solange keine blockierende Schwelle überschritten ist [neu] | -| `unhealthy` | Mindestens eine Pflichtanforderung nachweislich verletzt | false [neu] | -| `unknown` | Mindestens eine Pflichtanforderung ungeprüft, nicht beantwortet oder veraltet | false [neu] | +| `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. [neu] +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` @@ -1077,7 +1160,7 @@ 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`. [neu] +`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 @@ -1085,7 +1168,7 @@ 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`. [neu] +und interne Details gehören nicht in `publicMessage`. ### § 16.6 Frontend, Login und Überwachung @@ -1094,7 +1177,7 @@ 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. [neu] +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 @@ -1102,7 +1185,7 @@ 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. [neu] +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 @@ -1110,12 +1193,12 @@ keine Rückstufung auf eine ältere Sequenz. Eine neue Startgeneration wird 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. [neu] +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. [neu] +Idempotenz bleiben auch nach positivem Check erforderlich. ### § 16.7 Beispiel, Ausbaustufe und spätere Prüfungen @@ -1123,7 +1206,7 @@ Idempotenz bleiben auch nach positivem Check erforderlich. [neu] 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. [neu] +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. @@ -1133,13 +1216,13 @@ 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. [neu] +automatischen Ressourcenänderungen durch einen Check. Primärquellen: [Redis PING](https://redis.io/docs/latest/commands/ping/) für den eng begrenzten Verbindungsnachweis und [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. [neu] +Entwurf, kein behaupteter branchenweiter Standard. ### § 16.8 Deklarierte Abhängigkeiten und Listenerdiagnose @@ -1149,14 +1232,14 @@ 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. [neu] +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. [neu] +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, @@ -1164,7 +1247,7 @@ 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. [neu] +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` @@ -1175,17 +1258,17 @@ 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. [neu] +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. [neu] +passenden Speichergrenzen. Beispiel einer Consumer-Zeile im standardisierten Bericht (synthetische Werte; -der vollständige Bericht hat zusätzlich die Felder aus § 16.5): [neu] +der vollständige Bericht hat zusätzlich die Felder aus § 16.5): ```json { @@ -1218,4 +1301,4 @@ der vollständige Bericht hat zusätzlich die Felder aus § 16.5): [neu] 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. [neu] +Listener, Dateneinheiten/null, Diagnose-ACL und Browser-Allowlist. diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php index 1d8855c..e592fcf 100644 --- a/examples/api-draft/01-connect.php +++ b/examples/api-draft/01-connect.php @@ -9,7 +9,8 @@ use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Connector\Redis\RedisConnection; use Phore\MessageQueue\Connector\Redis\RedisStreamsConnector; -use Phore\MessageQueue\MessageQueueInterface; +use Phore\MessageQueue\PhoreMQ; +use Phore\MessageQueue\ConnectorInterface; use Phore\MessageQueue\Schema\PhoreSchemaMapper; use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\Security\MessageSecurityInterface; @@ -34,17 +35,17 @@ function optionsForDevelopment(string $sharedSecret): ConnectionOptions ); } -// 1. URL mit Benutzername und Passwort; Sonderzeichen percent-encoden. -function connectByUrl(string $username, string $password, string $sharedSecret): MessageQueueInterface +// 1. Normalfall: new PhoreMQ mit DSN und Optionen. Sonderzeichen percent-encoden. +function connectByUrl(string $username, string $password, string $sharedSecret): PhoreMQ { $dsn = 'redis://' . rawurlencode($username) . ':' . rawurlencode($password) . '@127.0.0.1:6379/0?prefix=demo'; - return (new ConnectionFactory())->connect($dsn, optionsForDevelopment($sharedSecret)); + return new PhoreMQ(connection: $dsn, options: optionsForDevelopment($sharedSecret)); } // 2. Direkter Konnektor: dieselbe gemeinsame API und Sicherheitskette. -function connectDirectly(string $username, string $password, string $sharedSecret): MessageQueueInterface +function connectDirectly(string $username, string $password, string $sharedSecret): PhoreMQ { $connector = new RedisStreamsConnector(new RedisConnection( host: '127.0.0.1', @@ -55,16 +56,27 @@ function connectDirectly(string $username, string $password, string $sharedSecre prefix: 'demo', )); + return new PhoreMQ($connector, optionsForDevelopment($sharedSecret)); +} + +// 3. Factory als gleichwertige Alternative: Rückgabe ist ebenfalls PhoreMQ. +function connectByFactory(string $dsn, string $sharedSecret): PhoreMQ +{ + return (new ConnectionFactory())->connect($dsn, optionsForDevelopment($sharedSecret)); +} + +function connectConnectorByFactory(ConnectorInterface $connector, string $sharedSecret): PhoreMQ +{ return (new ConnectionFactory())->fromConnector($connector, optionsForDevelopment($sharedSecret)); } -// 3. Attribut-Konfiguration einer lokalen Verbindung; keine Secrets im Attribut. +// 4. Attribut-Konfiguration einer lokalen Verbindung; keine Secrets im Attribut. #[QueueConnection(dsn: 'redis://127.0.0.1:6379/0?prefix=demo')] final class LocalConnection { } -function connectWithAttribute(string $sharedSecret): MessageQueueInterface +function connectWithAttribute(string $sharedSecret): PhoreMQ { return (new ConnectionFactory())->fromAttributes( LocalConnection::class, @@ -72,13 +84,19 @@ function connectWithAttribute(string $sharedSecret): MessageQueueInterface ); } -// 4. Austauschbarer Security-Provider: z. B. später eigener PGP-Provider. +// 5. Austauschbarer Security-Provider: z. B. später eigener PGP-Provider. // Der Provider muss Topic/Audience, Keyring und Zeitprüfung implementieren. -function connectWithSecurityProvider(string $dsn, MessageSecurityInterface $security): MessageQueueInterface +function connectWithSecurityProvider(string $dsn, MessageSecurityInterface $security): PhoreMQ { - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( + return new PhoreMQ($dsn, new ConnectionOptions( security: $security, )); } -// Jede verwendete Connection anschließend mit $mq->close() freigeben. +// Eine dieser Varianten EINMAL im Bootstrap aufrufen und das erhaltene Objekt +// für publish/subscribe/request/respond/check/run weiterreichen (z. B. per DI). +// Die Beispiele 02–09 mit Factory bleiben gültig: Sie erhalten dasselbe PhoreMQ. +// Gegen MessageQueueInterface typisierte Anwendungskomponenten bleiben möglich. +// Kein Singleton; der Connector gehört exklusiv zu dieser MQ-Instanz. +// Konstruktor/Factory verbinden sofort; Fehler werfen die Exceptions aus § 11. +// Jede verwendete MQ-Instanz anschließend in finally mit $mq->close() freigeben. From 41fa5b925dee2b9199aa9e9813b1795745b0e514 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 08:05:54 +0200 Subject: [PATCH 06/16] docs: infer subscription metadata from typed callbacks and reject conflicts --- .ai-usage-info.md | 7 + README.md | 7 + .../proposals/2026-09-12-message-queue-api.md | 176 +++++++++++++++--- examples/api-draft/03-attributes.php | 109 ++++++++++- 4 files changed, 264 insertions(+), 35 deletions(-) diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 2d30506..f75f7dd 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -16,6 +16,13 @@ Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das `MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. +`subscribe($callback)` übernimmt Topic, Subscription und Wire-Typ aus dem +Mapping des ersten DTO-Parameters; am Handler reicht alternativ `#[Subscribe]`. +Offene Werte werden explizit ergänzt, widersprüchliche feste Angaben und +doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere +Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten +konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). + ## Beispiele Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: diff --git a/README.md b/README.md index 40ada6b..c19ca9c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das `MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. +`subscribe($callback)` übernimmt Topic, Subscription und Wire-Typ aus dem +Mapping des ersten DTO-Parameters; am Handler reicht alternativ `#[Subscribe]`. +Offene Werte werden explizit ergänzt, widersprüchliche feste Angaben und +doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere +Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten +konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). + ## Git Submodules Beim Klonen direkt mit auschecken: diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index cdf0b73..97413c9 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -7,6 +7,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -19,7 +20,7 @@ dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. Eine optionale Request/Reply-Schicht ergänzt RPC mit Rückgabewerten und -Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. [geändert] +Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. **Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder @@ -46,7 +47,7 @@ nicht stillschweigend auf schwächere Semantik zurückfallen. Die Empfehlung ist die konkrete Queue-Fassade `PhoreMQ`, die `MessageQueueInterface` implementiert, mit **fünf alltäglichen Operationen**. Event, Request und Antwort-Handler sind am Verb erkennbar; Broker, Routing, -Schema und Middleware werden einmal am Objekt konfiguriert. [geändert] +Schema und Middleware werden einmal am Objekt konfiguriert. ```php $mq = new PhoreMQ($dsn, $options); // Einmal erzeugen; DSN oder Connector. @@ -69,7 +70,7 @@ der Komfortaufruf für `publish` mit Mapping; Attribute registrieren dieselben Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und -Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. [geändert] +Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. Für Diagnose gibt es zusätzlich genau einen Queue-Aufruf `check()`. Dienstentwickler melden Zustandsänderungen über `HealthState::set()` in einem @@ -119,8 +120,8 @@ lokale Bindung, löscht aber weder Subscription noch Rückstand. | Baustein | Verantwortung | |---|---| -| `ConnectionFactory` / `ConnectionOptions` | Alternative Erzeugung von `PhoreMQ` und gemeinsame Konfiguration; dieselbe DSN-Auflösung wie im Konstruktor [geändert] | -| `PhoreMQ` | Zentrales Objekt; akzeptiert DSN oder Connector und Optionen, implementiert `MessageQueueInterface` und verwaltet den Lebenszyklus [neu] | +| `ConnectionFactory` / `ConnectionOptions` | Alternative Erzeugung von `PhoreMQ` und gemeinsame Konfiguration; dieselbe DSN-Auflösung 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 | @@ -158,7 +159,7 @@ Ein Provider kann auch verschlüsseln; HMAC allein tut dies nicht. [Vollständige Beispiele: 01-connect.php](../../examples/api-draft/01-connect.php). Der normale Einstieg erzeugt unmittelbar das zentrale Objekt; die Varianten -sind Alternativen, nicht mehrere benötigte Verbindungen: [geändert] +sind Alternativen, nicht mehrere benötigte Verbindungen: ```php use Phore\MessageQueue\PhoreMQ; @@ -177,7 +178,7 @@ $mq = $factory->fromAttributes(LocalConnection::class, $options); // PhoreMQ ``` Vorgeschlagene öffentliche Erzeugungssignaturen (Deklarationsauszug, -keine Implementierung): [neu] +keine Implementierung): ```php // Phore\MessageQueue\PhoreMQ implements MessageQueueInterface @@ -199,7 +200,7 @@ kommen aus DSN oder Connector-Konfiguration; Schema, Security, Routing, Middleware, RPC, Health und Dateispeicher aus `ConnectionOptions`. Sämtliche Einstellungen werden damit beim Erzeugen übergeben. Es gibt keine parallelen DSN-/Connector-Felder im Optionsobjekt, keine später notwendigen Setter und -kein zusätzliches `connect()` auf dem MQ-Objekt. [neu] +kein zusätzliches `connect()` auf dem MQ-Objekt. `null` bedeutet ein frisches Optionsobjekt mit denselben dokumentierten Defaults für alle Erzeugungswege, kein implizites Lesen von Environment oder @@ -207,7 +208,7 @@ Secrets. Erforderliche Security-/Provider-Konfiguration muss weiterhin explizit vorliegen; fehlende Konfiguration wird nicht durch unsichere Defaults ersetzt. Konfiguration wird beim Erzeugen validiert und als Snapshot verwendet; spätere Mutation des Optionsobjekts ändert das laufende MQ nicht. Explizit -zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. [neu] +zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. Konstruktor und Factory bauen die Verbindung sofort mit begrenztem Verbindungstimeout auf. Erfolgreiche Rückkehr liefert ein verwendbares @@ -215,7 +216,7 @@ Verbindungstimeout auf. Erfolgreiche Rückkehr liefert ein verwendbares Bereitschaft (dafür `check`). Beide Wege werfen dieselben Konfigurations-, DSN-, Verbindungs- und Auth-Exceptions aus § 11. Teilweise geöffnete eigene Ressourcen werden bei einem Fehler freigegeben. Kein verstecktes Lazy-Connect -mit erst beim ersten Publish auftretendem initialem Verbindungsfehler. [neu] +mit erst beim ersten Publish auftretendem initialem Verbindungsfehler. Eine interne gemeinsame Initialisierung löst DSNs auf, validiert Optionen und bindet Connector und Dienste genau einmal. Die Factory delegiert an @@ -224,7 +225,7 @@ Factory auf. Direkte Connector-Injektion umgeht ausschließlich die DSN- Auflösung, niemals Security, Codec, Middleware oder Capability-Prüfungen. Die Factory gibt das `PhoreMQ` selbst zurück, keinen zusätzlichen Wrapper. Anwendungscode kann für austauschbare Abhängigkeiten weiterhin gegen -`MessageQueueInterface` typisieren. [neu] +`MessageQueueInterface` typisieren. Ein MQ-Objekt wird einmal je Verbindung und Prozess erzeugt und für alle zugehörigen Topics, Registrierungen, RPC und Checks wiederverwendet; kein @@ -234,7 +235,7 @@ Transportressourcen, `stop()` beendet nur den Worker-Loop. Ein an `PhoreMQ` auch beim gescheiterten Aufbau; er darf nicht gleichzeitig in ein zweites MQ-Objekt injiziert werden. Für geteilte In-Memory-Daten erhält jedes MQ einen eigenen Connector am selben `InMemoryBroker`. Separate RPC-/Health-Verbindungen -bleiben bei den in §§ 13 und 16 beschriebenen Laufzeitanforderungen nötig. [neu] +bleiben bei den in §§ 13 und 16 beschriebenen Laufzeitanforderungen nötig. `fromAttributes` liest genau eine lokal angegebene Klasse mit `#[QueueConnection(dsn: ...)]`; kein automatisches Scannen des Dateisystems. @@ -244,12 +245,12 @@ an der Factory über `registerConnectorFactory(scheme, factory)` registriert werden. Diese Registrierung verändert keine globale Registry: der einfache Konstruktor kennt nur die freigegebenen Standard-Schemes; für eigene Schemes nutzt man die konfigurierte Factory oder injiziert den Connector direkt. -Unbekannte Schemes/Optionen werden in beiden Wegen abgelehnt. [geändert] +Unbekannte Schemes/Optionen werden in beiden Wegen abgelehnt. Vorgesehene spätere Contract-Tests: gleicher konkreter Rückgabetyp und Funktionsumfang, gleiche Defaults/Exceptions/Sicherheitskette, einmaliger Verbindungsaufbau, Ressourcenfreigabe bei Teilfehlern, exklusives Connector- -Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. [neu] +Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. | Vorgeschlagene DSN | Bedeutung | |---|---| @@ -257,7 +258,7 @@ Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. [neu] | `rediss://user:password@host:6380/0` | Redis über TLS mit Zertifikatsprüfung | | `redis://:password@host:6379/0` | Redis-Passwort ohne ACL-Benutzer | | `redis+unix:///run/redis/redis.sock?db=0` | Redis-Server über Unix-Socket, weiterhin Redis-Protokoll | -| `memory://` | Isolierter In-Memory-Broker je MQ-Erzeugung [geändert] | +| `memory://` | Isolierter In-Memory-Broker je MQ-Erzeugung | | `unix:///run/user/1000/phore-mq.sock` | Eigenes lokales MQ-Protokoll, benötigt separaten Dev-Broker | | `sqs://eu-central-1/123456789012` | Geplanter Queue-Adapter; logische Topics per Routingtabelle auf Queue-URLs abbilden | | `sns+sqs://eu-central-1/123456789012` | Geplanter Topic-Fan-out; SNS-ARNs und Subscription-Queues aus Routingtabelle | @@ -275,7 +276,7 @@ Broker-Zugangsdaten und HMAC-Shared-Secret sind getrennte Einstellungen. Attribute enthalten höchstens lokale Beispiel-DSNs oder Verbindungsnamen, keine produktiven Secrets. Für produktive Deployment-Konfiguration ist die -programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. [geändert] +programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. ## § 5 Senden, empfangen und Worker-Lebenszyklus @@ -286,8 +287,8 @@ Die vorgeschlagenen öffentlichen Signaturen lauten: publish(string $topic, string $type, array|object $payload, ?PublishOptions $options = null): PublishReceipt; emit(object $message, ?PublishOptions $options = null): PublishReceipt; -subscribe(string $topic, string $subscription, callable $handler, - ?SubscriptionOptions $options = null): SubscriptionHandle; +subscribe(string|callable $topic, ?string $subscription = null, + ?callable $handler = null, ?SubscriptionOptions $options = null): SubscriptionHandle; request(string $topic, string $type, array|object $params, ?RequestOptions $options = null): PendingReply; respond(string $topic, string $subscription, callable $handler, @@ -300,15 +301,17 @@ close(): void; `publish` benennt Topic und Typ ausdrücklich; `emit` liest sie aus Registry oder Attribut der lokalen Sendeklasse. Ein fehlendes oder widersprüchliches -Mapping wirft `MessageMappingException`. Explizites Mapping hat Vorrang vor -Attributen; mehrfache programmatische Registrierung desselben Sendetyps wird -abgelehnt. `PublishOptions` kann eine stabile `messageId`, `expiresAt`, +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. +`subscribe` sendet niemals automatisch einen Rückgabewert. [geändert] -`SubscriptionOptions` enthält optional `type` als exakten Filter, +`SubscriptionOptions` enthält optional `topic` und `subscription` für die +Callback-Kurzform sowie `type` als exakten Filter, `payloadClass` als lokale Zielklasse, `startAt`, `ackMode`, `retryPolicy` und `durability` (Default `Durability::Durable`). Memory/Unix-Tests wählen explizit `Durability::Volatile`; damit wird keine Haltbarkeit über Prozessneustarts @@ -317,7 +320,19 @@ Ohne Typfilter muss der Array-Handler alle Nachrichtentypen des Topics verarbeiten können. Nicht passende Typen werden für diese Subscription bewusst übersprungen und bestätigt; ein separater Handler darf nicht dieselbe Subscription mit anderem Filter übernehmen. -Filteränderungen benötigen eine neue Subscription oder explizite Migration. +Filteränderungen benötigen eine neue Subscription oder explizite Migration. [geändert] + +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 wird nur als +existierender Funktionsname akzeptiert, niemals als automatisch entdeckter +Topic-Handler. 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. [neu] `subscribe` registriert und bindet, `run` startet den blockierenden Empfang. Vorgesehen: `RunOptions(maxMessages, maxSeconds, idleTimeoutSeconds)`; @@ -348,11 +363,13 @@ geworfen statt in einer Endlosschleife verborgen. [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')]` auf DTOs und -`#[Subscribe(topic: 'users', subscription: 'billing-users', -type: 'user.created.v1')]` auf öffentlichen Methoden. PHPDoc-Annotationen als +`#[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. +PHPDoc-Feldtypen von `phore/schema` bleiben nutzbar. [geändert] Ein SDK enthält ausschließlich Contracts/DTOs und optionale Attribute, keine Connection, Secrets oder Worker. Ein SDK darf auch ganz ohne MQ-Attribute @@ -376,6 +393,7 @@ Typisierte Handler benötigen einen eindeutigen Message-Typ aus 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. [geändert] Kompatibilität bedeutet: Pflichtfelder müssen vorhanden sein, die vorhandenen bekannten Felder müssen rekursiv ihren Datentypen entsprechen. Zusätzliche @@ -414,6 +432,106 @@ Identitätsprüfungen für Wire-Payloads sowie `unserialize()` sind ausgeschloss 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. [neu] + +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. [neu] + +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. [neu] + +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: [neu] + +```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. [neu] + +`emit($dto)` verwendet dieselben festen Topic-/Typ-Angaben; die Subscription +spielt beim Senden keine Rolle. Ohne festes Topic sendet die Anwendung mit +`publish($topic, $type, $dto)` bzw. einem 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. `emit` ohne auflösbares Topic wirft +`MessageMappingException`. Mehrere Topics werden durch mehrere ausdrückliche +Publishes angesprochen, nicht durch einen verborgenen Broadcast. [neu] + +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. [neu] + +`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. [neu] + +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. [neu] + ## § 7 Redis-Standard und Konnektorvergleich Die folgende Bewertung ist eine Designableitung aus den verlinkten @@ -582,7 +700,7 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `UnsupportedCapabilityException` | Dauerhafter Fan-out mit reinem SQS oder Replay ohne Unterstützung | | `ConnectionException` / `AuthenticationException` | Netzwerkproblem retrybar; falsche Credentials nicht endlos wiederholen | | `PublishException` | Annahme fehlgeschlagen oder unbekannt; `outcome` = rejected/unknown | -| `MessageMappingException` / `InvalidHandlerException` | Fehlender Typname, Konflikt oder mehrdeutige Reflection | +| `MessageMappingException` / `InvalidHandlerException` | `MAPPING_INCOMPLETE`, `MAPPING_CONFLICT`, `DUPLICATE_SUBSCRIPTION` oder mehrdeutige Reflection; Details in § 6.2 [geändert] | | `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 | diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index 8a68049..6c9f067 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -6,7 +6,10 @@ use Phore\MessageQueue\Attribute\MessageType; use Phore\MessageQueue\Attribute\Subscribe; -use Phore\MessageQueue\ConnectionFactory; +use Phore\MessageQueue\PhoreMQ; +use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\Exception\MessageMappingException; +use Phore\MessageQueue\Exception\InvalidHandlerException; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageQueueInterface; @@ -20,7 +23,8 @@ * liegen. Hier bleiben alle Teile zum Lesen in einer Beispieldatei. */ -#[MessageType('user.created.v1', topic: 'users')] +// Feste Gruppe: mehrere Worker mit dieser Vorgabe TEILEN die Zustellungen. +#[MessageType('user.created.v1', topic: 'users', subscription: 'sdk-users')] final class T_UserCreated { public string $userId; @@ -29,8 +33,8 @@ final class T_UserCreated final class UserHandlers { - // Topic/Subscription stehen am Handler, die Klasse ergibt sich per Reflection. - #[Subscribe(topic: 'users', subscription: 'sdk-users', type: 'user.created.v1')] + // Alle drei Werte kommen aus T_UserCreated. Keine doppelte Definition. + #[Subscribe] public function onCreated(T_UserCreated $user, MessageContext $context): void { printf("SDK: %s / %s\n", $context->messageId, $user->email); @@ -47,7 +51,7 @@ public function onRawCreated(array $data): void function createConnection(string $dsn, string $sharedSecret): MessageQueueInterface { - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( + return new PhoreMQ($dsn, new ConnectionOptions( security: new HmacSecurity( sharedSecret: $sharedSecret, keyId: 'development-1', @@ -77,7 +81,7 @@ function demo(string $dsn, string $sharedSecret): void { $mq = createConnection($dsn, $sharedSecret); try { - // Explizite Registrierung, keine Magie und kein Container erforderlich. + // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); send($mq); $mq->run(new RunOptions(maxMessages: 4, maxSeconds: 10)); @@ -89,3 +93,96 @@ function demo(string $dsn, string $sharedSecret): void // Getrennte Prozesse: Empfänger legt/bindet Subscriptions vor dem ersten Senden // an und ruft run() auf. Sender ruft danach send() auf seiner eigenen Connection // auf. Beide verwenden denselben Broker-Prefix, HMAC-Key und dieselbe Audience. + + +// Alternative zur Attributregistrierung: nur den Callback übergeben. +// Auf einer eigenen MQ-Instanz statt demo()/registerHandlers() ausführen. +function demoCallback(string $dsn, string $sharedSecret): void +{ + $mq = createConnection($dsn, $sharedSecret); + 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. + send($mq); + // Empfangsschleife für registrierte Handler, kein verzögertes emit/publish. + $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + } 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(string $dsn, string $sharedSecret): void +{ + $mq = createConnection($dsn, $sharedSecret); + 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); + $mq->publish('audit.billing', 'audit.entry.v1', $entry); + // emit($entry) wäre MAPPING_INCOMPLETE: kein festes Topic auf diesem DTO. + $mq->run(new RunOptions(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) { + // 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) { + // MAPPING_INCOMPLETE: missingFields=[topic, subscription]. + } + + $subscription = $mq->subscribe($typed); + try { + try { + $mq->subscribe($typed); + } catch (InvalidHandlerException $error) { + // DUPLICATE_SUBSCRIPTION: users/sdk-users bereits lokal registriert. + // Derselbe Gruppenname in einem anderen Workerprozess ist dagegen erlaubt. + } + } finally { + $subscription->cancel(); // Lokales Binding lösen; dauerhafte Gruppe bleibt bestehen. + } +} From 04e72856fcf146079d147ca5f9aff8ccc516993b Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 08:15:15 +0200 Subject: [PATCH 07/16] docs: unify publish with optional await and direct runtime options --- .ai-usage-info.md | 12 +- README.md | 14 +- .../proposals/2026-09-12-message-queue-api.md | 248 ++++++++++++++---- examples/api-draft/02-programmatic.php | 11 +- examples/api-draft/03-attributes.php | 13 +- examples/api-draft/05-rpc.php | 54 +++- 6 files changed, 270 insertions(+), 82 deletions(-) diff --git a/.ai-usage-info.md b/.ai-usage-info.md index f75f7dd..7289676 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -23,6 +23,15 @@ doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). +`publish` sendet sofort und liefert `SendResult` mit Broker-Beleg `receipt`. +Mit konfiguriertem RPC-Rückkanal wartet optional +`publish($command)->await(timeoutSeconds: 5)` auf ein Responder-Ergebnis; +`await` sendet nicht erneut. Ohne Rückkanal ist späteres Warten nicht möglich. +Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; +`RunOptions`/`AwaitOptions` bleiben erlaubt, direkte Werte überschreiben ihre +entsprechenden Felder. Das vollständige Beispiel steht in +[05-rpc.php](examples/api-draft/05-rpc.php). + ## Beispiele Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: @@ -46,7 +55,8 @@ Unterschiedliche Subscription-Namen erzeugen Fan-out; identische Namen verteilen Arbeit innerhalb einer Gruppe. Lock-Koordination zählt bestätigte Teilnehmer einer festen Liste, kein universelles verteiltes Lock über die Queue. -Für RPC sind `request()->await()` und `respond()` vorgesehen; Metadaten bleiben +Für RPC sind `publish(...)->await()` und `respond()` vorgesehen; +`request(...)->await()` bleibt eine explizite Komfortform; Metadaten bleiben außerhalb des Payloads. Middleware erhält zwei klar getrennte Hooks für Senden und Handler-Ausführung. Auch diese Ergänzungen sind ausschließlich Entwurf. diff --git a/README.md b/README.md index c19ca9c..666693e 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,10 @@ verfügbare Worker; gleichmäßiger Zufall oder Exactly-once-Ausführung werden nicht vorausgesetzt. Die Alltags-API bleibt klein: `publish()` sendet ein Event, `subscribe()` -empfängt Events, `request()->await()` erwartet eine Antwort, `respond()` +empfängt Events, `publish(...)->await()` wartet optional auf eine Antwort, `respond()` registriert einen Command-Handler und `run()` verarbeitet Nachrichten. -`emit($dto)` ist die kurze Variante für bereits zugeordnete SDK-Typen. +`publish($dto)` übernimmt Topic und Typ aus den Metadaten des Objekts; +eine separate `emit`-Methode ist nicht mehr vorgesehen. Für Diagnose ergänzt `check()` einen standardisierten Bericht über Verbindung und Consumer-Bereitschaft. Dienste können über einen gemeinsamen `HealthState` Probleme aktiv melden und betroffene Verarbeitung pausieren; Frontend und @@ -56,6 +57,15 @@ doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). +`publish` sendet sofort und liefert `SendResult` mit Broker-Beleg `receipt`. +Mit konfiguriertem RPC-Rückkanal wartet optional +`publish($command)->await(timeoutSeconds: 5)` auf ein Responder-Ergebnis; +`await` sendet nicht erneut. Ohne Rückkanal ist späteres Warten nicht möglich. +Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; +`RunOptions`/`AwaitOptions` bleiben erlaubt, direkte Werte überschreiben ihre +entsprechenden Felder. Das vollständige Beispiel steht in +[05-rpc.php](examples/api-draft/05-rpc.php). + ## Git Submodules Beim Klonen direkt mit auschecken: diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 97413c9..509b08e 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -8,6 +8,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -45,16 +46,19 @@ nicht stillschweigend auf schwächere Semantik zurückfallen. ### § 1.1 Kleine API auf einen Blick Die Empfehlung ist die konkrete Queue-Fassade `PhoreMQ`, die -`MessageQueueInterface` implementiert, mit **fünf alltäglichen Operationen**. -Event, Request und Antwort-Handler sind am Verb erkennbar; Broker, Routing, +`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 liefern ein `SendResult`; nur dessen +optional aufgerufenes `await` wartet auf eine fachliche Antwort. [geändert] ```php $mq = new PhoreMQ($dsn, $options); // Einmal erzeugen; DSN oder Connector. $mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); $mq->subscribe('users', 'billing-users', function (array $event): void { /* ... */ }); -$reply = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3])->await(); +$reply = $mq->publish('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3]) + ->await(timeoutSeconds: 5); // Rückkanal einmal in ConnectionOptions konfigurieren. echo $reply->payload['quotient']; // 4; wartet ausdrücklich auf eine entfernte Antwort. $mq->respond('calculator', 'calculator-workers', function (array $params): array { @@ -65,12 +69,14 @@ $mq->run(); Diese Zeilen illustrieren getrennte Sender-/Empfängerprozesse, kein sequenziell ausführbares Skript; der Responder muss vor dem Request laufen. Konstruktor -bzw. Factory und `close()` gehören zum Verbindungslebenszyklus. `emit($dto)` ist ausschließlich -der Komfortaufruf für `publish` mit Mapping; Attribute registrieren dieselben +bzw. Factory und `close()` gehören zum Verbindungslebenszyklus. `publish($dto)` ist +die Objektform derselben Sendemethode mit automatischem Mapping; Attribute registrieren dieselben Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. +`request` bleibt eine optionale explizite RPC-Komfortform, ist für das Warten +nach `publish` aber nicht mehr erforderlich. [geändert] Für Diagnose gibt es zusätzlich genau einen Queue-Aufruf `check()`. Dienstentwickler melden Zustandsänderungen über `HealthState::set()` in einem @@ -102,10 +108,12 @@ Verhalten, kein zusätzlicher Broadcast-Schalter beim Senden. Beispiele in § 15 Der Grundvertrag lautet **at least once innerhalb der konfigurierten Aufbewahrung und Verfügbarkeit**. Doppelte Zustellungen sind möglich, ebenso -eine unklare Publish-Bestätigung bei Verbindungsabbruch. `PublishReceipt` -bestätigt Backend-Annahme, keine Verarbeitung durch Empfänger. Es gibt keine +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 backendübergreifende Exactly-once-Garantie und keine globale Reihenfolge. -Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. +Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. [geändert] `subscribe()` bindet eine benannte Subscription und prüft ihre Konfiguration. Neue Subscriptions beginnen standardmäßig bei `StartPosition::Latest` zum @@ -284,23 +292,23 @@ programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. Die vorgeschlagenen öffentlichen Signaturen lauten: ```php -publish(string $topic, string $type, array|object $payload, - ?PublishOptions $options = null): PublishReceipt; -emit(object $message, ?PublishOptions $options = null): PublishReceipt; +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 $topic, string $type, array|object $params, - ?RequestOptions $options = null): PendingReply; + ?RequestOptions $options = null): SendResult; respond(string $topic, string $subscription, callable $handler, ?SubscriptionOptions $options = null): SubscriptionHandle; registerHandlers(object $handler): void; -run(?RunOptions $options = null): void; +run(?RunOptions $options = null, ?int $maxMessages = null, + ?float $maxSeconds = null, ?float $idleTimeoutSeconds = null): void; stop(): void; close(): void; ``` -`publish` benennt Topic und Typ ausdrücklich; `emit` liest sie aus Registry -oder Attribut der lokalen Sendeklasse. Ein fehlendes oder widersprüchliches +`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 @@ -310,6 +318,24 @@ 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. [geändert] +`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. [neu] + +`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. [neu] + `SubscriptionOptions` enthält optional `topic` und `subscription` für die Callback-Kurzform sowie `type` als exakten Filter, `payloadClass` als lokale Zielklasse, `startAt`, `ackMode`, `retryPolicy` und @@ -320,7 +346,7 @@ Ohne Typfilter muss der Array-Handler alle Nachrichtentypen des Topics verarbeiten können. Nicht passende Typen werden für diese Subscription bewusst übersprungen und bestätigt; ein separater Handler darf nicht dieselbe Subscription mit anderem Filter übernehmen. -Filteränderungen benötigen eine neue Subscription oder explizite Migration. [geändert] +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. @@ -332,15 +358,26 @@ existierender Funktionsname akzeptiert, niemals als automatisch entdeckter Topic-Handler. 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. [neu] +keinen Callback und ist kein gültiger Aufruf. `subscribe` registriert und bindet, `run` startet den blockierenden Empfang. -Vorgesehen: `RunOptions(maxMessages, maxSeconds, idleTimeoutSeconds)`; +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. +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. [neu] Standardmäßig folgt Ack erst nach erfolgreicher Callback-Rückkehr. Ein temporärer Handlerfehler löst eine begrenzte Retry-Policy aus; endgültige @@ -369,7 +406,7 @@ 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. [geändert] +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 @@ -393,7 +430,7 @@ Typisierte Handler benötigen einen eindeutigen Message-Typ aus 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. [geändert] +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 @@ -452,7 +489,7 @@ 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. [neu] +wie in § 6 beschrieben ungültig. Der Resolver sammelt Klassenmapping/`MessageType`, ein gegebenenfalls am Callback vorhandenes `Subscribe`-Attribut und explizite Aufruf-/Optionswerte. @@ -462,21 +499,21 @@ 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. [neu] +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. [neu] +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: [neu] +Klasse übernommen: ```php #[MessageType('audit.entry.v1')] @@ -497,15 +534,16 @@ 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. [neu] +logischen Topics bezeichnet unterschiedliche Bindungen. -`emit($dto)` verwendet dieselben festen Topic-/Typ-Angaben; die Subscription -spielt beim Senden keine Rolle. Ohne festes Topic sendet die Anwendung mit -`publish($topic, $type, $dto)` bzw. einem Array. Bei einem gemappten DTO müssen +`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. `emit` ohne auflösbares Topic wirft +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. [neu] +Publishes angesprochen, nicht durch einen verborgenen Broadcast. [geändert] Die Registrierung prüft alle Metadaten vor dem Anlegen/Binden von Ressourcen. Widersprüche werfen `MessageMappingException` mit `code=MAPPING_CONFLICT`, @@ -514,7 +552,7 @@ 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. [neu] +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; @@ -523,14 +561,14 @@ eine bereits einzeln registrierte Methode darf nicht durch anschließendes 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. [neu] +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. [neu] +es bleibt ausschließlich API-Entwurf. ## § 7 Redis-Standard und Konnektorvergleich @@ -700,7 +738,9 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `UnsupportedCapabilityException` | Dauerhafter Fan-out mit reinem SQS oder Replay ohne Unterstützung | | `ConnectionException` / `AuthenticationException` | Netzwerkproblem retrybar; falsche Credentials nicht endlos wiederholen | | `PublishException` | Annahme fehlgeschlagen oder unbekannt; `outcome` = rejected/unknown | -| `MessageMappingException` / `InvalidHandlerException` | `MAPPING_INCOMPLETE`, `MAPPING_CONFLICT`, `DUPLICATE_SUBSCRIPTION` oder mehrdeutige Reflection; Details in § 6.2 [geändert] | +| `ReplyNotEnabledException` | await auf ohne Rückkanal gesendeter Nachricht; kein nachträgliches Senden [neu] | +| `PendingCapacityExceededException` | Lokale Kapazität für weitere antwortfähige Sends erschöpft; vor Publish ablehnen [neu] | +| `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 | @@ -770,9 +810,10 @@ Primärquellen, abgerufen am 2026-09-12: [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. `request` veröffentlicht sofort und gibt `PendingReply` zurück; -erst `await()` blockiert. Der Responder liefert mit `return` ein Array oder -DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. +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. [geändert] ### § 13.1 Einmalige Konfiguration und Aufruf @@ -783,16 +824,24 @@ Im lokalen Beispiel dürfen beide auf derselben HMAC-Audience arbeiten. Anwendungen verwenden eigene Reply-Topics je Instanz, oder einen expliziten zentralen Demultiplexer; konkurrierende Client-Prozesse dürfen nicht denselben Reply-Consumer teilen und fremde Antworten wegkonsumieren. Die Rückkanal- -Subscription wird vor Veröffentlichung des ersten Requests bestätigt. +Subscription wird vor Veröffentlichung jeder antwortfähigen Nachricht +bereitgestellt; auch das lokale Korrelationsregister existiert vor Publish, +damit sehr schnelle Antworten nicht verloren gehen. [geändert] `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. -`PendingReply::await(): Reply` verarbeitet nur den internen Rückkanal dieser +`SendResult::await(?AwaitOptions $options = null, ?float $timeoutSeconds = null, +?string $responseClass = null, ?callable $onNotice = 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. +`RequestOptions::responseClass`/`onNotice` liefern lediglich die Anfangswerte +für dieselben Await-Einstellungen. `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 @@ -824,10 +873,11 @@ 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. -`request()->await()` wartet absichtlich nur auf eine terminale Antwort. +`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. +`request(all: true)`-Option oder zusätzliche Queue-Methode eingeführt. [geändert] Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack des Requests. Bei unklarer Antwortannahme bleibt der Request wiederholbar. @@ -875,6 +925,99 @@ 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. +### § 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. [neu] + +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. [neu] + +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. [neu] + +`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)` +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. [neu] + +Ein lokaler Timeout wirft `RequestTimeoutException`, bricht aber den entfernten +Handler nicht ab. 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` 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. [neu] + +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. [neu] + +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 +`RequestOptions::timeoutSeconds` als ursprüngliche Remote-Frist übernimmt; +eine zweite Promise-/Worker-API entsteht dadurch nicht. [neu] + +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. [neu] + ## § 14 Metadaten, Middleware und API-Entscheidung [Beispiel 06](../../examples/api-draft/06-metadata-middleware.php) zeigt @@ -886,21 +1029,22 @@ Das ist sowohl mit normalen Events als auch mit RPC nutzbar. | Framework / Library | Recherchierter Ansatz | Entscheidung für diese API | |---|---|---| -| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich `request()->await()` nennen | -| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Request/Reply übernehmen; keine boolesche Option, die die Bedeutung eines normalen Sends verändert | +| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich durch angehängtes `await()` ausdrücken [geändert] | +| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Rückkanal vor dem Sendebefehl vorbereiten; das anschließende await löst keine zweite Sendung aus [geändert] | | RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren | | NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen | | MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden | | Laravel Queues | Job-Middleware um Handler-Ausführung mit Fortsetzungs-Callback | Kleinen Callable-Hook übernehmen, ohne Laravel-Job-Basisklasse und Container | -**Empfehlung für dieses Paket:** explizite Verben auf einer Queue-Instanz, -kleine Callbacks und optionale DTOs. Der alltägliche RPC-Aufruf benötigt nur -`request(...)->await()`, der Dienst nur `respond(...); run()`; das einmalige -Setup verwaltet Rückkanal und Policies. Zwei klare Methoden sind hier -verständlicher als ein `send` mit Mode-Flags oder ein generisches -Middleware-/Stamp-System für jeden einzelnen Aufruf. Das ist eine -Designabwägung für die beschriebenen Anforderungen, kein objektiver -Leistungsvergleich der Frameworks. +**Empfehlung für dieses Paket:** eine Sendemethode `publish` für DTOs oder +explizite Topic-/Typ-/Payload-Angaben, kleine Callbacks und optionales +`await` auf dem Sendeergebnis. Der alltägliche RPC-Aufruf lautet +`publish($command)->await(timeoutSeconds: 5)`, der Dienst verwendet +`respond(...); run()`. Das einmalige Setup verwaltet Rückkanal und Policies; +`request` bleibt eine ausdrückliche RPC-Komfortform. Direkte Laufzeitparameter +und Optionsobjekte sind gleichwertige Zugänge zur selben Konfiguration. +Dies ist die aktualisierte Designentscheidung für die Nutzeranforderungen, +kein behaupteter objektiver Leistungsvergleich der Frameworks. [geändert] ### § 14.2 Metadaten außerhalb des fachlichen Payloads diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php index c2f27df..5f8c28d 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -9,7 +9,6 @@ use Phore\MessageQueue\Exception\MessageValidationException; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageRegistry; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\Schema\PhoreSchemaMapper; use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; @@ -69,18 +68,18 @@ function demo(string $dsn, string $sharedSecret): void ]); // Zwei unabhängige Subscriptions verarbeiten je dieselbe Nachricht. - $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + $mq->run(maxMessages: 2, maxSeconds: 10); - // Typobjekt ohne Attribute: emit löst das programmatische Mapping auf. + // Typobjekt ohne Attribute: publish löst das programmatische Mapping auf. $user = new LocalUserCreated(); $user->userId = 'u-456'; $user->email = 'other@example.org'; - $mq->emit($user); - $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + $mq->publish($user); + $mq->run(maxMessages: 2, maxSeconds: 10); // Ohne Contract registrierter Typ: JSON-Daten, keine Schema-Hydration. $mq->publish('telemetry', 'heartbeat.v1', ['service' => 'billing']); - $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 10)); + $mq->run(maxMessages: 1, maxSeconds: 10); // Aussagekräftiger lokaler Fehler, bevor die Nachricht versendet wird. try { diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index 6c9f067..06932ac 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -13,7 +13,6 @@ use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageQueueInterface; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\Schema\PhoreSchemaMapper; use Phore\MessageQueue\Security\HmacSecurity; @@ -68,7 +67,7 @@ function send(MessageQueueInterface $mq): void $user->userId = 'u-789'; $user->email = 'sdk-user@example.org'; - $mq->emit($user); // Liest MessageType, validiert und serialisiert. + $mq->publish($user); // Liest MessageType, validiert und serialisiert. // Gleichwertige explizite API, etwa für eine andere Anwendung ohne SDK: $mq->publish('users', 'user.created.v1', [ @@ -84,7 +83,7 @@ function demo(string $dsn, string $sharedSecret): void // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); send($mq); - $mq->run(new RunOptions(maxMessages: 4, maxSeconds: 10)); + $mq->run(maxMessages: 4, maxSeconds: 10); } finally { $mq->close(); } @@ -105,8 +104,8 @@ function demoCallback(string $dsn, string $sharedSecret): void printf("Callback: %s / %s\n", $context->messageId, $user->email); }); // users + sdk-users + user.created.v1; automatische Hydration. send($mq); - // Empfangsschleife für registrierte Handler, kein verzögertes emit/publish. - $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + // Empfangsschleife für registrierte Handler, kein verzögertes publish. + $mq->run(maxMessages: 2, maxSeconds: 10); } finally { $mq->close(); } @@ -150,8 +149,8 @@ function demoMultipleTopics(string $dsn, string $sharedSecret): void $entry->text = 'Ein Vorgang wurde abgeschlossen.'; $mq->publish('audit.users', 'audit.entry.v1', $entry); $mq->publish('audit.billing', 'audit.entry.v1', $entry); - // emit($entry) wäre MAPPING_INCOMPLETE: kein festes Topic auf diesem DTO. - $mq->run(new RunOptions(maxMessages: 2, maxSeconds: 10)); + // publish($entry) wäre MAPPING_INCOMPLETE: kein festes Topic auf diesem DTO. + $mq->run(maxMessages: 2, maxSeconds: 10); } finally { $mq->close(); } diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php index 65117ec..19f0c0f 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -5,6 +5,10 @@ namespace Examples\MessageQueue\Rpc; use Phore\MessageQueue\Attribute\Respond; +use Phore\MessageQueue\Attribute\MessageType; +use Phore\MessageQueue\PublishOptions; +use Phore\MessageQueue\RunOptions; +use Phore\MessageQueue\Rpc\AwaitOptions; use Phore\MessageQueue\ConnectionFactory; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\MessageQueueInterface; @@ -12,7 +16,6 @@ use Phore\MessageQueue\Rpc\Notice; use Phore\MessageQueue\Rpc\RemoteCommandException; use Phore\MessageQueue\Rpc\RequestContext; -use Phore\MessageQueue\Rpc\RequestOptions; use Phore\MessageQueue\Rpc\RequestTimeoutException; use Phore\MessageQueue\Rpc\RpcConnectionOptions; use Phore\MessageQueue\Security\HmacSecurity; @@ -43,6 +46,12 @@ function connect(string $dsn, string $secret, bool $client): MessageQueueInterfa )); } +#[MessageType('math.divide.v1', topic: 'calculator')] +final class Divide +{ + public function __construct(public float $a, public float $b) {} +} + final class DivideHandler { // Alternative zur programmatischen Registrierung unten: registerHandlers(). @@ -91,7 +100,10 @@ function runServer(string $dsn, string $secret): void // Gleichwertige Alternative mit Attributen, NICHT zusätzlich registrieren: // $mq->registerHandlers(new DivideHandler()); - $mq->run(); + $mq->run(maxMessages: 100, maxSeconds: 60); + // Alternativen: run(new RunOptions(maxMessages: 100, maxSeconds: 60)) + // oder run(new RunOptions(maxSeconds: 60), maxMessages: 100). + // Direkte Werte überschreiben dieselben Optionsfelder, ohne das Objekt zu ändern. } finally { $mq->close(); } @@ -101,27 +113,38 @@ function runClient(string $dsn, string $secret): void { $mq = connect($dsn, $secret, client: true); try { - // Kleiner Standardaufruf: Parameter senden und ausdrücklich auf Antwort warten. - $reply = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3])->await(); + // Publish erkennt das DTO und übernimmt Topic/Typ. Sendet sofort. + $sent = $mq->publish(new Divide(12, 3)); + // Andere lokale Arbeit wäre hier möglich; der Broker hat die Nachricht bereits. + $reply = $sent->await(timeoutSeconds: 5); printf("Ergebnis: %s\n", $reply->payload['quotient']); // 4 - // Erweiterter Aufruf: Metadaten außerhalb der Parameter, Warnings live. - $pending = $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0.5], new RequestOptions( - timeoutSeconds: 5, + // Gleicher Aufruf in einer Zeile; await sendet NICHT erneut. + $reply = $mq->publish(new Divide(15, 3))->await(timeoutSeconds: 5); + + // Ohne Warten: ignoriertes SendResult verzögert oder verhindert das Senden nicht. + $mq->publish(new Divide(20, 4)); + // Der Responder arbeitet trotzdem; seine nicht benötigte Antwort läuft ab/wird verworfen. + + // Programmatische Variante, Metadaten vor Publish, Warteoptionen erst bei await. + $pending = $mq->publish('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0.5], new PublishOptions( + reply: true, // Rückkanal zwingend: fehlende Konfiguration scheitert VOR Publish. + replyTimeoutSeconds: 15, // Wire-Deadline ab Senden; await verlängert sie nicht. metadata: ['app.traceId' => 'trace-demo-42', 'app.locale' => 'de-DE'], + )); + $reply = $pending->await(new AwaitOptions(timeoutSeconds: 10), + timeoutSeconds: 5, // Direkter Wert überschreibt hier die 10 Sekunden. onNotice: static function (Notice $notice): void { printf("%s [%s]: %s\n", $notice->level, $notice->code, $notice->message); }, - )); - // Hier kann der Client andere Arbeit erledigen; erst await() blockiert. - $reply = $pending->await(); + ); printf("Ergebnis: %s, Worker: %s\n", $reply->payload['quotient'], $reply->metadata['app.worker']); - // $reply->notices enthält die finale Zusammenfassung; nicht doppelt ausgeben. - // Optional: RequestOptions(responseClass: LocalResult::class), wenn die - // Connection eine PhoreSchemaMapper-Bridge verwendet; gleiches Strukturprinzip. + // reply->notices enthält die finale Zusammenfassung; nicht doppelt ausgeben. + // Optional await(responseClass: LocalResult::class), mit Schema-Bridge auf der Connection. + // Reine Events können mit PublishOptions(reply: false) ohne Antwortaufwand senden. try { - $mq->request('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0])->await(); + $mq->publish(new Divide(12, 0))->await(timeoutSeconds: 5); } catch (RemoteCommandException $error) { printf("Command fehlgeschlagen [%s]: %s\n", $error->errorCode, $error->getMessage()); // Erwartet: DIVIDE_BY_ZERO; keine entfernten PHP-Stacks/Objekte. @@ -133,3 +156,6 @@ function runClient(string $dsn, string $secret): void $mq->close(); } } + +// request(...)->await() bleibt eine explizite RPC-Komfortform (Beispiel 08). +// subscribe-Handler antworten nicht automatisch: Ohne respond endet await im Timeout. From 93917ba25431b229eb6e6de3a1751ff5f34b4a88 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 08:19:08 +0200 Subject: [PATCH 08/16] docs: explain worker limits and await timeout exceptions in examples --- .../proposals/2026-09-12-message-queue-api.md | 87 +++++++++++++------ examples/api-draft/02-programmatic.php | 23 ++++- examples/api-draft/03-attributes.php | 6 ++ examples/api-draft/04-files-and-local.php | 6 ++ examples/api-draft/05-rpc.php | 22 ++++- examples/api-draft/06-metadata-middleware.php | 4 + examples/api-draft/07-broadcast-locking.php | 3 + examples/api-draft/08-processing-workers.php | 5 ++ 8 files changed, 124 insertions(+), 32 deletions(-) diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 509b08e..ff06532 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -9,6 +9,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -50,7 +51,7 @@ Die Empfehlung ist die konkrete Queue-Fassade `PhoreMQ`, die Senden, Warten und Empfang sind am jeweiligen Aufruf erkennbar; Broker, Routing, Schema und Middleware werden einmal am Objekt konfiguriert. `publish` sendet sofort und liefern ein `SendResult`; nur dessen -optional aufgerufenes `await` wartet auf eine fachliche Antwort. [geändert] +optional aufgerufenes `await` wartet auf eine fachliche Antwort. ```php $mq = new PhoreMQ($dsn, $options); // Einmal erzeugen; DSN oder Connector. @@ -76,7 +77,7 @@ keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterunge kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. `request` bleibt eine optionale explizite RPC-Komfortform, ist für das Warten -nach `publish` aber nicht mehr erforderlich. [geändert] +nach `publish` aber nicht mehr erforderlich. Für Diagnose gibt es zusätzlich genau einen Queue-Aufruf `check()`. Dienstentwickler melden Zustandsänderungen über `HealthState::set()` in einem @@ -113,7 +114,7 @@ eine unklare Publish-Bestätigung bei Verbindungsabbruch. keine Verarbeitung durch Empfänger. `SendResult::await()` liefert dagegen die fachliche Antwort eines Responders, keine Bestätigung aller Subscriber. Es gibt keine backendübergreifende Exactly-once-Garantie und keine globale Reihenfolge. -Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. [geändert] +Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. `subscribe()` bindet eine benannte Subscription und prüft ihre Konfiguration. Neue Subscriptions beginnen standardmäßig bei `StartPosition::Latest` zum @@ -316,7 +317,7 @@ desselben Sendetyps wird abgelehnt. `PublishOptions` kann eine stabile `messageI `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. [geändert] +`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. @@ -324,7 +325,7 @@ 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. [neu] +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'))`. @@ -334,7 +335,7 @@ 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. [neu] +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, @@ -367,7 +368,7 @@ direkt `run(maxMessages: 100, maxSeconds: 30, idleTimeoutSeconds: 2)`; 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] +Attachment-Zugriff. Broker-spezifische Objekte werden nicht weitergereicht. Optionsobjekte bleiben als erster Parameter erlaubt, etwa `run(new RunOptions(maxSeconds: 60), maxMessages: 100)`. Nicht-null direkt @@ -377,7 +378,35 @@ 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. [neu] +`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 aufgrund eines Typfilters übersprungener fachlicher 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 nach der bestehenden Lease-/Freigabepolicy behandelt. [neu] + +`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. [neu] + +`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 ausführlich in Beispiel 02 und am RPC-Loop in 05. [neu] Standardmäßig folgt Ack erst nach erfolgreicher Callback-Rückkehr. Ein temporärer Handlerfehler löst eine begrenzte Retry-Policy aus; endgültige @@ -543,7 +572,7 @@ spielt beim Senden keine Rolle. Ohne festes Topic ergänzt die Anwendung es mit 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. [geändert] +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`, @@ -738,8 +767,8 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `UnsupportedCapabilityException` | Dauerhafter Fan-out mit reinem SQS oder Replay ohne Unterstützung | | `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 [neu] | -| `PendingCapacityExceededException` | Lokale Kapazität für weitere antwortfähige Sends erschöpft; vor Publish ablehnen [neu] | +| `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` | @@ -813,7 +842,7 @@ 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. [geändert] +DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. ### § 13.1 Einmalige Konfiguration und Aufruf @@ -826,7 +855,7 @@ zentralen Demultiplexer; konkurrierende Client-Prozesse dürfen nicht denselben Reply-Consumer teilen und fremde Antworten wegkonsumieren. Die Rückkanal- Subscription wird vor Veröffentlichung jeder antwortfähigen Nachricht bereitgestellt; auch das lokale Korrelationsregister existiert vor Publish, -damit sehr schnelle Antworten nicht verloren gehen. [geändert] +damit sehr schnelle Antworten nicht verloren gehen. `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. @@ -841,7 +870,7 @@ und einen unabhängig laufenden Responder verwenden. `RequestOptions::responseClass`/`onNotice` liefern lediglich die Anfangswerte für dieselben Await-Einstellungen. `PendingReply` entfällt als separater Rückgabetyp im Entwurf; bestehende `request(...)->await()`-Beispiele bleiben -gültig. [geändert] +gültig. `Reply` besitzt schreibgeschützte `payload`, `metadata` und `notices`. `responseClass` hydriert `payload` strukturell nach § 6, ohne die PHP-Klasse @@ -877,7 +906,7 @@ eine Korrelations-ID allein ist keine Authentifizierung. 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. [geändert] +`request(all: true)`-Option oder zusätzliche Queue-Methode eingeführt. Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack des Requests. Bei unklarer Antwortannahme bleibt der Request wiederholbar. @@ -945,7 +974,7 @@ Builder: weder `await` noch Destruktor oder `run` veröffentlichen ihn erneut. 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. [neu] +`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. @@ -957,7 +986,7 @@ true verlangt ihn und wirft bei fehlender Konfiguration **vor dem Senden** 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. [neu] +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. @@ -967,7 +996,7 @@ Events für seinen registrierten Typ und antwortet nach demselben Protokoll. 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. [neu] +transportiert, damit keine Antwortschleifen entstehen. `PublishOptions::replyTimeoutSeconds` bestimmt die vor dem Senden signierte Antwortfrist (Default 30 Sekunden ab Sendebeginn). `expiresAt` kann die Frist @@ -979,10 +1008,12 @@ 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. [neu] +ist zu diesem Zeitpunkt ausdrücklich bereits gesendet. -Ein lokaler Timeout wirft `RequestTimeoutException`, bricht aber den entfernten -Handler nicht ab. Solange die ursprüngliche Antwortfrist läuft, darf derselbe +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. @@ -991,7 +1022,7 @@ Await-Ausführung fixiert `responseClass` 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. [neu] +wird, und sind nach Möglichkeit zusätzlich im finalen Reply enthalten. [geändert] Der Client puffert nur begrenzt viele offene Vorgänge/Antwortbytes. Eine erschöpfte Kapazität wird vor einem weiteren antwortfähigen Publish als @@ -1001,7 +1032,7 @@ 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. [neu] +`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 @@ -1010,13 +1041,13 @@ 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 `RequestOptions::timeoutSeconds` als ursprüngliche Remote-Frist übernimmt; -eine zweite Promise-/Worker-API entsteht dadurch nicht. [neu] +eine zweite Promise-/Worker-API entsteht dadurch nicht. 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. [neu] +Parameter versus Optionsobjekt. Beispiel 05 zeigt beide Sendeformen. ## § 14 Metadaten, Middleware und API-Entscheidung @@ -1029,8 +1060,8 @@ Das ist sowohl mit normalen Events als auch mit RPC nutzbar. | Framework / Library | Recherchierter Ansatz | Entscheidung für diese API | |---|---|---| -| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich durch angehängtes `await()` ausdrücken [geändert] | -| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Rückkanal vor dem Sendebefehl vorbereiten; das anschließende await löst keine zweite Sendung aus [geändert] | +| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich durch angehängtes `await()` ausdrücken | +| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Rückkanal vor dem Sendebefehl vorbereiten; das anschließende await löst keine zweite Sendung aus | | RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren | | NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen | | MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden | @@ -1044,7 +1075,7 @@ explizite Topic-/Typ-/Payload-Angaben, kleine Callbacks und optionales `request` bleibt eine ausdrückliche RPC-Komfortform. Direkte Laufzeitparameter und Optionsobjekte sind gleichwertige Zugänge zur selben Konfiguration. Dies ist die aktualisierte Designentscheidung für die Nutzeranforderungen, -kein behaupteter objektiver Leistungsvergleich der Frameworks. [geändert] +kein behaupteter objektiver Leistungsvergleich der Frameworks. ### § 14.2 Metadaten außerhalb des fachlichen Payloads diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php index 5f8c28d..16f6981 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -67,19 +67,36 @@ function demo(string $dsn, string $sharedSecret): void 'displayName' => 'Optionales neues Feld', ]); + // Run-Optionen (Sekunden sind Laufzeiten, keine Pollingintervalle): + // maxMessages: höchstens so viele abgeschlossene fachliche Zustellversuche + // in DIESEM run(), über alle registrierten Subscriptions zusammen. + // Fan-out an zwei Gruppen zählt zweimal; Redelivery zählt erneut. + // Auch behandelte Retry-/Reject-/Validierungsfehler zählen, nicht nur Erfolge. + // Interne Health-/RPC-Replies und leere Polls zählen nicht. + // maxSeconds: Gesamtbudget ab run()-Start, inklusive Warten und Verarbeitung. + // idleTimeoutSeconds: optional; beendet nach so langer zusammenhängender + // Wartezeit ohne fachliche Zustellung. Beginnt beim Eintritt ins Warten neu; + // Handlerlaufzeit zählt nicht als Leerlauf. Beispiel: run(idleTimeoutSeconds: 2). + // Das zuerst erreichte Limit beendet normal: keine Timeout-Exception. + // Laufende synchrone Handler werden nicht hart abgebrochen; maxSeconds kann + // deshalb überschritten werden. Nach Fristablauf startet kein weiterer Handler. + // minMessages gibt es nicht: keine garantierte Mindestzahl erzwingen. + // Ohne gesetztes Limit gilt für diese Grenze unbegrenzt; run() läuft bis stop() + // oder einem Infrastrukturfehler. Limits müssen positiv sein (kein 0/-1). + // Dieselben Felder sind alternativ in RunOptions verfügbar (siehe 05-rpc.php). // Zwei unabhängige Subscriptions verarbeiten je dieselbe Nachricht. - $mq->run(maxMessages: 2, maxSeconds: 10); + $mq->run(maxMessages: 2, maxSeconds: 10); // Höchstens 2 Versuche oder 10 s, ggf. weniger. // Typobjekt ohne Attribute: publish löst das programmatische Mapping auf. $user = new LocalUserCreated(); $user->userId = 'u-456'; $user->email = 'other@example.org'; $mq->publish($user); - $mq->run(maxMessages: 2, maxSeconds: 10); + $mq->run(maxMessages: 2, maxSeconds: 10); // Höchstens 2 Versuche oder 10 s, ggf. weniger. // Ohne Contract registrierter Typ: JSON-Daten, keine Schema-Hydration. $mq->publish('telemetry', 'heartbeat.v1', ['service' => 'billing']); - $mq->run(maxMessages: 1, maxSeconds: 10); + $mq->run(maxMessages: 1, maxSeconds: 10); // Höchstens 1 Versuch oder 10 s. // Aussagekräftiger lokaler Fehler, bevor die Nachricht versendet wird. try { diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index 06932ac..709d163 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -83,6 +83,8 @@ function demo(string $dsn, string $sharedSecret): void // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); send($mq); + // Höchstens 4 Zustellversuch(e) insgesamt oder 10 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $mq->run(maxMessages: 4, maxSeconds: 10); } finally { $mq->close(); @@ -105,6 +107,8 @@ function demoCallback(string $dsn, string $sharedSecret): void }); // users + sdk-users + user.created.v1; automatische Hydration. send($mq); // Empfangsschleife für registrierte Handler, kein verzögertes publish. + // Höchstens 2 Zustellversuch(e) 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(); @@ -150,6 +154,8 @@ function demoMultipleTopics(string $dsn, string $sharedSecret): void $mq->publish('audit.users', 'audit.entry.v1', $entry); $mq->publish('audit.billing', 'audit.entry.v1', $entry); // publish($entry) wäre MAPPING_INCOMPLETE: kein festes Topic auf diesem DTO. + // Höchstens 2 Zustellversuch(e) 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(); diff --git a/examples/api-draft/04-files-and-local.php b/examples/api-draft/04-files-and-local.php index 870a800..c906d73 100644 --- a/examples/api-draft/04-files-and-local.php +++ b/examples/api-draft/04-files-and-local.php @@ -50,6 +50,8 @@ function zipDemo(string $dsn, string $sharedSecret, string $storeDirectory, stri 'archive' => Attachment::fromPath($zipPath, contentType: 'application/zip'), ], )); + // Höchstens 1 Zustellversuch(e) insgesamt oder 30 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 30)); } finally { $mq->close(); @@ -71,6 +73,8 @@ function inMemoryDemo(): void printf("Memory: %s\n", $data['userId']); }, new SubscriptionOptions(durability: Durability::Volatile)); $sender->publish('users', 'user.created.v1', ['userId' => 'local-1']); + // Höchstens 1 Zustellversuch(e) insgesamt oder 1 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $receiver->run(new RunOptions(maxMessages: 1, maxSeconds: 1)); } finally { $sender->close(); @@ -105,6 +109,8 @@ function unixClientDemo(string $socketDsn): void printf("Unix: %s\n", $data['value']); }, new SubscriptionOptions(durability: Durability::Volatile)); $mq->publish('local', 'ping.v1', ['value' => 'hello']); + // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); } finally { $mq->close(); diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php index 19f0c0f..51261b5 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -100,6 +100,11 @@ function runServer(string $dsn, string $secret): void // Gleichwertige Alternative mit Attributen, NICHT zusätzlich registrieren: // $mq->registerHandlers(new DivideHandler()); + // maxMessages: maximal 100 Zustellversuche insgesamt, nicht je Subscription. + // maxSeconds: bis zu 60 s Gesamtbudget inklusive Leerlauf; erstes Limit gewinnt. + // Ein laufender synchroner Handler darf noch fertig werden, ggf. über die 60 s. + // Erreichen dieser Grenzen ist normale Rückkehr, keine Timeout-Exception. + // minMessages ist nicht vorgesehen; weniger als 100 Versuche sind zulässig. $mq->run(maxMessages: 100, maxSeconds: 60); // Alternativen: run(new RunOptions(maxMessages: 100, maxSeconds: 60)) // oder run(new RunOptions(maxSeconds: 60), maxMessages: 100). @@ -116,6 +121,11 @@ function runClient(string $dsn, string $secret): void // Publish erkennt das DTO und übernimmt Topic/Typ. Sendet sofort. $sent = $mq->publish(new Divide(12, 3)); // Andere lokale Arbeit wäre hier möglich; der Broker hat die Nachricht bereits. + // timeoutSeconds: maximal 5 s LOKALES Warten ab diesem await-Aufruf, + // begrenzt durch die verbleibende, schon beim Publish gesetzte Antwortfrist. + // Bereits vorhandene finale Antwort: sofortige Rückgabe ohne neue Sendung. + // Kein finales Result/Error bis dahin: RequestTimeoutException (catch unten), + // niemals null/false oder ein leeres scheinbar erfolgreiches Reply. $reply = $sent->await(timeoutSeconds: 5); printf("Ergebnis: %s\n", $reply->payload['quotient']); // 4 @@ -134,13 +144,17 @@ function runClient(string $dsn, string $secret): void )); $reply = $pending->await(new AwaitOptions(timeoutSeconds: 10), timeoutSeconds: 5, // Direkter Wert überschreibt hier die 10 Sekunden. + // onNotice: Zwischenmeldungen ausgeben; sie beenden await nicht und + // setzen weder die lokale Wartefrist noch die Remote-Deadline zurück. onNotice: static function (Notice $notice): void { printf("%s [%s]: %s\n", $notice->level, $notice->code, $notice->message); }, ); printf("Ergebnis: %s, Worker: %s\n", $reply->payload['quotient'], $reply->metadata['app.worker']); // reply->notices enthält die finale Zusammenfassung; nicht doppelt ausgeben. - // Optional await(responseClass: LocalResult::class), mit Schema-Bridge auf der Connection. + // responseClass: lokale DTO-Klasse für reply->payload; vor Rückgabe strukturell + // prüfen/hydrieren. Ohne Angabe Array; benötigt bei DTOs die Schema-Bridge. + // Beispiel: await(responseClass: LocalResult::class). // Reine Events können mit PublishOptions(reply: false) ohne Antwortaufwand senden. try { @@ -150,7 +164,13 @@ function runClient(string $dsn, string $secret): void // Erwartet: DIVIDE_BY_ZERO; keine entfernten PHP-Stacks/Objekte. } } catch (RequestTimeoutException $timeout) { + // await wirft RequestTimeoutException bei abgelaufener lokaler Wartefrist + // oder ursprünglicher Antwortdeadline ohne rechtzeitig empfangenes finales Ergebnis. + // Der Fehler enthält requestId. Er ist kein RemoteCommandException: + // ein bekannter fachlicher Fehler des Responders ist eine andere Ursache. // Ein Timeout stoppt das entfernte Command NICHT und sendet es nicht erneut. + // Falls die ursprüngliche Antwortfrist noch läuft, kann derselbe SendResult + // erneut await() aufrufen. Nicht publish() wiederholen: das wäre ein neuer Job. printf("Keine rechtzeitige Antwort für Request %s\n", $timeout->requestId); } finally { $mq->close(); diff --git a/examples/api-draft/06-metadata-middleware.php b/examples/api-draft/06-metadata-middleware.php index c6468c3..92660ae 100644 --- a/examples/api-draft/06-metadata-middleware.php +++ b/examples/api-draft/06-metadata-middleware.php @@ -100,6 +100,8 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ correlationId: 'request-42', metadata: ['app.locale' => 'de-DE'], )); + // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); // Eine Warning direkt als gewöhnliches Event versenden: keine neue API nötig. @@ -111,6 +113,8 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ correlationId: 'request-42', metadata: ['app.traceId' => $traceId], )); + // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. $diagnostics->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); // Für den Fehlerpfad oben: payload ['orderId' => 'order-43', 'mode' => 'reject']. diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php index d1d3fb9..8134ea8 100644 --- a/examples/api-draft/07-broadcast-locking.php +++ b/examples/api-draft/07-broadcast-locking.php @@ -159,6 +159,9 @@ static function (array $state, MessageContext $context) use ( new PublishOptions(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(new RunOptions(maxSeconds: $remaining)); } diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php index 2e3159b..b9573d6 100644 --- a/examples/api-draft/08-processing-workers.php +++ b/examples/api-draft/08-processing-workers.php @@ -73,10 +73,15 @@ function submitJobs(string $dsn, string $secret): void 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 RequestOptions(timeoutSeconds: 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. $reply = $call->await(); // Dispatcher ordnet auch frühere Antworten korrekt zu. printf("%s: %s (%d Bytes), SHA-256 %s\n", $reply->metadata['app.workerId'], From 2afbd991632c9e94dbf51f263d6b5ef33444e007 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 09:05:56 +0200 Subject: [PATCH 09/16] docs: explain queue guarantees and simplify API error examples --- .ai-usage-info.md | 11 +- README.md | 11 +- docs/message-queue-basics-101.md | 197 ++++++++++++++++ .../proposals/2026-09-12-message-queue-api.md | 211 ++++++++++++++++-- examples/api-draft/01-connect.php | 88 +++++++- examples/api-draft/02-programmatic.php | 24 +- examples/api-draft/03-attributes.php | 31 +-- examples/api-draft/04-files-and-local.php | 89 +------- examples/api-draft/05-rpc.php | 72 +++--- examples/api-draft/06-metadata-middleware.php | 23 +- examples/api-draft/07-broadcast-locking.php | 25 +-- examples/api-draft/08-processing-workers.php | 37 +-- examples/api-draft/09-system-check.php | 32 +-- examples/api-draft/10-callback-errors.php | 74 ++++++ 14 files changed, 656 insertions(+), 269 deletions(-) create mode 100644 docs/message-queue-basics-101.md create mode 100644 examples/api-draft/10-callback-errors.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 7289676..3296b3a 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -32,6 +32,15 @@ Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; entsprechenden Felder. Das vollständige Beispiel steht in [05-rpc.php](examples/api-draft/05-rpc.php). +Einfacher lokaler Einstieg im Entwurf: `new PhoreMQ('file:///tmp/phore-mq-demo')`. +Die Voraussetzungen dieses geplanten Entwicklungsadapters stehen zentral in +[01-connect.php](examples/api-draft/01-connect.php); Redis bleibt Produktionsstandard. +Callback-Fehler und begrenzte Retries zeigt +[10-callback-errors.php](examples/api-draft/10-callback-errors.php). + +[Message Queue Basics 101](docs/message-queue-basics-101.md) erklärt Begriffe, +Zustellung, Aufbewahrung und die Grenzen der geplanten Adapter. + ## Beispiele Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: @@ -39,7 +48,7 @@ Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht aus - [Verbindungen](examples/api-draft/01-connect.php) - [Programmatische Nutzung und eigene DTOs](examples/api-draft/02-programmatic.php) - [PHP-Attribute für SDK-Typen und Handler](examples/api-draft/03-attributes.php) -- [Dateien, In-Memory und Unix-Socket](examples/api-draft/04-files-and-local.php) +- [ZIP-Dateien per Speicherreferenz](examples/api-draft/04-files-and-local.php) - [RPC mit Rückgabewerten und Begleitmeldungen](examples/api-draft/05-rpc.php) - [Getrennte Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) - [Broadcast und Einsammeln aller Lock-Antworten](examples/api-draft/07-broadcast-locking.php) diff --git a/README.md b/README.md index 666693e..33b02ec 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. - [Verbinden: URL, Konnektor und Attribute](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 und lokale Entwicklung](examples/api-draft/04-files-and-local.php) +- [ZIP-Dateien per Speicherreferenz](examples/api-draft/04-files-and-local.php) - [RPC: Command, Ergebnis, Warnings und Fehler](examples/api-draft/05-rpc.php) - [Metadaten und Diagnose-Middleware](examples/api-draft/06-metadata-middleware.php) - [Broadcast und Antworten aller Lock-Teilnehmer](examples/api-draft/07-broadcast-locking.php) @@ -66,6 +66,15 @@ Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; entsprechenden Felder. Das vollständige Beispiel steht in [05-rpc.php](examples/api-draft/05-rpc.php). +Einfacher lokaler Einstieg im Entwurf: `new PhoreMQ('file:///tmp/phore-mq-demo')`. +Die Voraussetzungen dieses geplanten Entwicklungsadapters stehen zentral in +[01-connect.php](examples/api-draft/01-connect.php); Redis bleibt Produktionsstandard. +Callback-Fehler und begrenzte Retries zeigt +[10-callback-errors.php](examples/api-draft/10-callback-errors.php). + +[Message Queue Basics 101](docs/message-queue-basics-101.md) erklärt Begriffe, +Zustellung, Aufbewahrung und die Grenzen der geplanten Adapter. + ## Git Submodules Beim Klonen direkt mit auschecken: diff --git a/docs/message-queue-basics-101.md b/docs/message-queue-basics-101.md new file mode 100644 index 0000000..07d1d52 --- /dev/null +++ b/docs/message-queue-basics-101.md @@ -0,0 +1,197 @@ +# 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. Noch keiner der hier genannten Adapter ist 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, beispielsweise Redis oder 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 NATS-*Subject* ist die Routingadresse einer Nachricht. Ein JetStream-Stream kann Nachrichten mehrerer Subjects speichern; ein Consumer bestimmt, welche davon er liest. Bei RabbitMQ routet dagegen eine Exchange anhand von Bindings und gegebenenfalls Routing Keys in Queues. Diese Begriffe lassen sich nicht überall eins zu eins auf „Topic“ abbilden. Der Adapter übernimmt die Zuordnung. [NATS: Streams](https://docs.nats.io/learn/jetstream/your-first-stream), [RabbitMQ: Exchanges](https://www.rabbitmq.com/docs/exchanges) + +## 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 Regeln des jeweiligen Brokers. Gleichmäßiger Zufall ist nicht garantiert. Mehr Worker erlauben parallele Verarbeitung, verändern aber die Reihenfolge der Fertigstellung. Nach einem Verbindungs- oder Lease-Verlust 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), [Redis: Consumer Groups](https://redis.io/docs/latest/commands/xreadgroup/) + +## 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 den Regeln für Lease und Retry 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 | +| **Lease / Visibility / Ack-Frist** | Wie lange darf ein Worker die offene Zustellung bearbeiten, bevor sie erneut verfügbar werden kann? | +| **RPC-Wartefrist** | Wie lange wartet dieser Aufrufer auf eine Antwort? Ablauf stoppt die entfernte Arbeit nicht | + +## Welche Adapter vorgesehen sind + +Alle Statusangaben beziehen sich auf dieses Paket, nicht auf die Reife des jeweiligen Brokers. + +| Adapter / Status | Fan-out und konkurrierende Worker | Bestätigung und Wiederholung | Aufbewahrung und wichtigste Grenze | +|---|---|---|---| +| **Redis Streams — erster produktiver Adapter geplant** | Eigene Gruppe je Subscription; Worker teilen eine Gruppe | `XACK` entfernt den Eintrag aus der Pending-Liste der Gruppe; Claim übernimmt verwaiste Zustellungen | Stream bleibt bis Trimming/Löschung; Ack allein löscht den Streameintrag nicht. Persistenz und Eviction müssen passend konfiguriert sein | +| **Redis Pub/Sub — möglicher späterer Modus** | Aktive Subscriber erhalten Nachrichten | Keine dauerhafte Ack-/Recovery-Semantik | Keine Historie für offline gegangene Subscriber; kein Ersatz für Streams | +| **SQS — geplanter Arbeitsqueue-Adapter** | Worker teilen eine Queue; eine Queue allein liefert keinen unabhängigen Fan-out | Visibility Timeout verbirgt die Zustellung vorübergehend, `DeleteMessage` bestätigt die Verarbeitung | Standardmäßig 4 Tage, konfigurierbar bis 14 Tage; Standard Queues erlauben Duplikate und bieten keine strenge Reihenfolge | +| **SNS + SQS — geplanter Fan-out-Adapter** | SNS verteilt an eine SQS-Queue je Subscription; deren Worker teilen Arbeit | Annahme durch SNS und Zustellung nach SQS sind eigene Schritte; Retry-/DLQ-Konfiguration nötig | Nach Übergabe gilt die Retention der jeweiligen SQS-Queue; nicht als universelles Event-Archiv behandeln | +| **Azure Service Bus — geplant** | Queues für Arbeitsverteilung, Topics mit Subscriptions für Fan-out | Peek-Lock, `Complete`, `Abandon`, Lock-Erneuerung und Dead Letter | TTL und Zustellgrenzen konfigurieren; Receive-and-Delete passt nicht zum geplanten Auto-Ack-nach-Erfolg | +| **RabbitMQ — geplant** | Exchange/Bindings routen in Queues; Consumer einer Queue teilen Arbeit | Publisher Confirms und Consumer-Acks; Requeue/Dead Letter | Queue-/Message-TTL und Längenlimits konfigurieren; dauerhafte Queue, persistente Nachrichten und geeigneter Queue-Typ gehören zum Haltbarkeitskonzept | +| **NATS JetStream — später geplant** | Eigenständige Consumer für unabhängige Sichten; gemeinsam genutzter Pull-Consumer für Worker | Stream-Publish-Ack und explizites Consumer-Ack, Redelivery nach Ack-Frist | Limits-, Interest- und WorkQueue-Retention unterscheiden sich. WorkQueue passt nicht zu beliebig überlappenden unabhängigen Consumern | +| **In-Memory — für Tests geplant** | Lokale Gruppen am selben ausdrücklich geteilten Brokerobjekt | Vom Testadapter nachgebildet | Prozessende verliert Zustand; keine Kommunikation zwischen unabhängigen Prozessen | +| **file — lokaler Entwicklungsadapter geplant** | Prozesse desselben OS-Benutzers teilen einen SQLite-Queue-Root | Transaktionale Claims, Leases und Fehlerablage sind Teil des Entwurfs | Lokale Dateien überleben Neustarts; Retention/Bereinigung nötig. Kein NFS-/Multi-Host-Adapter | +| **Unix-Dev-Broker — Anschlussphase geplant** | Separater lokaler Prozess verwaltet Gruppen | Eigenes lokales MQ-Protokoll erforderlich | Im Entwurf volatil; Broker-Neustart verliert Zustand. Ein Unix-Socket allein ist keine Queue | + +Belege zu den Brokerzeilen: [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/), [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/), [SQS Retention](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-queue-parameters.html), [SQS Standard Queues](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html), [SNS an SQS](https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html), [Azure Settlement](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-transfers-locks-settlement), [RabbitMQ Confirms](https://www.rabbitmq.com/docs/confirms), [JetStream Retention](https://docs.nats.io/learn/jetstream/retention-policies). Die drei lokalen Adapterzeilen sind eigene Designentscheidungen; Details im [API-Proposal](proposals/2026-09-12-message-queue-api.md). + +FIFO-Funktionen, etwa bei SQS, sowie Sessions oder geordnete Consumer können bestimmte Reihenfolgen absichern. Sie sind keine universelle Zusage dieser Abstraktion und müssen später als konkrete Adapter-Capability beschrieben werden. Core NATS ist außerdem von JetStream zu unterscheiden: Sein gewöhnlicher Pub/Sub-Betrieb ist kein persistenter JetStream-Consumer. [SQS FIFO](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-fifo-queues.html), [NATS: JetStream](https://docs.nats.io/learn/jetstream) + +## Aufbewahrung passend zur Anwendung wählen + +Es gibt daher keine gemeinsame Antwort „alle Nachrichten bleiben sieben Tage“. Bei Redis kann Trimming die Historie kürzen, bei SQS gilt die Queue-Retention, bei JetStream zusätzlich die gewählte Retention-Policy. Azure kann abgelaufene Nachrichten je Einstellung entfernen oder dead-lettern. Auch Dead-Letter-Speicher brauchen eine eigene Aufbewahrungs- und Bereinigungsregel. [Redis XTRIM](https://redis.io/docs/latest/commands/xtrim/), [Azure Ablaufzeiten](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-expiration) + +Die Aufbewahrung muss zu erwarteten Ausfällen und Wiederholungen passen. Bei großen ZIP-Dateien gilt das zusätzlich für die ausgelagerte Datei: Eine erhaltene Nachricht mit bereits gelöschtem Attachment ist nicht mehr vollständig verarbeitbar. + +## Was der Anwendungsentwickler konfiguriert + +Für die **geplanten lokalen Beispiele** reicht: + +```php +$mq = new PhoreMQ('file:///tmp/phore-mq-demo'); +``` + +Das lokale Profil bündelt Queue, Fehlerablage, Attachment-Store, einen persistenten lokalen Signaturschlüssel und eindeutige RPC-Rückkanäle. Voraussetzungen und die explizite Redis-/Factory-Konfiguration stehen einmalig in [01-connect.php](../examples/api-draft/01-connect.php). Es ist kein Produktions-Sicherheitsprofil für mehrere Dienste oder Benutzer. + +Danach entscheidet die Anwendung vor allem über drei Dinge: **Routing** (wer braucht welche Nachrichten?), **Lebensdauer** (wie lange darf Arbeit offen bleiben?) und **Fehlerbehandlung** (wann erneut versuchen, wann endgültig ablegen?). Topics, Bindings und Berechtigungen werden bei Netzwerkbrokern in Produktion vorab provisioniert; die Library legt sie nur mit ausdrücklich erlaubtem `autoCreate` an. + +Ein Prozess kann mehrere Handler registrieren. `run(maxMessages: 100, maxSeconds: 30)` verarbeitet ihre Zustellungen, bis das erste Limit erreicht ist. Es bedeutet weder 100 parallele Jobs noch garantiert 100 erfolgreiche Jobs. Synchrone Handler laufen in einem Worker nacheinander; für Parallelität startet die Anwendung mehrere Worker derselben Subscription. Lange Jobs brauchen passende Leases oder deren Verlängerung. Details und Rückkehrbedingungen stehen in [02-programmatic.php](../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 Standardeinstellung des lokalen file-Profils erzeugt deshalb +eigene Reply-Endpunkte je MQ-Instanz. Bei Netzwerkbrokern werden diese und ihre +Berechtigungen konfiguriert. 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. Fehlende Adapterfähigkeiten sollen früh als `UnsupportedCapabilityException` 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. diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index ff06532..3da279c 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -10,6 +10,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -212,12 +213,15 @@ DSN-/Connector-Felder im Optionsobjekt, keine später notwendigen Setter und kein zusätzliches `connect()` auf dem MQ-Objekt. `null` bedeutet ein frisches Optionsobjekt mit denselben dokumentierten -Defaults für alle Erzeugungswege, kein implizites Lesen von Environment oder -Secrets. Erforderliche Security-/Provider-Konfiguration muss weiterhin -explizit vorliegen; fehlende Konfiguration wird nicht durch unsichere Defaults +Defaults für alle Erzeugungswege. Environment und externe Secret-Stores werden +nicht implizit gelesen; der file-Adapter verwaltet ausschließlich seinen +dokumentierten lokalen Schlüssel im gewählten Root. Erforderliche Security-/ +Provider-Konfiguration muss für Netzwerkadapter weiterhin +explizit vorliegen; das lokale file-Profil aus § 4.1 liefert dokumentierte +Entwicklungsvorgaben; fehlende Konfiguration wird nicht durch unsichere Defaults ersetzt. Konfiguration wird beim Erzeugen validiert und als Snapshot verwendet; spätere Mutation des Optionsobjekts ändert das laufende MQ nicht. Explizit -zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. +zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. [geändert] Konstruktor und Factory bauen die Verbindung sofort mit begrenztem Verbindungstimeout auf. Erfolgreiche Rückkehr liefert ein verwendbares @@ -267,6 +271,7 @@ Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. | `rediss://user:password@host:6380/0` | Redis über TLS mit Zertifikatsprüfung | | `redis://:password@host:6379/0` | Redis-Passwort ohne ACL-Benutzer | | `redis+unix:///run/redis/redis.sock?db=0` | Redis-Server über Unix-Socket, weiterhin Redis-Protokoll | +| `file:///tmp/phore-mq-demo` | Geplanter lokaler Entwicklungsadapter mit SQLite-Datei, Reply-/Failure-/Attachment-Store; Details § 4.1 [neu] | | `memory://` | Isolierter In-Memory-Broker je MQ-Erzeugung | | `unix:///run/user/1000/phore-mq.sock` | Eigenes lokales MQ-Protokoll, benötigt separaten Dev-Broker | | `sqs://eu-central-1/123456789012` | Geplanter Queue-Adapter; logische Topics per Routingtabelle auf Queue-URLs abbilden | @@ -287,6 +292,54 @@ Attribute enthalten höchstens lokale Beispiel-DSNs oder Verbindungsnamen, keine produktiven Secrets. Für produktive Deployment-Konfiguration ist die programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. +### § 4.1 Kurzer lokaler Einstieg in den Beispielen + +Die Beispiele 02–10 verwenden `new PhoreMQ('file:///tmp/phore-mq-demo')`. +`file` ist ein hier neu vorgeschlagener lokaler Entwicklungsadapter, noch +keine vorhandene Funktion. Er speichert die Queue transaktional in SQLite +unter dem angegebenen Root; `ext-pdo_sqlite` ist erforderlich. Alle Prozesse +eines Demos teilen denselben Root. Unabhängige Demos verwenden frische Roots, +da Subscriptions, Backlog und Fehler Neustarts überleben. Redis Streams bleibt +der erste produktive Adapter; kein NFS-, Netzwerk- oder Multi-Host-Betrieb mit +file und keine Gleichsetzung seiner Last-/Timing-Eigenschaften mit Redis. [neu] + +Dieses explizit gewählte lokale Profil provisioniert Queue/Subscriptions, +FailureStore, Attachment-Store und pro MQ-Instanz einen zufällig eindeutigen +RPC-Rückkanal im eigenen Root. Antworten sind nur an intern registrierte +logische Reply-Ziele dieses Roots zulässig; niemals an beliebige Dateipfade. +Es aktiviert die Schema-Bridge bei installiertem `phore/schema`; wenn eine +benötigte Bridge fehlt, bleibt es bei `MissingDependencyException`. Der +Konstruktor installiert keine Pakete und liest keine Environment-Variablen. +Beispiel 02 ergänzt nur sein Klassenmapping, 06 seine Middleware und 09 seine +Health-Definitionen. Solche fachlich relevanten Optionen bleiben sichtbar. +Explizite Optionswerte überschreiben Profilvorgaben; ausgelassene Felder +behalten die lokalen Vorgaben, auch in partiellen RPC-/Health-Optionsobjekten. [neu] + +Das Root wird nur als privates Verzeichnis desselben OS-Benutzers verwendet +(Verzeichnis 0700, Dateien 0600); fremde Besitzer, unsichere Rechte und +Symlink-Pfade werden abgelehnt statt still übernommen. Ein kryptografisch +zufälliger HMAC-Key wird bei Erstinitialisierung atomar exklusiv angelegt und +persistent gemeinsam verwendet, nicht pro Prozess ersetzt. Alle Frames +verwenden die bestehende Signaturprüfung. Das ist ausschließlich Vertrauen +zwischen lokalen Prozessen desselben Benutzers, keine Dienst-/Mandanten- +Identität. Diese file-spezifische Erzeugung ersetzt keine Secret-Konfiguration +bei Redis oder anderen Netzwerkadaptern. [neu] + +Claims, Versuchszähler, verzögerte Freigabe und Settlement müssen per SQLite- +Transaktion konsistent sein; Handler laufen außerhalb der DB-Transaktion. +Lease-Tokens verhindern Settlement durch veraltete Worker, nach Prozessabbruch +können Leases wieder aufgenommen werden. At least once, Idempotenz, begrenztes +Polling und kurze DB-Lock-Timeouts bleiben notwendig. Fehlerablage erfolgt +transaktional vor/mit Ack, Attachments bleiben separate Dateien mit geprüften +Referenzen. Retention und Bereinigung gelten auch für verwaiste Rückkanäle. +Ohne diese Eigenschaften darf der Adapter keine Durable-Capability melden. [neu] + +Nur Beispiel 01 zeigt vollständige Connection-Optionen und die In-Memory-/ +Unix-Alternativen. Die übrigen Dateien erklären ihr jeweiliges Thema mit dem +kurzen Einstieg; ihre APIs bleiben Entwürfe. Spätere Contract-Tests prüfen +insbesondere zwei Prozesse, Crash/Lease-Recovery, Retry-Zähler, atomare +Fehlerablage, private Pfade/Key-Erzeugungsrennen und eindeutige Rückkanäle. [neu] + ## § 5 Senden, empfangen und Worker-Lebenszyklus [Programmatische Beispiele: 02-programmatic.php](../../examples/api-draft/02-programmatic.php). @@ -389,7 +442,7 @@ Ein aufgrund eines Typfilters übersprungener fachlicher 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 nach der bestehenden Lease-/Freigabepolicy behandelt. [neu] +bzw. werden nach der bestehenden Lease-/Freigabepolicy behandelt. `maxSeconds` ist das Gesamtbudget ab Loop-Start, einschließlich Warten und Verarbeitung. `idleTimeoutSeconds` begrenzt eine zusammenhängende Wartephase @@ -399,14 +452,14 @@ 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. [neu] +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 ausführlich in Beispiel 02 und am RPC-Loop in 05. [neu] +Optionskommentare stehen ausführlich in Beispiel 02 und am RPC-Loop in 05. Standardmäßig folgt Ack erst nach erfolgreicher Callback-Rückkehr. Ein temporärer Handlerfehler löst eine begrenzte Retry-Policy aus; endgültige @@ -614,6 +667,7 @@ Primärquellen, keine Aussage über bereits implementierte Adapter. | RabbitMQ | Exchanges/Bindings, Queues, Consumer-Ack und Publisher Confirms | Topic auf Exchange, Subscription auf Queue; AMQP-Protokollversion ausdrücklich festlegen | | NATS JetStream | Persistente Streams, langlebige Consumer, Ack und Redelivery | Späterer Adapter, Core NATS nicht mit JetStream gleichsetzen | | In-Memory | Prozessinterne kontrollierte Zustellung | Frühes Testwerkzeug, kein Ersatz für Brokerintegrationstests | +| file (geplanter lokaler SQLite-Adapter) | Gemeinsamer Root für lokale Prozesse, Claims/Leases und Fehlerablage | Kurzer Entwicklungs-Einstieg gemäß § 4.1; kein Multi-Host-/NFS-Backend [neu] | | Unix-Socket | Lokaler Byte-Transport | Benötigt Dev-Broker für Routing, Gruppen und Receipts; keine Queue allein durch Socket/Semaphore | Redis benötigt getrennte Empfangs-/Publish-Verbindungen, begrenztes Blocking @@ -648,9 +702,11 @@ benötigt weder Symfony-Servicecontainer noch automatische Handler-Suche. 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; kein generiertes, -fest eingebautes oder stillschweigend fehlendes Secret. Ein Empfänger mit -HMAC-Policy weist unsignierte Nachrichten immer zurück. +HMAC oder bewusstes `UnsignedSecurity` für isolierte Tests; die persistente +lokale Key-Erzeugung des file-Profils ist in § 4.1 geregelt. 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. [geändert] Das signierte Envelope enthält Protokollversion, `messageId`, fachlichen Typ, Topic, Audience, UTC-`issuedAt`, optional `expiresAt`, Content-Type, Payload, @@ -728,6 +784,10 @@ Reassembly sind spätere Erweiterungen und kein impliziter Bestandteil von ## § 10 Lokale Entwicklung: Memory, Redis-Socket und Dev-Broker +Die konkreten Verbindungsbeispiele stehen zentral in +[01-connect.php](../../examples/api-draft/01-connect.php); der neue file-Einstieg +ist in § 4.1 beschrieben. [geändert] + `memory://` durchläuft denselben Codec, dieselbe Signierung und dieselbe Schema-Bridge. Es kopiert serialisierte Nachrichten, keine veränderbaren Objektreferenzen. Zwei unabhängig erstellte Memory-Verbindungen teilen keinen @@ -794,6 +854,69 @@ 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 nach folgender Policy. [neu] + +| Callback-Ergebnis | Standard im Entwurf | +|---|---| +| Normale Rückkehr | Ack nach erfolgreicher Verarbeitung [neu] | +| `RetryableMessageException` | Begrenzter Retry mit Verzögerung; kein unendliches Erzwingen [neu] | +| Andere unbehandelte Exception, einschließlich `TypeError` | Ebenfalls begrenzt wiederholen; nach Ausschöpfen sichere Fehlerablage und Alarm [neu] | +| `RejectMessageException` | Sofort endgültig in die Fehlerablage, kein Retry [neu] | +| `CommandFailedException` oder freigegebene `RemoteException` (§ 13.5) in `respond` | Bewusster fachlicher RPC-Fehler: sichere finale Antwort, danach Ack; keine technische Wiederholung [neu] | +| Infrastrukturfehler bei Retry/Ack/FailureStore | Kein vorgetäuschter Erfolg; `run` wirft Infrastruktur-Exception, unbestätigte Nachricht bleibt wiederholbar [neu] | + +Vorgeschlagene Default-Policy: höchstens vier Versuche insgesamt, also drei +Wiederholungen, mit 1, 2 und 4 Sekunden Verzögerung plus zufälligem Jitter von +±10 %. `context->attempt` beginnt bei 1 und wird dauerhaft je Zustellung an +eine Subscription geführt; Prozessneustart oder ein anderer Worker setzt den +Zähler nicht zurück. Die Policy bleibt über `SubscriptionOptions::retryPolicy` +austauschbar. 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. [neu] + +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 lokale file-Adapter hat die Fehlerablage im Profil, +andere Adapter müssen eine verfügbare Ablage konfigurieren. 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. [neu] + +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. [neu] + +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 ablaufende +Lease 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. [neu] + +Orientierung: [Symfony Messenger – Retries & Failures](https://symfony.com/doc/current/messenger.html#retries-failures) +trennt verzögerte Wiederholungen, endgültige Fehler und Failure-Transports; +[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. [neu] + ## § 12 Paketgrenzen, spätere Prüfungen und Quellen In die Library gehören Transportvertrag, Registry, Worker-Lebenszyklus, @@ -860,7 +983,8 @@ damit sehr schnelle Antworten nicht verloren gehen. `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. `SendResult::await(?AwaitOptions $options = null, ?float $timeoutSeconds = null, -?string $responseClass = null, ?callable $onNotice = null): Reply` verarbeitet +?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 @@ -870,7 +994,7 @@ und einen unabhängig laufenden Responder verwenden. `RequestOptions::responseClass`/`onNotice` liefern lediglich die Anfangswerte für dieselben Await-Einstellungen. `PendingReply` entfällt als separater Rückgabetyp im Entwurf; bestehende `request(...)->await()`-Beispiele bleiben -gültig. +gültig. [geändert] `Reply` besitzt schreibgeschützte `payload`, `metadata` und `notices`. `responseClass` hydriert `payload` strukturell nach § 6, ohne die PHP-Klasse @@ -885,7 +1009,7 @@ Ohne konfigurierte RPC-Schicht sind `request` und `respond` frühe |---|---|---| | 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` | `requestId`, `kind=error`, eigene `messageId`, 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, @@ -1000,7 +1124,7 @@ 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)` +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 @@ -1008,7 +1132,7 @@ 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. +ist zu diesem Zeitpunkt ausdrücklich bereits gesendet. [geändert] Ein lokaler Timeout oder Ablauf der ursprünglichen Antwortdeadline ohne rechtzeitiges finales Ergebnis wirft `RequestTimeoutException` mit `requestId`, @@ -1018,7 +1142,7 @@ 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` und Notice-Callback für diesen Handle; +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 @@ -1049,6 +1173,61 @@ 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`. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + ## § 14 Metadaten, Middleware und API-Entscheidung [Beispiel 06](../../examples/api-draft/06-metadata-middleware.php) zeigt diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php index e592fcf..f3a7b39 100644 --- a/examples/api-draft/01-connect.php +++ b/examples/api-draft/01-connect.php @@ -4,6 +4,14 @@ namespace Examples\MessageQueue\Connections; +use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\Security\UnsignedSecurity; +use Phore\MessageQueue\RunOptions; +use Phore\MessageQueue\MessageContext; +use Phore\MessageQueue\Durability; +use Phore\MessageQueue\Development\UnixDevBroker; +use Phore\MessageQueue\Connector\InMemory\InMemoryConnector; +use Phore\MessageQueue\Connector\InMemory\InMemoryBroker; use Phore\MessageQueue\Attribute\QueueConnection; use Phore\MessageQueue\ConnectionFactory; use Phore\MessageQueue\ConnectionOptions; @@ -95,8 +103,86 @@ function connectWithSecurityProvider(string $dsn, MessageSecurityInterface $secu // Eine dieser Varianten EINMAL im Bootstrap aufrufen und das erhaltene Objekt // für publish/subscribe/request/respond/check/run weiterreichen (z. B. per DI). -// Die Beispiele 02–09 mit Factory bleiben gültig: Sie erhalten dasselbe PhoreMQ. +// Die Beispiele 02–10 verwenden den einfachen Konstruktor; die Factory liefert denselben Typ. // Gegen MessageQueueInterface typisierte Anwendungskomponenten bleiben möglich. // Kein Singleton; der Connector gehört exklusiv zu dieser MQ-Instanz. // Konstruktor/Factory verbinden sofort; Fehler werfen die Exceptions aus § 11. // Jede verwendete MQ-Instanz anschließend in finally mit $mq->close() freigeben. + +// 6. Einfacher lokaler Einstieg für Beispiele 02–10 (geplanter file-Adapter). +// Keine Netzwerkdienste oder zusätzlichen ConnectionOptions nötig. +// SQLite-basierte Queue für Prozesse desselben lokalen Benutzers; ext-pdo_sqlite nötig. +// Das Verzeichnis enthält Queue, Fehlerablage, Attachments und einen persistenten +// lokalen HMAC-Key. Private Rechte werden geprüft; unsichere bestehende Pfade abgelehnt. +// Unique Reply-Endpunkte pro Instanz, Auto-Provisionierung nur innerhalb dieses Roots. +// Schema-Bridge wird genutzt, wenn phore/schema installiert ist; DTO-Nutzung ohne +// verfügbare Bridge scheitert weiterhin ausdrücklich. Keine Installation im Konstruktor. +// Alle zusammengehörigen Prozesse verwenden denselben Root; unabhängige Demos einen +// frischen Root wählen. Kein NFS-/Multi-Host-/Produktionsbroker; Redis bleibt Standard. +function connectLocal(): PhoreMQ +{ + return new PhoreMQ('file:///tmp/phore-mq-demo'); +} + +// Die folgenden detaillierten lokalen Verbindungsvarianten sind hier zentralisiert. +// In einem Test teilen zwei Connections denselben prozessinternen Broker. +// Auch dieser Konnektor nutzt Codec/Envelope statt PHP-Objektreferenzen. +function inMemoryDemo(): void +{ + $broker = new InMemoryBroker(); + $factory = new ConnectionFactory(); + $options = new ConnectionOptions(security: new UnsignedSecurity(), autoCreate: true); + $sender = $factory->fromConnector(new InMemoryConnector($broker), $options); + $receiver = $factory->fromConnector(new InMemoryConnector($broker), $options); + + try { + $receiver->subscribe('users', 'local-users', function (array $data): void { + printf("Memory: %s\n", $data['userId']); + }, new SubscriptionOptions(durability: Durability::Volatile)); + $sender->publish('users', 'user.created.v1', ['userId' => 'local-1']); + // Höchstens 1 Zustellversuch(e) insgesamt oder 1 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $receiver->run(new RunOptions(maxMessages: 1, maxSeconds: 1)); + } finally { + $sender->close(); + $receiver->close(); + } +} + +// Eigener Prozess A: Dev-Broker starten. Elternverzeichnis muss privat sein. +// Volatil: Neustart des Brokers verliert gespeicherte Nachrichten/Subscriptions. +function runUnixBroker(string $socketPath): void +{ + $broker = new UnixDevBroker(socketPath: $socketPath, socketMode: 0600); + try { + $broker->run(); + } finally { + $broker->close(); + } +} + +// Prozess B (Receiver) und C (Sender) können dieselbe lokale DSN verwenden. +// Der Receiver muss seine Subscription anlegen, bevor der Sender publiziert. +function unixClientDemo(string $socketDsn): void +{ + // Beispiel: unix:///run/user/1000/phore-mq.sock + // Bewusst unsigniert nur für isolierte lokale Entwicklung. + $mq = (new ConnectionFactory())->connect($socketDsn, new ConnectionOptions( + security: new UnsignedSecurity(), + autoCreate: true, + )); + try { + $mq->subscribe('local', 'local-worker', function (array $data): void { + printf("Unix: %s\n", $data['value']); + }, new SubscriptionOptions(durability: Durability::Volatile)); + $mq->publish('local', 'ping.v1', ['value' => 'hello']); + // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. + $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); + } finally { + $mq->close(); + } +} + +// Alternative mit echter Redis-Semantik, ohne eigenen Dev-Broker: +// $factory->connect('redis+unix:///run/redis/redis.sock?db=0', $options); diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php index 16f6981..df44493 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -4,20 +4,18 @@ namespace Examples\MessageQueue\Programmatic; -use Phore\MessageQueue\ConnectionFactory; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\MessageValidationException; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageRegistry; -use Phore\MessageQueue\Schema\PhoreSchemaMapper; -use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 5–6 und 11. - * Beispielaufruf nach Implementierung: demo($dsn, $sharedSecret). - * Dafür einen frischen Redis-Prefix verwenden: zwei neue Subscriptions werden - * vor dem Publish gebunden. Bei wiederverwendetem Prefix kann Backlog anliegen. + * Beispielaufruf nach Implementierung: demo(). + * Dafür ein frisches lokales Queue-Verzeichnis verwenden: zwei neue Subscriptions werden + * vor dem Publish gebunden. Bei wiederverwendetem Verzeichnis kann Backlog anliegen. */ // Beliebige eigene Klasse: kein gemeinsames SDK und keine Attribute notwendig. @@ -27,21 +25,13 @@ final class LocalUserCreated public string $email; } -function demo(string $dsn, string $sharedSecret): void +function demo(): void { $registry = new MessageRegistry(); $registry->register('user.created.v1', LocalUserCreated::class, topic: 'users'); - $mq = (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $sharedSecret, - keyId: 'development-1', - audience: 'user-services-development', - ), - registry: $registry, - schemaMapper: new PhoreSchemaMapper(), - autoCreate: true, - )); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(registry: $registry)); + // Nur das hier gezeigte Klassenmapping ergänzen; Verbindung siehe 01-connect.php. try { // Array: für diesen Typ validiert der registrierte Contract die Struktur. diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index 709d163..53d7375 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -10,11 +10,8 @@ use Phore\MessageQueue\SubscriptionOptions; use Phore\MessageQueue\Exception\MessageMappingException; use Phore\MessageQueue\Exception\InvalidHandlerException; -use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageQueueInterface; -use Phore\MessageQueue\Schema\PhoreSchemaMapper; -use Phore\MessageQueue\Security\HmacSecurity; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 4–6. @@ -48,19 +45,6 @@ public function onRawCreated(array $data): void } } -function createConnection(string $dsn, string $sharedSecret): MessageQueueInterface -{ - return new PhoreMQ($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $sharedSecret, - keyId: 'development-1', - audience: 'user-services-development', - ), - schemaMapper: new PhoreSchemaMapper(), - autoCreate: true, - )); -} - function send(MessageQueueInterface $mq): void { $user = new T_UserCreated(); @@ -76,9 +60,9 @@ function send(MessageQueueInterface $mq): void ]); } -function demo(string $dsn, string $sharedSecret): void +function demo(): void { - $mq = createConnection($dsn, $sharedSecret); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); @@ -93,14 +77,13 @@ function demo(string $dsn, string $sharedSecret): void // Getrennte Prozesse: Empfänger legt/bindet Subscriptions vor dem ersten Senden // an und ruft run() auf. Sender ruft danach send() auf seiner eigenen Connection -// auf. Beide verwenden denselben Broker-Prefix, HMAC-Key und dieselbe Audience. - +// auf. Beide verwenden dasselbe lokale Queue-Verzeichnis (Vorgaben in 01-connect.php). // Alternative zur Attributregistrierung: nur den Callback übergeben. // Auf einer eigenen MQ-Instanz statt demo()/registerHandlers() ausführen. -function demoCallback(string $dsn, string $sharedSecret): void +function demoCallback(): void { - $mq = createConnection($dsn, $sharedSecret); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { $mq->subscribe(function (T_UserCreated $user, MessageContext $context): void { printf("Callback: %s / %s\n", $context->messageId, $user->email); @@ -135,9 +118,9 @@ public function onEntry(T_AuditEntry $entry): void } } -function demoMultipleTopics(string $dsn, string $sharedSecret): void +function demoMultipleTopics(): void { - $mq = createConnection($dsn, $sharedSecret); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { $handler = static function (T_AuditEntry $entry): void { printf("Audit: %s\n", $entry->text); diff --git a/examples/api-draft/04-files-and-local.php b/examples/api-draft/04-files-and-local.php index c906d73..5f5f347 100644 --- a/examples/api-draft/04-files-and-local.php +++ b/examples/api-draft/04-files-and-local.php @@ -4,38 +4,23 @@ namespace Examples\MessageQueue\FilesAndLocal; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Attachment; -use Phore\MessageQueue\ConnectionFactory; -use Phore\MessageQueue\ConnectionOptions; -use Phore\MessageQueue\Connector\InMemory\InMemoryBroker; -use Phore\MessageQueue\Connector\InMemory\InMemoryConnector; -use Phore\MessageQueue\Development\UnixDevBroker; -use Phore\MessageQueue\Durability; use Phore\MessageQueue\MessageContext; -use Phore\MessageQueue\Payload\LocalPayloadStore; use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\RunOptions; -use Phore\MessageQueue\Security\HmacSecurity; -use Phore\MessageQueue\Security\UnsignedSecurity; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 8–10. - * Dateispeicher und UnixDevBroker sind ausdrücklich Anschlussphase. - * Pfade und Secrets werden durch den Aufrufer festgelegt, keine Environment-Reads. + * ZIP-Transport per Dateireferenz; In-Memory/Unix-Verbindungen stehen in 01-connect.php. + * Eingabe-/Ausgabepfad kommen vom Aufrufer, keine Environment-Reads. */ -function zipDemo(string $dsn, string $sharedSecret, string $storeDirectory, string $zipPath, string $outputPath): void +function zipDemo(string $zipPath, string $outputPath): void { - $mq = (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $sharedSecret, - keyId: 'development-1', - audience: 'export-services-development', - ), - payloadStore: new LocalPayloadStore(directory: $storeDirectory), - autoCreate: true, - )); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + // Lokaler Attachment-Store gehört zum file-Profil; Konfiguration siehe 01-connect.php. try { $mq->subscribe('exports', 'archive-importer', function (array $data, MessageContext $context) use ($outputPath): void { @@ -57,65 +42,3 @@ function zipDemo(string $dsn, string $sharedSecret, string $storeDirectory, stri $mq->close(); } } - -// In einem Test teilen zwei Connections denselben prozessinternen Broker. -// Auch dieser Konnektor nutzt Codec/Envelope statt PHP-Objektreferenzen. -function inMemoryDemo(): void -{ - $broker = new InMemoryBroker(); - $factory = new ConnectionFactory(); - $options = new ConnectionOptions(security: new UnsignedSecurity(), autoCreate: true); - $sender = $factory->fromConnector(new InMemoryConnector($broker), $options); - $receiver = $factory->fromConnector(new InMemoryConnector($broker), $options); - - try { - $receiver->subscribe('users', 'local-users', function (array $data): void { - printf("Memory: %s\n", $data['userId']); - }, new SubscriptionOptions(durability: Durability::Volatile)); - $sender->publish('users', 'user.created.v1', ['userId' => 'local-1']); - // Höchstens 1 Zustellversuch(e) insgesamt oder 1 s Gesamtbudget; erstes Limit gewinnt. - // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. - $receiver->run(new RunOptions(maxMessages: 1, maxSeconds: 1)); - } finally { - $sender->close(); - $receiver->close(); - } -} - -// Eigener Prozess A: Dev-Broker starten. Elternverzeichnis muss privat sein. -// Volatil: Neustart des Brokers verliert gespeicherte Nachrichten/Subscriptions. -function runUnixBroker(string $socketPath): void -{ - $broker = new UnixDevBroker(socketPath: $socketPath, socketMode: 0600); - try { - $broker->run(); - } finally { - $broker->close(); - } -} - -// Prozess B (Receiver) und C (Sender) können dieselbe lokale DSN verwenden. -// Der Receiver muss seine Subscription anlegen, bevor der Sender publiziert. -function unixClientDemo(string $socketDsn): void -{ - // Beispiel: unix:///run/user/1000/phore-mq.sock - // Bewusst unsigniert nur für isolierte lokale Entwicklung. - $mq = (new ConnectionFactory())->connect($socketDsn, new ConnectionOptions( - security: new UnsignedSecurity(), - autoCreate: true, - )); - try { - $mq->subscribe('local', 'local-worker', function (array $data): void { - printf("Unix: %s\n", $data['value']); - }, new SubscriptionOptions(durability: Durability::Volatile)); - $mq->publish('local', 'ping.v1', ['value' => 'hello']); - // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. - // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. - $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); - } finally { - $mq->close(); - } -} - -// Alternative mit echter Redis-Semantik, ohne eigenen Dev-Broker: -// $factory->connect('redis+unix:///run/redis/redis.sock?db=0', $options); diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php index 51261b5..1c6a7c6 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -4,54 +4,40 @@ namespace Examples\MessageQueue\Rpc; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Attribute\Respond; +use Phore\MessageQueue\Attribute\RemoteError; +use Phore\MessageQueue\Rpc\RemoteException; use Phore\MessageQueue\Attribute\MessageType; use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\Rpc\AwaitOptions; -use Phore\MessageQueue\ConnectionFactory; -use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\MessageQueueInterface; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\Notice; use Phore\MessageQueue\Rpc\RemoteCommandException; use Phore\MessageQueue\Rpc\RequestContext; use Phore\MessageQueue\Rpc\RequestTimeoutException; -use Phore\MessageQueue\Rpc\RpcConnectionOptions; -use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 13–14. - * Nach Implementierung: zuerst runServer($dsn, $secret) in Prozess A starten, - * dann runClient($dsn, $secret) in Prozess B. Beide nutzen denselben Redis-Prefix. - * replyTopic/replySubscription müssen je gleichzeitig aktiver Client-Instanz - * eindeutig sein. Die festen Namen unten gelten für genau einen Demo-Client. + * Nach Implementierung: zuerst runServer() in Prozess A starten, + * dann runClient() in Prozess B. Beide nutzen dasselbe lokale Queue-Verzeichnis. + * Der file-Adapter vergibt pro Client einen eigenen Rückkanal; Details in 01-connect.php. */ -function connect(string $dsn, string $secret, bool $client): MessageQueueInterface -{ - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $secret, - keyId: 'development-1', - audience: 'rpc-development', - ), - rpc: new RpcConnectionOptions( - replyTopic: $client ? 'rpc.replies.client-demo' : null, - replySubscription: $client ? 'client-demo' : null, - allowedReplyTopics: ['rpc.replies.client-demo'], - ), - autoCreate: true, // Produktion: Ressourcen und ACLs vorher provisionieren. - )); -} - #[MessageType('math.divide.v1', topic: 'calculator')] final class Divide { public function __construct(public float $a, public float $b) {} } +// Dieser explizite Fehlertyp darf seine Meldung über den RPC-Rückkanal senden. +// In einem gemeinsamen SDK können Server und Client dieselbe Klasse verwenden. +#[RemoteError('math.division_by_zero.v1')] +final class DivisionByZero extends RemoteException {} + final class DivideHandler { // Alternative zur programmatischen Registrierung unten: registerHandlers(). @@ -71,10 +57,7 @@ public function divide(array $params, RequestContext $request): array if ((float) $params['b'] === 0.0) { // Erzeugt eine terminale, sichere Fehlerantwort am Rückkanal. - throw new CommandFailedException( - errorCode: 'DIVIDE_BY_ZERO', - publicMessage: 'Division durch null ist nicht möglich.', - ); + throw new DivisionByZero('Division durch null ist nicht möglich.', errorCode: 'DIVIDE_BY_ZERO'); } if (abs($params['b']) < 1) { @@ -91,9 +74,9 @@ public function divide(array $params, RequestContext $request): array } } -function runServer(string $dsn, string $secret): void +function runServer(): void { - $mq = connect($dsn, $secret, client: false); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { $mq->respond('calculator', 'calculator-workers', [new DivideHandler(), 'divide'], new SubscriptionOptions(type: 'math.divide.v1')); @@ -114,9 +97,9 @@ function runServer(string $dsn, string $secret): void } } -function runClient(string $dsn, string $secret): void +function runClient(): void { - $mq = connect($dsn, $secret, client: true); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { // Publish erkennt das DTO und übernimmt Topic/Typ. Sendet sofort. $sent = $mq->publish(new Divide(12, 3)); @@ -157,12 +140,31 @@ function runClient(string $dsn, string $secret): void // Beispiel: await(responseClass: LocalResult::class). // Reine Events können mit PublishOptions(reply: false) ohne Antwortaufwand senden. + // 1. Allgemeine Fehlerbehandlung: kein Fehler-Topic abonnieren erforderlich. try { $mq->publish(new Divide(12, 0))->await(timeoutSeconds: 5); } catch (RemoteCommandException $error) { - printf("Command fehlgeschlagen [%s]: %s\n", $error->errorCode, $error->getMessage()); - // Erwartet: DIVIDE_BY_ZERO; keine entfernten PHP-Stacks/Objekte. + // Kein lokales Mapping: generische Exception, aber gleiche freigegebene Meldung. + printf("Remote-Fehler [%s]: %s\n", $error->errorCode, $error->getMessage()); } + + // 2. Gewünschte lokale Exception-Klasse ausdrücklich freigeben. + try { + $mq->publish(new Divide(12, 0))->await( + timeoutSeconds: 5, + errorTypes: [DivisionByZero::class], + ); + } catch (DivisionByZero $error) { + // Gleiche SDK-Klasse und Meldung wie auf dem Server, lokal neu erzeugt. + printf("Division korrigieren: %s\n", $error->getMessage()); + } catch (RemoteCommandException $error) { + // Unbekannter anderer Fehler bleibt ein generischer Remote-Fehler. + printf("RPC fehlgeschlagen: %s\n", $error->getMessage()); + } + // Alternativ await(new AwaitOptions(errorTypes: [DivisionByZero::class])). + // Der Client darf auch eine andere lokale Klasse mit demselben RemoteError-Namen + // registrieren. Keine entfernten PHP-Klassennamen, Stacktraces oder unserialize(). + } catch (RequestTimeoutException $timeout) { // await wirft RequestTimeoutException bei abgelaufener lokaler Wartefrist // oder ursprünglicher Antwortdeadline ohne rechtzeitig empfangenes finales Ergebnis. diff --git a/examples/api-draft/06-metadata-middleware.php b/examples/api-draft/06-metadata-middleware.php index 92660ae..d8c50be 100644 --- a/examples/api-draft/06-metadata-middleware.php +++ b/examples/api-draft/06-metadata-middleware.php @@ -4,7 +4,7 @@ namespace Examples\MessageQueue\Metadata; -use Phore\MessageQueue\ConnectionFactory; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\RejectMessageException; use Phore\MessageQueue\MessageContext; @@ -12,34 +12,21 @@ use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\PublishReceipt; use Phore\MessageQueue\RunOptions; -use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 14. * Zwei optionale Callable-Hooks, keine Middleware-Basisklasse erforderlich. - * Beispielaufruf: demo($dsn, $secret, $traceId), mit frischem Redis-Prefix. + * Beispielaufruf: demo($traceId), mit frischem lokalen Queue-Verzeichnis. */ -function demo(string $dsn, string $secret, string $traceId): void +function demo(string $traceId): void { - $factory = new ConnectionFactory(); - $security = new HmacSecurity( - sharedSecret: $secret, - keyId: 'development-1', - audience: 'orders-development', - ); - // Separate Connection ohne Diagnose-Middleware verhindert Fehlerschleifen. - $diagnostics = $factory->connect($dsn, new ConnectionOptions( - security: $security, - autoCreate: true, - )); + $diagnostics = new PhoreMQ('file:///tmp/phore-mq-demo'); try { - $mq = $factory->connect($dsn, new ConnectionOptions( - security: $security, - autoCreate: true, + $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions( sendMiddleware: [ // $next: callable(OutgoingMessage): PublishReceipt static function (OutgoingMessage $message, callable $next) use ($traceId): PublishReceipt { diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php index 8134ea8..ed1298c 100644 --- a/examples/api-draft/07-broadcast-locking.php +++ b/examples/api-draft/07-broadcast-locking.php @@ -4,14 +4,11 @@ namespace Examples\MessageQueue\BroadcastLocking; -use Phore\MessageQueue\ConnectionFactory; -use Phore\MessageQueue\ConnectionOptions; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Exception\RejectMessageException; use Phore\MessageQueue\MessageContext; -use Phore\MessageQueue\MessageQueueInterface; use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\RunOptions; -use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; /** @@ -37,21 +34,9 @@ public function tryAcquire(string $resource, string $roundId, int $leaseUntilUni public function release(string $resource, string $roundId, int $leaseUntilUnix): void; } -function connect(string $dsn, string $secret): MessageQueueInterface +function runParticipant(string $participantId, LocalLeaseManager $locks): void { - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $secret, - keyId: 'development-1', - audience: 'lock-demo', - ), - autoCreate: true, - )); -} - -function runParticipant(string $dsn, string $secret, string $participantId, LocalLeaseManager $locks): void -{ - $mq = connect($dsn, $secret); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { // ENTSCHEIDEND: Jede erwartete Instanz hat einen ANDEREN Subscription-Namen. $mq->subscribe('maintenance.locks', 'locks-' . $participantId, @@ -103,7 +88,7 @@ static function (array $command, MessageContext $context) use ($mq, $participant * Die Anwendung muss Ablauf/Fencing AN DER ZIELRESSOURCE durchsetzen; ein * PHP-Zeitvergleich allein schützt nicht vor Prozesspausen/Lease-Verlust. */ -function withAllLocks(string $dsn, string $secret, array $participants, callable $criticalSection): void +function withAllLocks(array $participants, callable $criticalSection): void { foreach ($participants as $participant) { if (!is_string($participant) || $participant === '') { @@ -128,7 +113,7 @@ function withAllLocks(string $dsn, string $secret, array $participants, callable 'leaseUntil' => $leaseUntil, ]; - $mq = connect($dsn, $secret); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { // Vor dem Broadcast binden, damit auch sofortige Antworten erfasst werden. $mq->subscribe('maintenance.replies.coordinator-demo', 'lock-coordinator', diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php index b9573d6..4524650 100644 --- a/examples/api-draft/08-processing-workers.php +++ b/examples/api-draft/08-processing-workers.php @@ -4,43 +4,22 @@ namespace Examples\MessageQueue\ProcessingWorkers; -use Phore\MessageQueue\ConnectionFactory; -use Phore\MessageQueue\ConnectionOptions; -use Phore\MessageQueue\MessageQueueInterface; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\RequestContext; use Phore\MessageQueue\Rpc\RequestOptions; -use Phore\MessageQueue\Rpc\RpcConnectionOptions; -use Phore\MessageQueue\Security\HmacSecurity; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 15.3. - * Prozesse A/B/C: runWorker($dsn, $secret, 'worker-1'/'worker-2'/'worker-3'). - * Danach Prozess D: submitJobs($dsn, $secret). - * Alle Connections nutzen denselben Redis-Prefix. Ein Reply-Endpunkt pro Client. + * Prozesse A/B/C: runWorker( 'worker-1'/'worker-2'/'worker-3'). + * Danach Prozess D: submitJobs(). + * Alle Connections nutzen dasselbe lokale Queue-Verzeichnis. Ein Reply-Endpunkt pro Client. */ -function connect(string $dsn, string $secret, bool $client): MessageQueueInterface +function runWorker(string $workerId): void { - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $secret, - keyId: 'development-1', - audience: 'processing-demo', - ), - rpc: new RpcConnectionOptions( - replyTopic: $client ? 'jobs.replies.client-demo' : null, - replySubscription: $client ? 'processing-client-demo' : null, - allowedReplyTopics: ['jobs.replies.client-demo'], - ), - autoCreate: true, - )); -} - -function runWorker(string $dsn, string $secret, string $workerId): void -{ - $mq = connect($dsn, $secret, client: false); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { // ENTSCHEIDEND: Alle Worker verwenden exakt dieselbe Subscription. // $workerId NICHT an 'text-processors' anhängen, sonst entsteht Fan-out! @@ -65,9 +44,9 @@ static function (array $job, RequestContext $request) use ($workerId): array { } } -function submitJobs(string $dsn, string $secret): void +function submitJobs(): void { - $mq = connect($dsn, $secret, client: true); + $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); try { $pending = []; foreach ([' erster Job ', ' zweiter Job ', ' dritter Job '] as $text) { diff --git a/examples/api-draft/09-system-check.php b/examples/api-draft/09-system-check.php index 38f5ba3..101c9d8 100644 --- a/examples/api-draft/09-system-check.php +++ b/examples/api-draft/09-system-check.php @@ -4,7 +4,7 @@ namespace Examples\MessageQueue\SystemCheck; -use Phore\MessageQueue\ConnectionFactory; +use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\RetryableMessageException; use Phore\MessageQueue\Health\CheckOptions; @@ -13,33 +13,17 @@ use Phore\MessageQueue\Health\HealthState; use Phore\MessageQueue\Health\ReadinessRequirement; use Phore\MessageQueue\MessageQueueInterface; -use Phore\MessageQueue\Security\HmacSecurity; -use Phore\MessageQueue\Rpc\RpcConnectionOptions; use Phore\MessageQueue\SubscriptionOptions; /** API-ENTWURF, keine ausführbare Implementierung. Proposal § 16. - * Alle Parameter/Secrets werden explizit injiziert. Health-Ressourcen und - * Reply-Berechtigungen müssen vorab provisioniert sein (autoCreate: false). + * Lokaler Einstieg wie in 01-connect.php; hier ausschließlich Health-Konfiguration. */ -function connect(string $dsn, string $secret, HealthOptions $health): MessageQueueInterface -{ - return (new ConnectionFactory())->connect($dsn, new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $secret, keyId: 'development-1', audience: 'health-demo', - ), - rpc: new RpcConnectionOptions(allowedReplyTopics: [ - 'jobs.replies.frontend-demo', // Explizit provisionierter RPC-Rückkanal. - ]), - health: $health, - autoCreate: false, - )); -} // Beispiel eines definierten Anwendungsfehlers aus dem injizierten Exporter. final class OutputPermissionException extends \RuntimeException {} function runExportWorker( - string $dsn, string $secret, string $instanceId, string $host, + string $instanceId, string $host, string $outputDirectory, callable $processExport, ): void { $state = new HealthState(serviceId: 'export-service', instanceId: $instanceId); @@ -64,7 +48,7 @@ function runExportWorker( ), topic: 'jobs.export', type: 'export.create.v1'); $refresh($state); - $mq = connect($dsn, $secret, new HealthOptions( + $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(health: new HealthOptions( state: $state, refresh: $refresh, refreshIntervalSeconds: 5, @@ -75,7 +59,7 @@ function runExportWorker( '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 { @@ -96,9 +80,9 @@ static function (array $parameters) use ($processExport, $state, $denied): array } } -function frontendConnection(string $dsn, string $secret, string $instanceId): MessageQueueInterface +function frontendConnection(string $instanceId): MessageQueueInterface { - return connect($dsn, $secret, new HealthOptions( + return new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(health: new HealthOptions( state: new HealthState(serviceId: 'frontend-backend', instanceId: $instanceId), requirements: [ // Mein System BRAUCHT diesen Nachrichtentyp, mit mindestens einem Worker. @@ -112,7 +96,7 @@ function frontendConnection(string $dsn, string $secret, string $instanceId): Me subscriptions: ['audit-service', 'mail-service'], ), ], - )); + ))); } function checkAtLogin(MessageQueueInterface $mq): array diff --git a/examples/api-draft/10-callback-errors.php b/examples/api-draft/10-callback-errors.php new file mode 100644 index 0000000..7839518 --- /dev/null +++ b/examples/api-draft/10-callback-errors.php @@ -0,0 +1,74 @@ +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 ($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')); + + $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'temporary']); + $mq->publish('jobs.demo', 'demo.process.v1', []); + $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'unexpected']); + + // Vorgeschlagener Standard: 1 erster Versuch + höchstens 3 Wiederholungen, + // mit 1/2/4 Sekunden Verzögerung und ±10 % 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); + // file-Profil speichert endgültige Fehler im lokalen FailureStore (siehe 01). + // 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. From 39acc8032394e1fa13d336fae3583b34656c9e19 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 11:16:43 +0200 Subject: [PATCH 10/16] docs: focus architecture on RabbitMQ and add local deployment setup --- .ai-usage-info.md | 130 ++-- README.md | 128 ++-- config/message-queue.json | 36 + deployment/rabbitmq/compose.yaml | 22 + deployment/rabbitmq/setup.py | 106 +++ docs/message-queue-basics-101.md | 55 +- .../proposals/2026-09-12-message-queue-api.md | 715 +++++++----------- docs/setup.md | 202 +++++ examples/api-draft/01-connect.php | 194 +---- examples/api-draft/02-programmatic.php | 10 +- examples/api-draft/03-attributes.php | 12 +- examples/api-draft/04-files-and-local.php | 15 +- examples/api-draft/05-rpc.php | 12 +- examples/api-draft/06-metadata-middleware.php | 12 +- examples/api-draft/07-broadcast-locking.php | 8 +- examples/api-draft/08-processing-workers.php | 12 +- examples/api-draft/09-system-check.php | 12 +- examples/api-draft/10-callback-errors.php | 12 +- examples/api-draft/connection.php | 28 + 19 files changed, 921 insertions(+), 800 deletions(-) create mode 100644 config/message-queue.json create mode 100644 deployment/rabbitmq/compose.yaml create mode 100644 deployment/rabbitmq/setup.py create mode 100644 docs/setup.md create mode 100644 examples/api-draft/connection.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 3296b3a..8cf307d 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -2,78 +2,58 @@ ## Sinn der Library -`phore/message-queue` ist derzeit eine Projektvorlage mit einem -[API-Proposal](docs/proposals/2026-09-12-message-queue-api.md), keine implementierte -Queue-Library. Geplant: Redis Streams, austauschbare Konnektoren, Topics und -Subscriptions, optionale `phore/schema`-Hydration, HMAC-Signierung und Dateireferenzen. -`Phore\MessageQueue` ist der vorgeschlagene Namespace; Composer-Name und -Autoloading sind noch unveränderte Template-Werte. - -Das zentrale Objekt ist `Phore\MessageQueue\PhoreMQ`: -`$mq = new PhoreMQ($dsn, $options)` oder `new PhoreMQ($connector, $options)`. -Die optionalen `ConnectionOptions` bündeln die gesamte weitere Konfiguration. -Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das -`MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, -wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. - -`subscribe($callback)` übernimmt Topic, Subscription und Wire-Typ aus dem -Mapping des ersten DTO-Parameters; am Handler reicht alternativ `#[Subscribe]`. -Offene Werte werden explizit ergänzt, widersprüchliche feste Angaben und -doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere -Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten -konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). - -`publish` sendet sofort und liefert `SendResult` mit Broker-Beleg `receipt`. -Mit konfiguriertem RPC-Rückkanal wartet optional -`publish($command)->await(timeoutSeconds: 5)` auf ein Responder-Ergebnis; -`await` sendet nicht erneut. Ohne Rückkanal ist späteres Warten nicht möglich. -Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; -`RunOptions`/`AwaitOptions` bleiben erlaubt, direkte Werte überschreiben ihre -entsprechenden Felder. Das vollständige Beispiel steht in -[05-rpc.php](examples/api-draft/05-rpc.php). - -Einfacher lokaler Einstieg im Entwurf: `new PhoreMQ('file:///tmp/phore-mq-demo')`. -Die Voraussetzungen dieses geplanten Entwicklungsadapters stehen zentral in -[01-connect.php](examples/api-draft/01-connect.php); Redis bleibt Produktionsstandard. -Callback-Fehler und begrenzte Retries zeigt -[10-callback-errors.php](examples/api-draft/10-callback-errors.php). - -[Message Queue Basics 101](docs/message-queue-basics-101.md) erklärt Begriffe, -Zustellung, Aufbewahrung und die Grenzen der geplanten Adapter. - -## Beispiele - -Die Dateien beschreiben ausschließlich die geplante API und sind noch nicht ausführbar: - -- [Verbindungen](examples/api-draft/01-connect.php) -- [Programmatische Nutzung und eigene DTOs](examples/api-draft/02-programmatic.php) -- [PHP-Attribute für SDK-Typen und Handler](examples/api-draft/03-attributes.php) -- [ZIP-Dateien per Speicherreferenz](examples/api-draft/04-files-and-local.php) -- [RPC mit Rückgabewerten und Begleitmeldungen](examples/api-draft/05-rpc.php) -- [Getrennte Metadaten und Middleware](examples/api-draft/06-metadata-middleware.php) -- [Broadcast und Einsammeln aller Lock-Antworten](examples/api-draft/07-broadcast-locking.php) -- [Processing-Queue mit konkurrierenden Workern und Ergebnis](examples/api-draft/08-processing-workers.php) -- [Standardisierter Systemcheck und aktiver Dienststatus](examples/api-draft/09-system-check.php) - -Geplant: `check()` für Verbindung und gezielte Consumer-Bereitschaft, -`HealthState::set()` für Dienstprobleme und Wiederherstellung. Aktive -Statusereignisse und Probe-Antworten verwenden denselben versionierten Vertrag; -unbekannte/veraltete Pflichtzustände gelten nicht als bereit. - -Unterschiedliche Subscription-Namen erzeugen Fan-out; identische Namen -verteilen Arbeit innerhalb einer Gruppe. Lock-Koordination zählt bestätigte -Teilnehmer einer festen Liste, kein universelles verteiltes Lock über die Queue. - -Für RPC sind `publish(...)->await()` und `respond()` vorgesehen; -`request(...)->await()` bleibt eine explizite Komfortform; Metadaten bleiben -außerhalb des Payloads. Middleware erhält zwei klar getrennte Hooks für Senden -und Handler-Ausführung. Auch diese Ergänzungen sind ausschließlich Entwurf. - -## Globale Funktionen - -Keine implementierten globalen Funktionen vorhanden. - -Deklarierte Nachrichtenabhängigkeiten lassen sich gemeinsam mit -`check(options: new CheckOptions(requireDeclared: true))` prüfen. Der Bericht -zeigt Listener, Bereitschaft, Ursachen und optional Host-/Speicherdiagnose; -fehlende Antworten gelten als unbekannt statt als sicher fehlender Listener. +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. Docker Compose und das Python- +Setup sind unabhängig von der geplanten PHP-Library verwendbar. + +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. + +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. + +## 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) diff --git a/README.md b/README.md index 33b02ec..31b5249 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,64 @@ # Phore Message Queue -Geplante universelle PHP-Library für Message Queues mit austauschbaren -Konnektoren. Redis Streams bildet die erste Implementierung; eine gemeinsame -API verbindet Topics, dauerhafte Subscriptions und konkurrierende Worker. -Optionale strukturelle Validierung und DTO-Hydration verwenden `phore/schema` -aus dem Repository `phore/phore-schema`. Nachrichtentypen und Handler lassen -sich programmatisch oder über PHP-Attribute zuordnen. Eine austauschbare -Sicherheitsschicht signiert Nachrichten transparent mit HMAC-SHA-256; -große Dateien werden über verifizierte Speicherreferenzen transportiert. -Der Entwurf umfasst außerdem Request/Reply für RPC, getrennte Metadaten -und zwei optionale Middleware-Hooks für Senden und Handler-Ausführung. +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: API-Entwurf, noch keine Queue-Implementierung.** Composer-Metadaten -und Autoloading stammen weiterhin aus der Projektvorlage. Die folgenden -PHP-Dateien zeigen die vorgeschlagene API und sind noch nicht ausführbar. +**Status: Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und +Autoloading stammen aus der Projektvorlage. Docker Compose und das Python- +Setup sind unabhängig von der geplanten PHP-Library verwendbar. -- [API-Entwurf, Konnektorvergleich und Paketgrenzen](docs/proposals/2026-09-12-message-queue-api.md) -- [Verbinden: URL, Konnektor und Attribute](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 Speicherreferenz](examples/api-draft/04-files-and-local.php) -- [RPC: Command, Ergebnis, Warnings und Fehler](examples/api-draft/05-rpc.php) -- [Metadaten und Diagnose-Middleware](examples/api-draft/06-metadata-middleware.php) -- [Broadcast und Antworten aller Lock-Teilnehmer](examples/api-draft/07-broadcast-locking.php) -- [Processing-Queue: ein verfügbarer Worker und ein Ergebnis](examples/api-draft/08-processing-workers.php) -- [Systemcheck, Dienststatus und frühzeitige Fehlermeldungen](examples/api-draft/09-system-check.php) - -**An alle:** pro Empfänger eine eigene Subscription. **An einen:** alle Worker -verwenden dieselbe Subscription. Das Backend verteilt die Zustellungen an -verfügbare Worker; gleichmäßiger Zufall oder Exactly-once-Ausführung werden -nicht vorausgesetzt. - -Die Alltags-API bleibt klein: `publish()` sendet ein Event, `subscribe()` -empfängt Events, `publish(...)->await()` wartet optional auf eine Antwort, `respond()` -registriert einen Command-Handler und `run()` verarbeitet Nachrichten. -`publish($dto)` übernimmt Topic und Typ aus den Metadaten des Objekts; -eine separate `emit`-Methode ist nicht mehr vorgesehen. -Für Diagnose ergänzt `check()` einen standardisierten Bericht über Verbindung -und Consumer-Bereitschaft. Dienste können über einen gemeinsamen `HealthState` -Probleme aktiv melden und betroffene Verarbeitung pausieren; Frontend und -Monitoring nutzen dasselbe Statusformat. -Der [Frameworkvergleich und die API-Entscheidung in § 14.1](docs/proposals/2026-09-12-message-queue-api.md) -begründen diesen Ansatz. - -Das zentrale Objekt ist `Phore\MessageQueue\PhoreMQ`: -`$mq = new PhoreMQ($dsn, $options)` oder `new PhoreMQ($connector, $options)`. -Die optionalen `ConnectionOptions` bündeln die gesamte weitere Konfiguration. -Die `ConnectionFactory` liefert ebenfalls `PhoreMQ`, das -`MessageQueueInterface` implementiert. Einmal je Verbindung erzeugen, -wiederverwenden und mit `close()` freigeben; beide Wege verbinden sofort. - -`subscribe($callback)` übernimmt Topic, Subscription und Wire-Typ aus dem -Mapping des ersten DTO-Parameters; am Handler reicht alternativ `#[Subscribe]`. -Offene Werte werden explizit ergänzt, widersprüchliche feste Angaben und -doppelte lokale Bindungen schon beim Registrieren abgelehnt. Für mehrere -Topics bleibt das Topic am Contract offen; feste Subscriptions bedeuten -konkurrierende Worker. Siehe [Attributbeispiele](examples/api-draft/03-attributes.php). +Vom Repository-Verzeichnis aus: -`publish` sendet sofort und liefert `SendResult` mit Broker-Beleg `receipt`. -Mit konfiguriertem RPC-Rückkanal wartet optional -`publish($command)->await(timeoutSeconds: 5)` auf ein Responder-Ergebnis; -`await` sendet nicht erneut. Ohne Rückkanal ist späteres Warten nicht möglich. -Häufige Optionen gehen direkt: `run(maxMessages: 100, maxSeconds: 30)`; -`RunOptions`/`AwaitOptions` bleiben erlaubt, direkte Werte überschreiben ihre -entsprechenden Felder. Das vollständige Beispiel steht in -[05-rpc.php](examples/api-draft/05-rpc.php). +```bash +docker compose -f deployment/rabbitmq/compose.yaml up -d --wait +python3 deployment/rabbitmq/setup.py +``` -Einfacher lokaler Einstieg im Entwurf: `new PhoreMQ('file:///tmp/phore-mq-demo')`. -Die Voraussetzungen dieses geplanten Entwicklungsadapters stehen zentral in -[01-connect.php](examples/api-draft/01-connect.php); Redis bleibt Produktionsstandard. -Callback-Fehler und begrenzte Retries zeigt -[10-callback-errors.php](examples/api-draft/10-callback-errors.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. -[Message Queue Basics 101](docs/message-queue-basics-101.md) erklärt Begriffe, -Zustellung, Aufbewahrung und die Grenzen der geplanten Adapter. +- [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 @@ -89,8 +74,3 @@ Nachträglich initialisieren oder aktualisieren: git submodule update --init --recursive git submodule update --remote --merge ``` - -Deklarierte Nachrichtenabhängigkeiten lassen sich gemeinsam mit -`check(options: new CheckOptions(requireDeclared: true))` prüfen. Der Bericht -zeigt Listener, Bereitschaft, Ursachen und optional Host-/Speicherdiagnose; -fehlende Antworten gelten als unbekannt statt als sicher fehlender Listener. 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/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.py b/deployment/rabbitmq/setup.py new file mode 100644 index 0000000..2f79041 --- /dev/null +++ b/deployment/rabbitmq/setup.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Provision the local RabbitMQ demo from neutral config; no PhoreMQ runtime needed. + +Repeated identical declarations are safe. No resources or messages are deleted. +Only local HTTP management is supported by this development helper. +""" +import argparse +import base64 +import json +import re +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, unquote, urlsplit +from urllib.request import Request, build_opener, ProxyHandler, HTTPRedirectHandler + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def declarations(config): + if set(config) != {"connection", "options", "topics", "subscriptions"}: + raise ValueError("Expected connection, options, topics and subscriptions") + if not isinstance(config["topics"], list) or not isinstance(config["subscriptions"], list): + raise ValueError("topics and subscriptions must be lists") + name = re.compile(r"[a-zA-Z_][a-zA-Z0-9_.-]{0,99}\Z") + topics = config["topics"] + if any(not isinstance(t, str) or (not name.fullmatch(t) or t.startswith("_phore")) for t in topics): + raise ValueError("Invalid topic name") + if len(set(topics)) != len(topics): + raise ValueError("Duplicate topic") + connection = urlsplit(config["connection"]) + if connection.scheme != "amqp" or connection.hostname not in ("127.0.0.1", "localhost"): + raise ValueError("This helper requires a local amqp DSN") + if connection.port != 5672 or connection.query or connection.fragment or not connection.username or connection.password is None: + raise ValueError("Invalid local connection DSN") + if not connection.path.startswith("/") or not connection.path[1:]: + raise ValueError("DSN must include a namespace") + namespace = unquote(connection.path[1:]) + vhost = quote(namespace, safe="") + actions = [] + for topic in topics: + actions.append(("PUT", f"exchanges/{vhost}/{quote('phore.topic:' + topic, safe='')}", + {"type": "topic", "durable": True, "auto_delete": False, "internal": False, "arguments": {}})) + seen = set() + for sub in config["subscriptions"]: + if not isinstance(sub, dict) or set(sub) != {"topic", "name", "type"}: + raise ValueError("Subscription requires topic, name and type (null means all)") + topic, subscription, kind = sub["topic"], sub["name"], sub["type"] + if topic not in topics or not isinstance(subscription, str) or not name.fullmatch(subscription): + raise ValueError("Invalid subscription or unknown topic") + if kind is not None and (not isinstance(kind, str) or not name.fullmatch(kind)): + raise ValueError("type must be an exact message type or null") + if (topic, subscription) in seen: + raise ValueError("Duplicate subscription") + seen.add((topic, subscription)) + queue = f"phore.sub:{topic}:{subscription}" + failure = f"phore.failure:{topic}:{subscription}" + args = {"x-queue-type": "quorum"} + actions.append(("PUT", f"queues/{vhost}/{quote(failure, safe='')}", + {"durable": True, "auto_delete": False, "arguments": args})) + # A dedicated failure destination never fans failed work out to other subscriptions. + args = {"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.append(("PUT", f"queues/{vhost}/{quote(queue, safe='')}", + {"durable": True, "auto_delete": False, "arguments": args})) + actions.append(("POST", f"bindings/{vhost}/e/{quote('phore.topic:' + topic, safe='')}/q/{quote(queue, safe='')}", + {"routing_key": kind if kind is not None else "#", "arguments": {}})) + return connection, actions + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[2] / "config/message-queue.json") + parser.add_argument("--dry-run", action="store_true", help="Validate and print declarations without connecting") + args = parser.parse_args() + config = json.loads(args.config.read_text()) + connection, actions = declarations(config) # Validate everything before the first write. + if args.dry_run: + print(json.dumps(actions, indent=2)) # Contains no DSN or credentials. + return + credentials = f"{unquote(connection.username)}:{unquote(connection.password)}".encode() + headers = {"Authorization": "Basic " + base64.b64encode(credentials).decode(), "Content-Type": "application/json"} + opener = build_opener(ProxyHandler({}), NoRedirect()) + base = "http://127.0.0.1:15672/api/" + # This fixed loopback endpoint prevents config from redirecting demo credentials. + for method, path, body in actions: + if method == "POST": + with opener.open(Request(base + path, headers=headers), timeout=10) as response: + existing = json.load(response) + if existing and any(b["routing_key"] != body["routing_key"] or b.get("arguments") for b in existing): + raise ValueError("Existing subscription filter differs; use a new subscription or an explicit migration") + with opener.open(Request(base + path, json.dumps(body).encode(), headers, method=method), timeout=10): + pass + print(f"Topology ready: {len(config['topics'])} topics, {len(config['subscriptions'])} subscriptions") + + +if __name__ == "__main__": + try: + main() + except HTTPError as error: + raise SystemExit(f"RabbitMQ management HTTP {error.code}; check readiness, permissions and declaration conflicts") from None + except (ValueError, TypeError, KeyError, OSError, URLError) as error: + raise SystemExit(f"Setup failed: {error}") from None diff --git a/docs/message-queue-basics-101.md b/docs/message-queue-basics-101.md index 07d1d52..b5c91d6 100644 --- a/docs/message-queue-basics-101.md +++ b/docs/message-queue-basics-101.md @@ -5,7 +5,7 @@ Stell dir einen Export vor: Die ZIP-Datei ist fertig, dann stürzt der Worker ab 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. Noch keiner der hier genannten Adapter ist implementiert.** Die Brokerfunktionen existieren unabhängig davon; die folgenden Zuordnungen beschreiben ihre geplante Nutzung durch PhoreMQ. +**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 @@ -15,7 +15,7 @@ Ein Benutzer wurde angelegt. Der Maildienst soll eine Begrüßung versenden, der |---|---| | 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, beispielsweise Redis oder RabbitMQ | +| 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` | @@ -23,7 +23,7 @@ Ein Benutzer wurde angelegt. Der Maildienst soll eine Begrüßung versenden, der | 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 NATS-*Subject* ist die Routingadresse einer Nachricht. Ein JetStream-Stream kann Nachrichten mehrerer Subjects speichern; ein Consumer bestimmt, welche davon er liest. Bei RabbitMQ routet dagegen eine Exchange anhand von Bindings und gegebenenfalls Routing Keys in Queues. Diese Begriffe lassen sich nicht überall eins zu eins auf „Topic“ abbilden. Der Adapter übernimmt die Zuordnung. [NATS: Streams](https://docs.nats.io/learn/jetstream/your-first-stream), [RabbitMQ: Exchanges](https://www.rabbitmq.com/docs/exchanges) +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 @@ -34,7 +34,7 @@ Im PhoreMQ-Entwurf bestimmt die Subscription die Verteilung: „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 Regeln des jeweiligen Brokers. Gleichmäßiger Zufall ist nicht garantiert. Mehr Worker erlauben parallele Verarbeitung, verändern aber die Reihenfolge der Fertigstellung. Nach einem Verbindungs- oder Lease-Verlust 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), [Redis: Consumer Groups](https://redis.io/docs/latest/commands/xreadgroup/) +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 @@ -43,7 +43,7 @@ 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 den Regeln für Lease und Retry 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. +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. @@ -55,49 +55,32 @@ Für den Export vom Einstieg hilft deshalb eine stabile Auftrags-ID: Der Worker |---|---| | **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 | -| **Lease / Visibility / Ack-Frist** | Wie lange darf ein Worker die offene Zustellung bearbeiten, bevor sie erneut verfügbar werden kann? | +| **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 | -## Welche Adapter vorgesehen sind +## RabbitMQ als einzige Umsetzung -Alle Statusangaben beziehen sich auf dieses Paket, nicht auf die Reife des jeweiligen Brokers. +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. -| Adapter / Status | Fan-out und konkurrierende Worker | Bestätigung und Wiederholung | Aufbewahrung und wichtigste Grenze | -|---|---|---|---| -| **Redis Streams — erster produktiver Adapter geplant** | Eigene Gruppe je Subscription; Worker teilen eine Gruppe | `XACK` entfernt den Eintrag aus der Pending-Liste der Gruppe; Claim übernimmt verwaiste Zustellungen | Stream bleibt bis Trimming/Löschung; Ack allein löscht den Streameintrag nicht. Persistenz und Eviction müssen passend konfiguriert sein | -| **Redis Pub/Sub — möglicher späterer Modus** | Aktive Subscriber erhalten Nachrichten | Keine dauerhafte Ack-/Recovery-Semantik | Keine Historie für offline gegangene Subscriber; kein Ersatz für Streams | -| **SQS — geplanter Arbeitsqueue-Adapter** | Worker teilen eine Queue; eine Queue allein liefert keinen unabhängigen Fan-out | Visibility Timeout verbirgt die Zustellung vorübergehend, `DeleteMessage` bestätigt die Verarbeitung | Standardmäßig 4 Tage, konfigurierbar bis 14 Tage; Standard Queues erlauben Duplikate und bieten keine strenge Reihenfolge | -| **SNS + SQS — geplanter Fan-out-Adapter** | SNS verteilt an eine SQS-Queue je Subscription; deren Worker teilen Arbeit | Annahme durch SNS und Zustellung nach SQS sind eigene Schritte; Retry-/DLQ-Konfiguration nötig | Nach Übergabe gilt die Retention der jeweiligen SQS-Queue; nicht als universelles Event-Archiv behandeln | -| **Azure Service Bus — geplant** | Queues für Arbeitsverteilung, Topics mit Subscriptions für Fan-out | Peek-Lock, `Complete`, `Abandon`, Lock-Erneuerung und Dead Letter | TTL und Zustellgrenzen konfigurieren; Receive-and-Delete passt nicht zum geplanten Auto-Ack-nach-Erfolg | -| **RabbitMQ — geplant** | Exchange/Bindings routen in Queues; Consumer einer Queue teilen Arbeit | Publisher Confirms und Consumer-Acks; Requeue/Dead Letter | Queue-/Message-TTL und Längenlimits konfigurieren; dauerhafte Queue, persistente Nachrichten und geeigneter Queue-Typ gehören zum Haltbarkeitskonzept | -| **NATS JetStream — später geplant** | Eigenständige Consumer für unabhängige Sichten; gemeinsam genutzter Pull-Consumer für Worker | Stream-Publish-Ack und explizites Consumer-Ack, Redelivery nach Ack-Frist | Limits-, Interest- und WorkQueue-Retention unterscheiden sich. WorkQueue passt nicht zu beliebig überlappenden unabhängigen Consumern | -| **In-Memory — für Tests geplant** | Lokale Gruppen am selben ausdrücklich geteilten Brokerobjekt | Vom Testadapter nachgebildet | Prozessende verliert Zustand; keine Kommunikation zwischen unabhängigen Prozessen | -| **file — lokaler Entwicklungsadapter geplant** | Prozesse desselben OS-Benutzers teilen einen SQLite-Queue-Root | Transaktionale Claims, Leases und Fehlerablage sind Teil des Entwurfs | Lokale Dateien überleben Neustarts; Retention/Bereinigung nötig. Kein NFS-/Multi-Host-Adapter | -| **Unix-Dev-Broker — Anschlussphase geplant** | Separater lokaler Prozess verwaltet Gruppen | Eigenes lokales MQ-Protokoll erforderlich | Im Entwurf volatil; Broker-Neustart verliert Zustand. Ein Unix-Socket allein ist keine Queue | - -Belege zu den Brokerzeilen: [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/), [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/), [SQS Retention](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-queue-parameters.html), [SQS Standard Queues](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html), [SNS an SQS](https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html), [Azure Settlement](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-transfers-locks-settlement), [RabbitMQ Confirms](https://www.rabbitmq.com/docs/confirms), [JetStream Retention](https://docs.nats.io/learn/jetstream/retention-policies). Die drei lokalen Adapterzeilen sind eigene Designentscheidungen; Details im [API-Proposal](proposals/2026-09-12-message-queue-api.md). - -FIFO-Funktionen, etwa bei SQS, sowie Sessions oder geordnete Consumer können bestimmte Reihenfolgen absichern. Sie sind keine universelle Zusage dieser Abstraktion und müssen später als konkrete Adapter-Capability beschrieben werden. Core NATS ist außerdem von JetStream zu unterscheiden: Sein gewöhnlicher Pub/Sub-Betrieb ist kein persistenter JetStream-Consumer. [SQS FIFO](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-fifo-queues.html), [NATS: JetStream](https://docs.nats.io/learn/jetstream) +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 -Es gibt daher keine gemeinsame Antwort „alle Nachrichten bleiben sieben Tage“. Bei Redis kann Trimming die Historie kürzen, bei SQS gilt die Queue-Retention, bei JetStream zusätzlich die gewählte Retention-Policy. Azure kann abgelaufene Nachrichten je Einstellung entfernen oder dead-lettern. Auch Dead-Letter-Speicher brauchen eine eigene Aufbewahrungs- und Bereinigungsregel. [Redis XTRIM](https://redis.io/docs/latest/commands/xtrim/), [Azure Ablaufzeiten](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-expiration) +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) -Die Aufbewahrung muss zu erwarteten Ausfällen und Wiederholungen passen. Bei großen ZIP-Dateien gilt das zusätzlich für die ausgelagerte Datei: Eine erhaltene Nachricht mit bereits gelöschtem Attachment ist nicht mehr vollständig verarbeitbar. +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 -Für die **geplanten lokalen Beispiele** reicht: +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 Python-Skript. Die PHP-Beispiele verwenden nach Implementierung: ```php -$mq = new PhoreMQ('file:///tmp/phore-mq-demo'); +$mq = new PhoreMQ(...demoConnection()); ``` -Das lokale Profil bündelt Queue, Fehlerablage, Attachment-Store, einen persistenten lokalen Signaturschlüssel und eindeutige RPC-Rückkanäle. Voraussetzungen und die explizite Redis-/Factory-Konfiguration stehen einmalig in [01-connect.php](../examples/api-draft/01-connect.php). Es ist kein Produktions-Sicherheitsprofil für mehrere Dienste oder Benutzer. - -Danach entscheidet die Anwendung vor allem über drei Dinge: **Routing** (wer braucht welche Nachrichten?), **Lebensdauer** (wie lange darf Arbeit offen bleiben?) und **Fehlerbehandlung** (wann erneut versuchen, wann endgültig ablegen?). Topics, Bindings und Berechtigungen werden bei Netzwerkbrokern in Produktion vorab provisioniert; die Library legt sie nur mit ausdrücklich erlaubtem `autoCreate` an. +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)` verarbeitet ihre Zustellungen, bis das erste Limit erreicht ist. Es bedeutet weder 100 parallele Jobs noch garantiert 100 erfolgreiche Jobs. Synchrone Handler laufen in einem Worker nacheinander; für Parallelität startet die Anwendung mehrere Worker derselben Subscription. Lange Jobs brauchen passende Leases oder deren Verlängerung. Details und Rückkehrbedingungen stehen in [02-programmatic.php](../examples/api-draft/02-programmatic.php). +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 @@ -120,9 +103,9 @@ 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 Standardeinstellung des lokalen file-Profils erzeugt deshalb -eigene Reply-Endpunkte je MQ-Instanz. Bei Netzwerkbrokern werden diese und ihre -Berechtigungen konfiguriert. Die Request-ID verknüpft Antworten; Signaturen, +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. @@ -194,4 +177,4 @@ Bei Callback-Exceptions sieht der Entwurf begrenzte Wiederholungen mit wachsende `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. Fehlende Adapterfähigkeiten sollen früh als `UnsupportedCapabilityException` 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. +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. diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 3da279c..434c68c 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -11,39 +11,34 @@ | 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 | ## § 1 Abstract und Lieferumfang -Eine frameworkunabhängige PHP-Library stellt eine gemeinsame Zugriffsschicht -für Topics, dauerhafte Subscriptions und Worker bereit. Redis Streams ist der -erste produktive Konnektor. Das zentrale Objekt `PhoreMQ` wird direkt mit -DSN oder Konnektor und `ConnectionOptions` erzeugt; alternativ liefert die -Connection-Factory dasselbe Objekt. Message-Typen besitzen stabile fachliche Namen; ihre PHP-Klassen -dürfen sich zwischen Anwendungen unterscheiden. `phore/schema` validiert und -hydriert optional die lokal erwartete Struktur. PHP-Attribute ergänzen die -programmatische API. Signierung und Dateispeicher sind austauschbare Dienste. -Eine optionale Request/Reply-Schicht ergänzt RPC mit Rückgabewerten und -Begleitmeldungen; Metadaten und Middleware bleiben vom Payload getrennt. - -**Dies ist ein Entwurf, keine implementierte oder installierbare API.** Das -Ziel-Repository enthält bisher nur die Projektvorlage, keine `src/`- oder -`test/`-Implementierung und keine eigene `SKILLS.md`. Als Namespace ist -`Phore\MessageQueue` vorgesehen; Composer-Name/Autoloading bleiben in diesem PR -unverändert. Beispiele verwenden PHP >=8.3, passend zur aktuellen Vorlage. - -| Ausbaustufe | Geplanter Inhalt | +**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. [neu] + +Die frameworkunabhängige PHP-Library bietet Topics, dauerhafte 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. [neu] + +**Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und Autoloading +bleiben unveränderte Template-Werte; Beispiele benötigen später PHP >=8.3. +Der Docker-Start und das Python-Setup aus [Setup](../setup.md) sind davon +unabhängige, verwendbare Entwicklungsdateien; sie implementieren keine MQ-Library. [neu] + +| Umfang | Entscheidung | |---|---| -| Erste Umsetzung | Factory, Registry, JSON-Envelope, Topic/Subscription-API, Redis Streams, In-Memory, Callback-Worker, Ack/Retry/Dead Letter, Exceptions, HMAC, optionale Schema-Bridge und Attribute | -| Anschlussphase | Attachment-/PayloadStore-Vertrag mit lokalem Dateispeicher; separater Unix-Entwicklungsbroker mit Konnektor | -| Optionale RPC-Erweiterung | `request`/`respond`, Rückkanal, Ergebnis/Fehler/Warnings und Middleware aus §§ 13–14; baut auf der Queue-API auf | -| Weitere Adapter | SQS für Arbeitsqueues, SNS+SQS für Fan-out, Azure Service Bus, RabbitMQ | -| Spätere Erweiterungen | PGP-Provider, S3/Blob-PayloadStore, Batch, Delay, Filter, Replay, Telemetrie, optionale Outbox-/Inbox-Integration | - -Die Beispiele illustrieren auch die Anschlussphase, ausdrücklich ohne sie in -diesem PR zu implementieren. Vor Umsetzung werden Konnektorabhängigkeiten und -unterstützte Serverversionen festgelegt; für Redis ist >=6.2 wegen `XAUTOCLAIM` -der vorgeschlagene Mindeststand. Provider-spezifische Erweiterungen dürfen -nicht stillschweigend auf schwächere Semantik zurückfallen. +| Transport | Genau ein RabbitMQ-Adapter hinter `ConnectorInterface` | +| Zustellung | Dauerhafte Subscriptions, Quorum Queues, Publisher Confirms, Ack nach Handler-Erfolg | +| 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 [neu] | + +Die Referenz für den Entwicklungsaufbau ist RabbitMQ 4.3 mit Management-Plugin. +Die produktive PHP-Client-Abhängigkeit wird bei Implementierung festgelegt. [neu] ### § 1.1 Kleine API auf einen Blick @@ -85,17 +80,17 @@ 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 | Fachlicher Vertrag, etwa `user.created.v1`; unabhängig von Namespace und Composer-Paket | +| 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 | Dauerhafte benannte Sicht auf ein Topic, etwa `billing-users` | | 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 | +| Delivery | Eine konkrete Zustellung inklusive opaque Receipt und Ack-/Retry-Steuerung [geändert] | Jede dauerhafte Subscription erhält eine Kopie. Worker derselben Subscription sind konkurrierende Consumer. Beispiel: `billing-users` und `audit-users` @@ -114,49 +109,48 @@ 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 -backendübergreifende Exactly-once-Garantie und keine globale Reihenfolge. -Fachliche Seiteneffekte müssen anhand `messageId` idempotent sein. - -`subscribe()` bindet eine benannte Subscription und prüft ihre Konfiguration. -Neue Subscriptions beginnen standardmäßig bei `StartPosition::Latest` zum -Zeitpunkt ihrer Anlage; bestehende behalten ihren Cursor. Ein späterer -Worker-Neustart setzt ihn niemals zurück. `Beginning` ist eine explizite -Replay-Capability und umfasst nur noch aufbewahrte Einträge. In Produktion -werden Topics und Subscriptions vorab provisioniert; nur eine ausdrücklich -aktivierte `autoCreate`-Option darf Ressourcen anlegen. `cancel()` löst die -lokale Bindung, löscht aber weder Subscription noch Rückstand. +Exactly-once-Garantie und keine globale Reihenfolge. +Fachliche Seiteneffekte benötigen eine stabile fachliche Idempotenz-ID; Wiederzustellungen behalten zusätzlich dieselbe `messageId`. [geändert] + +`subscribe()` bindet eine benannte Subscription und prüft ihren Vertrag. +Eine neu angelegte Subscription empfängt erst Nachrichten ab Erstellung ihrer +Bindung. Ein bestehender 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; `cancel()` beendet nur den lokalen Consumer, +löscht aber weder Subscription noch Rückstand. [neu] ## § 3 Abstraktionsschichten und Erweiterungspunkte | Baustein | Verantwortung | |---|---| -| `ConnectionFactory` / `ConnectionOptions` | Alternative Erzeugung von `PhoreMQ` und gemeinsame Konfiguration; dieselbe DSN-Auflösung wie im Konstruktor | +| `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` | Bytes publizieren/empfangen, Receipt bestätigen/freigeben, Fähigkeiten melden | +| `ConnectorInterface` | RabbitMQ kapseln: Topologie prüfen/anlegen, Bytes senden/empfangen und Zustellungen abschließen | | `PayloadStoreInterface` | Streams ablegen, Referenzen auflösen, Lebensdauer verwalten | -| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen | +| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen [geändert] | Sendepfad: Typ/Topic auflösen → Send-Middleware ausführen → Daten normalisieren -und ggf. validieren → Dateien ablegen → Envelope kodieren → signieren → Größen-/Capability-Prüfung +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. +die Signaturprüfung noch Settlement umgehen; Details in § 14.3. [geändert] Der Konnektor kennt keine Anwendungs-DTOnamen oder Callbacks. Seine -vorgeschlagenen primitiven Operationen sind `capabilities(): CapabilitySet`, +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. +Ungültige oder bereits erledigte Receipts erzeugen eine Settlement-Exception. [geändert] Ein `MessageSecurityInterface` bietet `protect(string $envelopeBytes, SecurityContext $context): ProtectedFrame` und `verify(ProtectedFrame $frame, @@ -167,178 +161,76 @@ Ein Provider kann auch verschlüsseln; HMAC allein tut dies nicht. ## § 4 Verbinden und DSN-Factory -[Vollständige Beispiele: 01-connect.php](../../examples/api-draft/01-connect.php). -Der normale Einstieg erzeugt unmittelbar das zentrale Objekt; die Varianten -sind Alternativen, nicht mehrere benötigte Verbindungen: +[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. [neu] ```php -use Phore\MessageQueue\PhoreMQ; - -$mq = new PhoreMQ('redis://localhost:6379/0', $options); -// Oder einen bereits konfigurierten Connector injizieren: -$mq = new PhoreMQ($connector, $options); -// Auch mit benannten Argumenten: -$mq = new PhoreMQ(connection: $dsn, options: $options); - -// Gleichwertige Alternative, etwa im DI-Bootstrap: -$factory = new ConnectionFactory(); -$mq = $factory->connect($dsn, $options); // PhoreMQ -$mq = $factory->fromConnector($connector, $options); // PhoreMQ -$mq = $factory->fromAttributes(LocalConnection::class, $options); // PhoreMQ +$mq = new PhoreMQ($dsn, $options); +$mq = new PhoreMQ(new RabbitMQConnector($dsn), $options); +$mq = (new ConnectionFactory())->connect($dsn, $options); ``` -Vorgeschlagene öffentliche Erzeugungssignaturen (Deklarationsauszug, -keine Implementierung): +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. [neu] -```php -// Phore\MessageQueue\PhoreMQ implements MessageQueueInterface -public function __construct( - string|ConnectorInterface $connection, - ?ConnectionOptions $options = null, -); - -// ConnectionFactory -public function connect(string $dsn, ?ConnectionOptions $options = null): PhoreMQ; -public function fromConnector(ConnectorInterface $connector, ?ConnectionOptions $options = null): PhoreMQ; -public function fromAttributes(string $class, ?ConnectionOptions $options = null): PhoreMQ; -``` - -`ConnectorInterface` liegt unter `Phore\MessageQueue\ConnectorInterface`. -Es gibt genau eine Verbindungsangabe: String bedeutet DSN, ein Objekt muss -das Connector-Interface implementieren. Host, Port und Broker-Credentials -kommen aus DSN oder Connector-Konfiguration; Schema, Security, Routing, -Middleware, RPC, Health und Dateispeicher aus `ConnectionOptions`. Sämtliche -Einstellungen werden damit beim Erzeugen übergeben. Es gibt keine parallelen -DSN-/Connector-Felder im Optionsobjekt, keine später notwendigen Setter und -kein zusätzliches `connect()` auf dem MQ-Objekt. - -`null` bedeutet ein frisches Optionsobjekt mit denselben dokumentierten -Defaults für alle Erzeugungswege. Environment und externe Secret-Stores werden -nicht implizit gelesen; der file-Adapter verwaltet ausschließlich seinen -dokumentierten lokalen Schlüssel im gewählten Root. Erforderliche Security-/ -Provider-Konfiguration muss für Netzwerkadapter weiterhin -explizit vorliegen; das lokale file-Profil aus § 4.1 liefert dokumentierte -Entwicklungsvorgaben; fehlende Konfiguration wird nicht durch unsichere Defaults -ersetzt. Konfiguration wird beim Erzeugen validiert und als Snapshot verwendet; -spätere Mutation des Optionsobjekts ändert das laufende MQ nicht. Explizit -zustandsbehaftete injizierte Dienste wie `HealthState` bleiben dagegen geteilt. [geändert] - -Konstruktor und Factory bauen die Verbindung sofort mit begrenztem -Verbindungstimeout auf. Erfolgreiche Rückkehr liefert ein verwendbares -`PhoreMQ`; sie bestätigt noch keine fremden Listener oder nachrichtenspezifische -Bereitschaft (dafür `check`). Beide Wege werfen dieselben Konfigurations-, -DSN-, Verbindungs- und Auth-Exceptions aus § 11. Teilweise geöffnete eigene -Ressourcen werden bei einem Fehler freigegeben. Kein verstecktes Lazy-Connect -mit erst beim ersten Publish auftretendem initialem Verbindungsfehler. - -Eine interne gemeinsame Initialisierung löst DSNs auf, validiert Optionen -und bindet Connector und Dienste genau einmal. Die Factory delegiert an -diesen Erzeugungsweg; der Konstruktor ruft nicht rekursiv die öffentliche -Factory auf. Direkte Connector-Injektion umgeht ausschließlich die DSN- -Auflösung, niemals Security, Codec, Middleware oder Capability-Prüfungen. -Die Factory gibt das `PhoreMQ` selbst zurück, keinen zusätzlichen Wrapper. -Anwendungscode kann für austauschbare Abhängigkeiten weiterhin gegen -`MessageQueueInterface` typisieren. - -Ein MQ-Objekt wird einmal je Verbindung und Prozess erzeugt und für alle -zugehörigen Topics, Registrierungen, RPC und Checks wiederverwendet; kein -globaler Singleton. `close()` ist idempotent und schließt die zugehörigen -Transportressourcen, `stop()` beendet nur den Worker-Loop. Ein an `PhoreMQ` -übergebener Connector steht exklusiv unter dessen Lebenszyklusverwaltung, -auch beim gescheiterten Aufbau; er darf nicht gleichzeitig in ein zweites -MQ-Objekt injiziert werden. Für geteilte In-Memory-Daten erhält jedes MQ einen -eigenen Connector am selben `InMemoryBroker`. Separate RPC-/Health-Verbindungen -bleiben bei den in §§ 13 und 16 beschriebenen Laufzeitanforderungen nötig. - -`fromAttributes` liest genau eine lokal angegebene Klasse mit -`#[QueueConnection(dsn: ...)]`; kein automatisches Scannen des Dateisystems. -Die gemeinsame DSN-Auswertung verwendet eine Schema-Allowlist und erzeugt -niemals beliebige PHP-Klassen aus URL-Inhalten. Eigene Provider können lokal -an der Factory über `registerConnectorFactory(scheme, factory)` registriert -werden. Diese Registrierung verändert keine globale Registry: der einfache -Konstruktor kennt nur die freigegebenen Standard-Schemes; für eigene Schemes -nutzt man die konfigurierte Factory oder injiziert den Connector direkt. -Unbekannte Schemes/Optionen werden in beiden Wegen abgelehnt. - -Vorgesehene spätere Contract-Tests: gleicher konkreter Rückgabetyp und -Funktionsumfang, gleiche Defaults/Exceptions/Sicherheitskette, einmaliger -Verbindungsaufbau, Ressourcenfreigabe bei Teilfehlern, exklusives Connector- -Ownership und idempotentes `close`. In diesem PR bleibt dies API-Entwurf. - -| Vorgeschlagene DSN | Bedeutung | +| DSN | Bedeutung | |---|---| -| `redis://user:password@host:6379/0?prefix=app` | Redis Streams, ACL-Zugang; `/0` ist Datenbank | -| `rediss://user:password@host:6380/0` | Redis über TLS mit Zertifikatsprüfung | -| `redis://:password@host:6379/0` | Redis-Passwort ohne ACL-Benutzer | -| `redis+unix:///run/redis/redis.sock?db=0` | Redis-Server über Unix-Socket, weiterhin Redis-Protokoll | -| `file:///tmp/phore-mq-demo` | Geplanter lokaler Entwicklungsadapter mit SQLite-Datei, Reply-/Failure-/Attachment-Store; Details § 4.1 [neu] | -| `memory://` | Isolierter In-Memory-Broker je MQ-Erzeugung | -| `unix:///run/user/1000/phore-mq.sock` | Eigenes lokales MQ-Protokoll, benötigt separaten Dev-Broker | -| `sqs://eu-central-1/123456789012` | Geplanter Queue-Adapter; logische Topics per Routingtabelle auf Queue-URLs abbilden | -| `sns+sqs://eu-central-1/123456789012` | Geplanter Topic-Fan-out; SNS-ARNs und Subscription-Queues aus Routingtabelle | -| `azure-servicebus://namespace.servicebus.windows.net` | Geplanter Service-Bus-Adapter mit Topic-/Subscription-Bindings | -| `amqp://user:password@host:5672/vhost` | Geplanter RabbitMQ-Adapter; TLS über `amqps` | - -Diese Schemes sind Library-Konventionen, keine Zusage bereits vorhandener -Treiber. Benutzername, Passwort und Token vor `@` werden einmal percent-dekodiert; -`@` im Passwort muss `%40` sein. Port, IPv6, Pfad, doppelte Query-Parameter -und Optionswerte werden strikt geprüft. Fehler/Logs redigieren Credentials. -Ein einzelner Key lässt sich für passende Anbieter als Passwort transportieren; -Cloud-Adapter bevorzugen explizit injizierte Credential-Provider für temporäre -Tokens und Managed Identity. Kein implizites Lesen von Environment-Variablen. -Broker-Zugangsdaten und HMAC-Shared-Secret sind getrennte Einstellungen. - -Attribute enthalten höchstens lokale Beispiel-DSNs oder Verbindungsnamen, -keine produktiven Secrets. Für produktive Deployment-Konfiguration ist die -programmatische Konstruktor-/Factory-Konfiguration vorzuziehen. +| `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` [neu] | + +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. [neu] + +`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 und optionalen PayloadStore. Konfiguration wird als +Snapshot übernommen; bewusst geteilte Zustandsobjekte wie `HealthState` +bleiben geteilt. Unbekannte Optionen sind Fehler. [neu] + +`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. [neu] ### § 4.1 Kurzer lokaler Einstieg in den Beispielen -Die Beispiele 02–10 verwenden `new PhoreMQ('file:///tmp/phore-mq-demo')`. -`file` ist ein hier neu vorgeschlagener lokaler Entwicklungsadapter, noch -keine vorhandene Funktion. Er speichert die Queue transaktional in SQLite -unter dem angegebenen Root; `ext-pdo_sqlite` ist erforderlich. Alle Prozesse -eines Demos teilen denselben Root. Unabhängige Demos verwenden frische Roots, -da Subscriptions, Backlog und Fehler Neustarts überleben. Redis Streams bleibt -der erste produktive Adapter; kein NFS-, Netzwerk- oder Multi-Host-Betrieb mit -file und keine Gleichsetzung seiner Last-/Timing-Eigenschaften mit Redis. [neu] - -Dieses explizit gewählte lokale Profil provisioniert Queue/Subscriptions, -FailureStore, Attachment-Store und pro MQ-Instanz einen zufällig eindeutigen -RPC-Rückkanal im eigenen Root. Antworten sind nur an intern registrierte -logische Reply-Ziele dieses Roots zulässig; niemals an beliebige Dateipfade. -Es aktiviert die Schema-Bridge bei installiertem `phore/schema`; wenn eine -benötigte Bridge fehlt, bleibt es bei `MissingDependencyException`. Der -Konstruktor installiert keine Pakete und liest keine Environment-Variablen. -Beispiel 02 ergänzt nur sein Klassenmapping, 06 seine Middleware und 09 seine -Health-Definitionen. Solche fachlich relevanten Optionen bleiben sichtbar. -Explizite Optionswerte überschreiben Profilvorgaben; ausgelassene Felder -behalten die lokalen Vorgaben, auch in partiellen RPC-/Health-Optionsobjekten. [neu] - -Das Root wird nur als privates Verzeichnis desselben OS-Benutzers verwendet -(Verzeichnis 0700, Dateien 0600); fremde Besitzer, unsichere Rechte und -Symlink-Pfade werden abgelehnt statt still übernommen. Ein kryptografisch -zufälliger HMAC-Key wird bei Erstinitialisierung atomar exklusiv angelegt und -persistent gemeinsam verwendet, nicht pro Prozess ersetzt. Alle Frames -verwenden die bestehende Signaturprüfung. Das ist ausschließlich Vertrauen -zwischen lokalen Prozessen desselben Benutzers, keine Dienst-/Mandanten- -Identität. Diese file-spezifische Erzeugung ersetzt keine Secret-Konfiguration -bei Redis oder anderen Netzwerkadaptern. [neu] - -Claims, Versuchszähler, verzögerte Freigabe und Settlement müssen per SQLite- -Transaktion konsistent sein; Handler laufen außerhalb der DB-Transaktion. -Lease-Tokens verhindern Settlement durch veraltete Worker, nach Prozessabbruch -können Leases wieder aufgenommen werden. At least once, Idempotenz, begrenztes -Polling und kurze DB-Lock-Timeouts bleiben notwendig. Fehlerablage erfolgt -transaktional vor/mit Ack, Attachments bleiben separate Dateien mit geprüften -Referenzen. Retention und Bereinigung gelten auch für verwaiste Rückkanäle. -Ohne diese Eigenschaften darf der Adapter keine Durable-Capability melden. [neu] - -Nur Beispiel 01 zeigt vollständige Connection-Optionen und die In-Memory-/ -Unix-Alternativen. Die übrigen Dateien erklären ihr jeweiliges Thema mit dem -kurzen Einstieg; ihre APIs bleiben Entwürfe. Spätere Contract-Tests prüfen -insbesondere zwei Prozesse, Crash/Lease-Recovery, Retry-Zähler, atomare -Fehlerablage, private Pfade/Key-Erzeugungsrennen und eindeutige Rückkanäle. [neu] +[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`: [neu] + +```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. [neu] + +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. [neu] ## § 5 Senden, empfangen und Worker-Lebenszyklus @@ -392,15 +284,14 @@ 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, `startAt`, `ackMode`, `retryPolicy` und -`durability` (Default `Durability::Durable`). Memory/Unix-Tests wählen explizit -`Durability::Volatile`; damit wird keine Haltbarkeit über Prozessneustarts -versprochen. Fehlende angeforderte Haltbarkeit ist ein Capability-Fehler. +`payloadClass` als lokale Zielklasse, `ackMode` und `retryPolicy`. +Fachliche Subscriptions sind immer dauerhaft; es gibt keine Cursor-, Replay- +oder wechselbaren Haltbarkeitsmodi. Ohne Typfilter muss der Array-Handler alle -Nachrichtentypen des Topics verarbeiten können. Nicht passende Typen werden -für diese Subscription bewusst übersprungen und bestätigt; ein separater -Handler darf nicht dieselbe Subscription mit anderem Filter übernehmen. -Filteränderungen benötigen eine neue Subscription oder explizite Migration. +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. [geändert] Die bisherigen Aufrufe `subscribe($topic, $subscription, $handler, $options)` bleiben gültig. Neu ist `subscribe($callback)` bzw. @@ -438,11 +329,10 @@ 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 aufgrund eines Typfilters übersprungener fachlicher Delivery zählt als -abgearbeiteter Versuch. Das Limit ist keine Anzahl erfolgreicher oder +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 nach der bestehenden Lease-/Freigabepolicy behandelt. +bzw. werden beim Schließen des Empfangschannels erneut verfügbar. [geändert] `maxSeconds` ist das Gesamtbudget ab Loop-Start, einschließlich Warten und Verarbeitung. `idleTimeoutSeconds` begrenzt eine zusammenhängende Wartephase @@ -467,16 +357,17 @@ 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. -`context->extendLease(seconds: ...)` ist capabilityabhängig; lange synchrone -Handler müssen aktiv verlängern oder eine ausreichende Lease konfigurieren. +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. [geändert] -Retry kann durch native Redelivery/Visibility oder Adapterlogik erfolgen. +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. +geworfen statt in einer Endlosschleife verborgen. [neu] ## § 6 SDK-Typen, Attribute und strukturelle Kompatibilität @@ -652,58 +543,68 @@ keine Ressourcenerzeugung bei Metadatenfehlern und Cleanup bei Bindefehlern. Beispiel 03 zeigt die erfolgreichen Varianten und erwartete Exceptions; es bleibt ausschließlich API-Entwurf. -## § 7 Redis-Standard und Konnektorvergleich +## § 7 RabbitMQ-Adapter und Konfigurationsabbildung -Die folgende Bewertung ist eine Designableitung aus den verlinkten -Primärquellen, keine Aussage über bereits implementierte Adapter. +Die öffentliche API verwendet generische Begriffe. RabbitMQ-Begriffe erscheinen +nur im Adapter, Deployment und zur Erklärung der konkreten Abbildung. [neu] -| Kandidat | Relevante Fähigkeiten | Konsequenz für dieses Paket | -|---|---|---| -| Redis Streams | Log, Consumer Groups, Pending-Liste, Ack, Claim verwaister Nachrichten | Standard: Stream pro Topic, Gruppe pro Subscription, eindeutiger Consumer pro Worker | -| Redis Pub/Sub | Flüchtige Broadcasts und Patterns; at most once | Optionaler eigener Modus, kein Ersatz für dauerhafte Subscriptions | -| Amazon SQS | Arbeitsqueue; Consumer teilen Nachrichten | `sqs` meldet nur konkurrierende Queue-Verarbeitung; zweite unabhängige Fan-out-Subscription wird abgelehnt | -| Amazon SNS + SQS | Topic-Fan-out in getrennte Queues | Vollständiges Subscription-Modell über SNS-Topic und Queue je Subscription | -| Azure Service Bus | Queues, Topics, dauerhafte Subscriptions und Filter | Geeigneter Cloud-Adapter; Credential-/PHP-Client-Auswahl noch prüfen | -| RabbitMQ | Exchanges/Bindings, Queues, Consumer-Ack und Publisher Confirms | Topic auf Exchange, Subscription auf Queue; AMQP-Protokollversion ausdrücklich festlegen | -| NATS JetStream | Persistente Streams, langlebige Consumer, Ack und Redelivery | Späterer Adapter, Core NATS nicht mit JetStream gleichsetzen | -| In-Memory | Prozessinterne kontrollierte Zustellung | Frühes Testwerkzeug, kein Ersatz für Brokerintegrationstests | -| file (geplanter lokaler SQLite-Adapter) | Gemeinsamer Root für lokale Prozesse, Claims/Leases und Fehlerablage | Kurzer Entwicklungs-Einstieg gemäß § 4.1; kein Multi-Host-/NFS-Backend [neu] | -| Unix-Socket | Lokaler Byte-Transport | Benötigt Dev-Broker für Routing, Gruppen und Receipts; keine Queue allein durch Socket/Semaphore | - -Redis benötigt getrennte Empfangs-/Publish-Verbindungen, begrenztes Blocking -und eindeutige Consumer-IDs. `XREADGROUP` liefert neue Nachrichten; Pending- -Recovery über `XAUTOCLAIM` und Ack über `XACK`. Ein Ack darf den Stream-Eintrag -nicht global löschen, solange andere Subscriptions ihn brauchen. -Aufbewahrungsregeln berücksichtigen langsame Gruppen und Pending-Einträge; -aggressives `MAXLEN` kann noch benötigte Daten entfernen. Redis-Persistenz, -Replikation und Eviction-Policy sind Betriebsentscheidungen und bestimmen -die tatsächliche Haltbarkeit. Der Adapter muss verlorene/ge-trimmte Pending- -Einträge sichtbar melden und darf sie nicht als erfolgreich verarbeitet werten. - -`capabilities()` beschreibt mindestens durableSubscriptions, competingConsumers, -acknowledgements, retry, deadLetter, leaseExtension, replay, delayedPublish, -ordering, filtering und maxFrameBytes. Zusätzliche Optionen werden nur bei -Unterstützung akzeptiert; etwa Delay, Priorität, FIFO und Transaktionen sind -keine universellen Versprechen. Transportgrößen werden inklusive Envelope, -Signatur, Encoding und Anbieter-Metadaten bewertet, nicht allein am Payload. - -### § 7.1 Was andere PHP-Abstraktionen bereits vorsehen - -Symfony Messenger zeigt DSN-Transports, Handler-Attribute, Envelopes/Middleware, -Retry/Failure-Transports, Worker-Limits, In-Memory-Tests und optionale -Message-Signierung. PHP Enqueue zeigt Connection-Factory, Context, -Producer/Consumer und explizite Acknowledgements. Daraus übernehmen wir eine -kleine öffentliche API, separate Transportverträge und einen klaren -Fehler-/Worker-Lebenszyklus. Das Paket wird dadurch kein Framework und -benötigt weder Symfony-Servicecontainer noch automatische Handler-Suche. +| Ö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` | 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 [neu] | + +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. [neu] + +`publish` verwendet persistente Frames, Publisher Confirms und `mandatory`. +Eine nicht routbare Nachricht wirft `UnroutableMessageException`; 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. [neu] + +`subscribe` validiert/anlegt Exchange, Queue und Binding gemäß `autoCreate`. +Identische Definitionen sind wiederholbar. Abweichende Typfilter, Queue-Eigenschaften +oder Namensbindungen werfen `TopologyConflictException`; 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. [neu] + +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). [neu] + +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. [neu] + +### § 7.1 Was andere PHP-Abstraktionen bereits vorsehen [gelöscht] ## § 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; die persistente -lokale Key-Erzeugung des file-Profils ist in § 4.1 geregelt. Kein pro Prozess +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. [geändert] @@ -730,9 +631,9 @@ 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, Retry- und Replay-Fenster passen; kein pauschales Fünf-Minuten- +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. +Signatur. Broker-Versuchszähler gehören nicht zum unveränderlichen Envelope. [geändert] Signierung verhindert Replay allein nicht. Eine optionale Inbox speichert `(audience, subscription, messageId)` mit Zuständen processing/completed und @@ -740,7 +641,7 @@ 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 Redis-Deduplication. +Anwendungs-/Transaktionsintegration, nicht nur eine transportseitige Duplikaterkennung. [geändert] Ungültige Signaturen werden ohne Callback quarantänisiert oder nach expliziter Policy verworfen, niemals endlos wiederholt. Quarantäne speichert begrenzte @@ -754,9 +655,7 @@ Broker-ACLs und sicherer Dateispeicher bleiben zusätzlich erforderlich. [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. Dieses -Claim-Check-Verfahren ist auch bei AWS/Azure beschrieben. Binärdaten werden -nicht unbeschränkt base64-kodiert in Redis/SQS geschoben. +`PayloadStoreInterface` übernimmt Upload und spätere Auflösung. Binärdaten werden nicht unbeschränkt base64-kodiert in die Queue geschrieben. [geändert] 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 @@ -775,41 +674,28 @@ 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 lokale FileStore funktioniert nur bei gemeinsam zugänglichem Dateisystem; -für mehrere Hosts braucht es etwa S3 oder Azure Blob. Begrenzungen gelten +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: Memory, Redis-Socket und Dev-Broker - -Die konkreten Verbindungsbeispiele stehen zentral in -[01-connect.php](../../examples/api-draft/01-connect.php); der neue file-Einstieg -ist in § 4.1 beschrieben. [geändert] - -`memory://` durchläuft denselben Codec, dieselbe Signierung und dieselbe -Schema-Bridge. Es kopiert serialisierte Nachrichten, keine veränderbaren -Objektreferenzen. Zwei unabhängig erstellte Memory-Verbindungen teilen keinen -Broker; für Sender/Empfänger innerhalb eines Tests wird derselbe explizite -`InMemoryBroker` an zwei Konnektoren injiziert. Deterministische Clock und -kontrollierte Redelivery sind nützliche spätere Test-Hooks. - -`redis+unix://` ist die einfache lokale Variante mit echter Redis-Semantik. -`unix://` ist dagegen ein eigener Konnektor: Ein separat gestarteter -`UnixDevBroker` verwaltet Topics, Subscriptions, konkurrierende Consumer und -volatile Pending-Receipts. Vorgeschlagenes Protokoll: begrenzte längenpräfixierte -Frames, Version, Request-ID, Publish, Subscribe, Delivery, Ack und Release; -partielle Reads/Writes, Backpressure und Disconnect müssen behandelt werden. -Nach Disconnect wird nicht bestätigte Arbeit erneut angeboten, solange der -Broker lebt; nach Broker-Neustart ist dessen Arbeitsspeicher verloren. - -Eine Semaphore koordiniert Zugriffe oder signalisiert Zustände, speichert -aber weder Nachrichten noch Abonnements. Sie ist höchstens ein internes -Hilfsmittel. Socketdatei und Elternverzeichnis brauchen passende Zugriffsrechte; -kein weltbeschreibbarer gemeinsamer Pfad, kein Überschreiben fremder Sockets. -Windows-Unterstützung, persistentes Spooling, Clustering und ein eigener -produktiver Broker gehören nicht zur ersten lokalen Implementierung. +`publish`. Ohne Store oder bei zu großem Frame folgt eine eindeutige Exception. [neu] + +## § 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). [neu] + +[setup.py](../../deployment/rabbitmq/setup.py) übersetzt die neutrale +Konfigurationsdatei in RabbitMQ-Deklarationen über dessen HTTP-Management-API. +Es benötigt nur Python 3, keine PHP-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. [neu] ## § 11 Exceptions und Diagnose @@ -823,8 +709,9 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | Exception | Beispiel / Behandlung | |---|---| | `InvalidDsnException` | Ungültiger Port oder unbekannte Option; Konfiguration korrigieren | -| `UnsupportedConnectorException` / `MissingDependencyException` | Treiber oder Schema-Bridge fehlt; vor Workerstart abbrechen | -| `UnsupportedCapabilityException` | Dauerhafter Fan-out mit reinem SQS oder Replay ohne Unterstützung | +| `MissingDependencyException` | RabbitMQ-Client oder benötigte Schema-Bridge fehlt; vor Workerstart abbrechen | +| `TopologyConflictException` / `TopologyVerificationException` | Deklaration widerspricht bestehender Topologie oder kann nicht vollständig geprüft werden | +| `UnroutableMessageException` | Keine passende Subscription für die veröffentlichte Nachricht | | `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 | @@ -837,15 +724,15 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `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` / `LeaseLostException` | Ack fehlgeschlagen/Lease verloren; Duplikate berücksichtigen | -| `FailureStoreException` | Sichere Fehlerablage fehlgeschlagen; kein Ack, Worker abbrechen | +| `SettlementException` | Ack fehlgeschlagen oder Delivery-Channel geschlossen; Duplikate berücksichtigen | +| `FailureStoreException` | Sichere Fehlerablage fehlgeschlagen; kein Ack, Worker abbrechen [geändert] | Validierung meldet konkrete Pfade und erwartete Typen, aber keine sensiblen -Istwerte. Ein unbekannter Typ wird bei explizitem Filter übersprungen; -trifft er einen Handler, der ein registriertes Schema verlangt, ist dies ein -Mappingfehler. Nicht explizit klassifizierte Handler-Exceptions werden +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. +sind keine Nachrichten-Retries. [neu] RPC ergänzt `RequestTimeoutException`, `RemoteCommandException`, `InvalidReplyException` und `RpcNotConfiguredException`. Die lokal vom @@ -860,35 +747,36 @@ Command-Fehler und ändert die Settlement-Entscheidung nicht. 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 nach folgender Policy. [neu] +`Throwable`s an der Handlergrenze ab und entscheidet nach folgender Policy. | Callback-Ergebnis | Standard im Entwurf | |---|---| -| Normale Rückkehr | Ack nach erfolgreicher Verarbeitung [neu] | -| `RetryableMessageException` | Begrenzter Retry mit Verzögerung; kein unendliches Erzwingen [neu] | -| Andere unbehandelte Exception, einschließlich `TypeError` | Ebenfalls begrenzt wiederholen; nach Ausschöpfen sichere Fehlerablage und Alarm [neu] | -| `RejectMessageException` | Sofort endgültig in die Fehlerablage, kein Retry [neu] | -| `CommandFailedException` oder freigegebene `RemoteException` (§ 13.5) in `respond` | Bewusster fachlicher RPC-Fehler: sichere finale Antwort, danach Ack; keine technische Wiederholung [neu] | -| Infrastrukturfehler bei Retry/Ack/FailureStore | Kein vorgetäuschter Erfolg; `run` wirft Infrastruktur-Exception, unbestätigte Nachricht bleibt wiederholbar [neu] | +| 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, mit 1, 2 und 4 Sekunden Verzögerung plus zufälligem Jitter von -±10 %. `context->attempt` beginnt bei 1 und wird dauerhaft je Zustellung an -eine Subscription geführt; Prozessneustart oder ein anderer Worker setzt den -Zähler nicht zurück. Die Policy bleibt über `SubscriptionOptions::retryPolicy` +Wiederholungen, mit 1, 2 und 4 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 Policy bleibt über `SubscriptionOptions::retryPolicy` austauschbar. 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. [neu] +der Job wird verzögert wieder verfügbar. [geändert] 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 lokale file-Adapter hat die Fehlerablage im Profil, -andere Adapter müssen eine verfügbare Ablage konfigurieren. Fehlerdaten +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. [neu] +nach Ursachenklärung mit erhaltenem Bezug und neuer expliziter Retry-Runde. [geändert] Bei technischen RPC-Fehlern wartet der Client über die zulässigen Retries. Nach endgültigem Scheitern sendet die Runtime, soweit Rückkanal und Deadline @@ -899,19 +787,17 @@ 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. [neu] +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 ablaufende -Lease ermöglichen Recovery. Externe Seiteneffekte werden nicht zurückgerollt; +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. [neu] +manuell gesetztes Ack lässt sich durch eine spätere Exception nicht widerrufen. [geändert] -Orientierung: [Symfony Messenger – Retries & Failures](https://symfony.com/doc/current/messenger.html#retries-failures) -trennt verzögerte Wiederholungen, endgültige Fehler und Failure-Transports; -[RabbitMQ – Acknowledgements](https://www.rabbitmq.com/docs/confirms) +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 @@ -921,18 +807,17 @@ ausgefallen, Middleware schluckt/erhält Fehler, RPC-Endfehler und manuelles Ack In die Library gehören Transportvertrag, Registry, Worker-Lebenszyklus, Serialization, optionale Schema-Bridge, Security-/PayloadStore-Schnittstellen -und konsistente Exceptions. Provider-SDKs werden über optionale Adapterpakete -eingebunden; welche davon als eigene Composer-Pakete erscheinen, wird bei -der Implementierungsplanung entschieden. SDK-Verträge lassen sich unabhängig -von Brokerinstallationen verteilen. +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. [geändert] Nicht in den Kern gehören fachliche DTOs, Business-Workflows, vollständige -Job-Scheduler, langfristige Workflow-/RPC-Ergebnisarchive, Broker-Provisionierung über Cloud-IAM, +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. +RPC-Umfang bleibt eine optionale Request/Reply-Erweiterung gemäß § 13. [geändert] Globale Lock-/Konsensverfahren gehören nicht in die MQ-Library. § 15 zeigt Broadcast und das Einsammeln von Lock-Bestätigungen; die tatsächlichen @@ -944,18 +829,14 @@ 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 Socket-Teilverarbeitung. Dieser -Entwurfs-PR fügt keine Laufzeitimplementierung oder Tests dafür hinzu. +Dateiprüfung, konkurrierende Consumer und Verbindungsabbrüche. Dieser +Entwurfs-PR fügt keine Laufzeitimplementierung oder Tests dafür hinzu. [geändert] Primärquellen, abgerufen am 2026-09-12: -- §§ 1, 7: [Redis Pub/Sub und Zustellgarantien](https://redis.io/docs/latest/develop/pubsub/), [XREADGROUP](https://redis.io/docs/latest/commands/xreadgroup/), [XAUTOCLAIM](https://redis.io/docs/latest/commands/xautoclaim/). -- §§ 3, 5, 7.1, 8: [Symfony Messenger: Transports, Retry, Attribute und Signierung](https://symfony.com/doc/current/messenger.html), [PHP Enqueue Quick Tour](https://php-enqueue.github.io/quick_tour/). -- § 7: [SNS-Fan-out an SQS](https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html), [Azure Service Bus: Queues, Topics, Subscriptions](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-queues-topics-subscriptions). -- § 7: [RabbitMQ Exchanges](https://www.rabbitmq.com/docs/exchanges), [Acknowledgements und Publisher Confirms](https://www.rabbitmq.com/docs/confirms), [NATS JetStream Consumers](https://docs.nats.io/learn/jetstream/pull-consumers). -- § 9: [AWS SQS Extended Client und S3](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-managing-large-messages.html), [Azure Claim-Check Pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/claim-check). -- § 10: [PHP stream_socket_server](https://www.php.net/manual/en/function.stream-socket-server.php). -- § 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). +- §§ 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). [neu] + +- § 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). [geändert] ## § 13 RPC: Command, Rückgabewert und Begleitmeldungen @@ -969,16 +850,25 @@ DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. ### § 13.1 Einmalige Konfiguration und Aufruf -`ConnectionOptions::rpc` nimmt `RpcConnectionOptions` entgegen. Der Client -konfiguriert `replyTopic` und `replySubscription` einmal pro aktiver -Client-Instanz; der Server konfiguriert eine `allowedReplyTopics`-Allowlist. -Im lokalen Beispiel dürfen beide auf derselben HMAC-Audience arbeiten. -Anwendungen verwenden eigene Reply-Topics je Instanz, oder einen expliziten -zentralen Demultiplexer; konkurrierende Client-Prozesse dürfen nicht denselben -Reply-Consumer teilen und fremde Antworten wegkonsumieren. Die Rückkanal- -Subscription wird vor Veröffentlichung jeder antwortfähigen Nachricht -bereitgestellt; auch das lokale Korrelationsregister existiert vor Publish, -damit sehr schnelle Antworten nicht verloren gehen. +`ConnectionOptions::rpc` nimmt `RpcConnectionOptions` entgegen. Bei +`enabled: true` erzeugt der Adapter vor dem ersten antwortfähigen Publish ein +zufällig eindeutiges Reply-Topic samt exklusiver, automatisch gelöschter Classic-Reply-Queue +pro Client unter `replyNamespace` (Demo `_phore.rpc`). Das ist eine ausdrücklich +aktivierte Ausnahme zur rein vorab angelegten fachlichen Topologie; passende +Configure-/Read-/Write-Rechte für den reservierten Bereich sind erforderlich, +auch bei `autoCreate: false`. Der Responder akzeptiert ausschließlich erlaubte +Reply-Ziele im konfigurierten Namespace; Zugang und Identität werden zusätzlich geprüft. [neu] + +Der interne Reply-Consumer wird vor Publish eingerichtet, einschließlich des +Korrelationsregisters. Er teilt seine Queue niemals mit anderen Clients. +Der Adapter verwendet reguläre Reply-Queues, kein verlustbehaftetes Direct Reply-to. +Replies können nach Client-Verbindungsabbruch verloren gehen; offene Aufrufe +enden mit Verbindungsfehler oder Timeout. Dies ist kein dauerhaftes RPC-Ergebnisarchiv. +Konfigurierbare feste `replyTopic`/`replySubscription` bleiben für einen expliziten +zentralen Demultiplexer möglich; unabhängige Clients dürfen sie nicht gemeinsam +als konkurrierende Consumer verwenden. Der Server kann `allowedReplyTopics` +zusätzlich auf konkrete Ziele einschränken. Anzahl, Bytes und Lebensdauer des +Rückkanals bleiben begrenzt. [neu] `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. @@ -994,7 +884,7 @@ und einen unabhängig laufenden Responder verwenden. `RequestOptions::responseClass`/`onNotice` liefern lediglich die Anfangswerte für dieselben Await-Einstellungen. `PendingReply` entfällt als separater Rückgabetyp im Entwurf; bestehende `request(...)->await()`-Beispiele bleiben -gültig. [geändert] +gültig. `Reply` besitzt schreibgeschützte `payload`, `metadata` und `notices`. `responseClass` hydriert `payload` strukturell nach § 6, ohne die PHP-Klasse @@ -1132,7 +1022,7 @@ 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. [geändert] +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`, @@ -1146,7 +1036,7 @@ Await-Ausführung fixiert `responseClass`, `errorTypes` und Notice-Callback für 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. [geändert] +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 @@ -1180,7 +1070,7 @@ 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`. [neu] +separate `RequestTimeoutException`. Für typisierte SDK-Fehler ist `#[RemoteError('math.division_by_zero.v1')]` auf einer konkreten Unterklasse von `RemoteException` vorgesehen. @@ -1190,7 +1080,7 @@ für Übertragung freigegebene Fehler. Sein gemeinsamer Konstruktor lautet 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. [neu] +ist bewusst öffentlich; sensible Rohmeldungen dürfen nicht hineinkopiert werden. Der Client erlaubt lokale Klassen mit `await(errorTypes: [DivisionByZero::class])` oder @@ -1201,7 +1091,7 @@ 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. [neu] +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. @@ -1211,7 +1101,7 @@ setzt den Request-Kontext aus der verifizierten Antwort. PHP-FQCN, Trace, `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. [neu] +Responder löst die terminale Fehlerantwort aus. Nur eine ausdrücklich deklarierte `RemoteException` oder die bestehende `CommandFailedException` darf den vorgesehenen sicheren Text exportieren. @@ -1220,13 +1110,13 @@ 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. [neu] +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. [neu] +und wiederholtes await ohne erneute Ausführung des Commands. ## § 14 Metadaten, Middleware und API-Entscheidung @@ -1235,26 +1125,14 @@ 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 Frameworkvergleich und API-Entscheidung +### § 14.1 API-Entscheidung -| Framework / Library | Recherchierter Ansatz | Entscheidung für diese API | -|---|---|---| -| Symfony Messenger | `dispatch`, Handler, Envelope/Stamps, Middleware; `HandledStamp` liefert Ergebnisse ausgeführter Handler, kein automatischer Remote-Rückkanal | Metadaten und Hooks übernehmen; Remote-Warten ausdrücklich durch angehängtes `await()` ausdrücken | -| PHP Enqueue | `sendCommand` mit Reply-Option, Promise/`receive`, `Result::reply` und `ReplyExtension` | Rückkanal vor dem Sendebefehl vorbereiten; das anschließende await löst keine zweite Sendung aus | -| RabbitMQ PHP-Tutorial | Callback-Queue, `reply_to`, `correlation_id`, Duplikatbehandlung | Rückkanal und IDs intern verwalten, nicht in jedem Handler manuell publizieren | -| NATS .NET Client | Explizites `RequestAsync`, Reply-Subject und Responder-Antwort | Verständliche Verben übernehmen; NATS-spezifische Inbox-Haltbarkeit nicht auf alle Broker übertragen | -| MassTransit | Typisierte Requests/Responses, Response-Address, Fault-Nachrichten und Timeouts | Sichere terminale Fehlerantwort und lokale Exception; zusätzliche Client-/Bus-Fabriken im Alltagsaufruf vermeiden | -| Laravel Queues | Job-Middleware um Handler-Ausführung mit Fortsetzungs-Callback | Kleinen Callable-Hook übernehmen, ohne Laravel-Job-Basisklasse und Container | - -**Empfehlung für dieses Paket:** eine Sendemethode `publish` für DTOs oder -explizite Topic-/Typ-/Payload-Angaben, kleine Callbacks und optionales -`await` auf dem Sendeergebnis. Der alltägliche RPC-Aufruf lautet -`publish($command)->await(timeoutSeconds: 5)`, der Dienst verwendet -`respond(...); run()`. Das einmalige Setup verwaltet Rückkanal und Policies; -`request` bleibt eine ausdrückliche RPC-Komfortform. Direkte Laufzeitparameter -und Optionsobjekte sind gleichwertige Zugänge zur selben Konfiguration. -Dies ist die aktualisierte Designentscheidung für die Nutzeranforderungen, -kein behaupteter objektiver Leistungsvergleich der Frameworks. +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. [neu] ### § 14.2 Metadaten außerhalb des fachlichen Payloads @@ -1322,12 +1200,7 @@ 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). -- [PHP Enqueue: Commands, Replies und Promise](https://php-enqueue.github.io/quick_tour/). -- [Symfony Messenger: Envelopes, Middleware und Handler-Ergebnisse](https://symfony.com/doc/current/messenger.html). -- [NATS .NET: Request/Reply und Queue-Gruppen](https://nats.io/blog/nats-dotnet-v2-alpha-release/). -- [MassTransit: Requests, Faults und Timeouts](https://masstransit.massient.com/concepts/requests). -- [Laravel 12: Job-Middleware](https://laravel.com/framework/docs/12.x/queues#job-middleware). +- [RabbitMQ: RPC mit PHP](https://www.rabbitmq.com/tutorials/tutorial-six-php). [neu] ## § 15 An alle Subscriber oder an einen Worker @@ -1344,8 +1217,7 @@ 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 Fan-out-Capability: Redis-Gruppen, RabbitMQ-Queues oder SNS+SQS passen, -ein einzelnes SQS-Queue-Backend kann nicht allen Gruppen Kopien liefern. +die RabbitMQ-Bindungen: Jede unabhängige Subscription besitzt ihre eigene Queue. [geändert] ### § 15.2 Lock-Koordination: alle bekannten Teilnehmer antworten @@ -1396,40 +1268,37 @@ beweist nicht, welcher Teilnehmer tatsächlich den Lock besitzt. [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.** Die Factory +**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. +Payload und die Kennung des verarbeitenden Workers zurück. [geändert] -Die Auswahl erfolgt brokerabhängig anhand verfügbarer Consumer, Credits, -Prefetch und Polling. „Random“ wird hier als „beliebiger verfügbarer Worker, +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. +Verteilungsstrategie benötigt, braucht einen gesonderten Scheduler. [neu] Pro Zustellversuch wird ein Consumer ausgewählt; ein normaler Job wird -nicht an alle Worker kopiert. Bei Crash, verlorenem Ack oder Lease-Ablauf +nicht an alle Worker kopiert. Bei Crash, verlorenem Ack oder Verbindungsabbruch kann derselbe Job dennoch erneut zugestellt werden. Ein pausierter alter -Worker kann nach Lease-Verlust sogar noch weiterlaufen, während ein neuer +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 Lease-Pflege, kritische Aktionen benötigen +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. +Request-Ack. [geändert] ### § 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 Lease-Verlust. -Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. +falsche Teilnehmeridentität und erneute Job-Ausführung nach Verbindungsabbruch. +Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. [geändert] -- [Redis XREADGROUP: Verteilung innerhalb von Consumer-Gruppen](https://redis.io/docs/latest/commands/xreadgroup/). -- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). -- [Redis: begrenzte Lock-Gültigkeit, Ownership und Fencing-Hinweise](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/). +- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). [geändert] Abruf: 2026-09-12; die konkrete API und die Barrierenlogik sind der hier vorgeschlagene Anwendungsentwurf. @@ -1483,10 +1352,10 @@ Exception; `check` untersucht eine bereits erzeugte Connection erneut. |---|---|---| | 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 Capability/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 oder veraltete Redis-Gruppen allein reichen nicht | +| 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 | +| 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 [geändert] | Ein erfolgreicher Health-Roundtrip beweist den Health-Pfad. Er beweist nicht automatisch den fachlichen Publish-Pfad, dessen Berechtigungen oder die @@ -1544,10 +1413,10 @@ 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 Lease-/Retry-Policy verzögert freigegeben oder +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. +und die bestehenden Retry-Grenzen bleiben wirksam. [geändert] ### § 16.4 Health-Kanal, Ausfälle und Authentifizierung @@ -1690,12 +1559,10 @@ 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ärquellen: [Redis PING](https://redis.io/docs/latest/commands/ping/) für -den eng begrenzten Verbindungsnachweis und [RabbitMQ Monitoring](https://www.rabbitmq.com/docs/monitoring) +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. - +Entwurf, kein behaupteter branchenweiter Standard. [neu] ### § 16.8 Deklarierte Abhängigkeiten und Listenerdiagnose diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..5aa2175 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,202 @@ +# 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 Python-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 Python 3. Alle Befehle laufen aus dem +Repository-Verzeichnis: + +```bash +docker compose -f deployment/rabbitmq/compose.yaml up -d --wait +python3 deployment/rabbitmq/setup.py +``` + +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.py` 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 der geplanten Library die dynamische Anlage fachlicher Ressourcen; 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.py` 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 Python-Skript ist bereits vorhanden: + +| Vorgang | Bereits verwendbares Setup | Geplante PHP-Library | +|---|---|---| +| Fachliche Topologie vorher anlegen | Konfiguration bearbeiten, `setup.py` 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 Python-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 +python3 deployment/rabbitmq/setup.py --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) diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php index f3a7b39..7028b0f 100644 --- a/examples/api-draft/01-connect.php +++ b/examples/api-draft/01-connect.php @@ -4,185 +4,63 @@ namespace Examples\MessageQueue\Connections; -use Phore\MessageQueue\SubscriptionOptions; -use Phore\MessageQueue\Security\UnsignedSecurity; -use Phore\MessageQueue\RunOptions; -use Phore\MessageQueue\MessageContext; -use Phore\MessageQueue\Durability; -use Phore\MessageQueue\Development\UnixDevBroker; -use Phore\MessageQueue\Connector\InMemory\InMemoryConnector; -use Phore\MessageQueue\Connector\InMemory\InMemoryBroker; -use Phore\MessageQueue\Attribute\QueueConnection; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; use Phore\MessageQueue\ConnectionFactory; use Phore\MessageQueue\ConnectionOptions; -use Phore\MessageQueue\Connector\Redis\RedisConnection; -use Phore\MessageQueue\Connector\Redis\RedisStreamsConnector; +use Phore\MessageQueue\Connector\RabbitMQ\RabbitMQConnector; use Phore\MessageQueue\PhoreMQ; -use Phore\MessageQueue\ConnectorInterface; -use Phore\MessageQueue\Schema\PhoreSchemaMapper; use Phore\MessageQueue\Security\HmacSecurity; -use Phore\MessageQueue\Security\MessageSecurityInterface; /** * API-ENTWURF: Die importierten MQ-Klassen existieren noch nicht. - * Siehe ../../docs/proposals/2026-09-12-message-queue-api.md, §§ 3–4, 8. - * Nach Implementierung lädt die Anwendung ihren Composer-Autoloader und ruft - * genau eine Verbindungsvariante auf. Secrets kommen vom Aufrufer. + * Der Docker-Broker und setup.py sind unabhängig davon verwendbar: docs/setup.md. + * RabbitMQ ist der einzige Adapter. Keine Treiberregistrierung oder Brokerwahl. */ -function optionsForDevelopment(string $sharedSecret): ConnectionOptions -{ - return new ConnectionOptions( - security: new HmacSecurity( - sharedSecret: $sharedSecret, - keyId: 'development-1', - audience: 'user-services-development', - ), - schemaMapper: new PhoreSchemaMapper(), // Optional; weglassen für reine Arrays. - autoCreate: true, // Nur Entwicklung: Topics/Subscriptions vor Publish anlegen. - ); -} - -// 1. Normalfall: new PhoreMQ mit DSN und Optionen. Sonderzeichen percent-encoden. -function connectByUrl(string $username, string $password, string $sharedSecret): PhoreMQ -{ - $dsn = 'redis://' . rawurlencode($username) . ':' . rawurlencode($password) - . '@127.0.0.1:6379/0?prefix=demo'; - - return new PhoreMQ(connection: $dsn, options: optionsForDevelopment($sharedSecret)); -} - -// 2. Direkter Konnektor: dieselbe gemeinsame API und Sicherheitskette. -function connectDirectly(string $username, string $password, string $sharedSecret): PhoreMQ -{ - $connector = new RedisStreamsConnector(new RedisConnection( - host: '127.0.0.1', - port: 6379, - database: 0, - username: $username, - password: $password, - prefix: 'demo', - )); - - return new PhoreMQ($connector, optionsForDevelopment($sharedSecret)); -} - -// 3. Factory als gleichwertige Alternative: Rückgabe ist ebenfalls PhoreMQ. -function connectByFactory(string $dsn, string $sharedSecret): PhoreMQ -{ - return (new ConnectionFactory())->connect($dsn, optionsForDevelopment($sharedSecret)); -} - -function connectConnectorByFactory(ConnectorInterface $connector, string $sharedSecret): PhoreMQ +// 1. Gemeinsame Konfiguration: DSN, autoCreate und explizit unsignierter Demo-Modus. +function connectDemo(): PhoreMQ { - return (new ConnectionFactory())->fromConnector($connector, optionsForDevelopment($sharedSecret)); + return new PhoreMQ(...demoConnection()); } -// 4. Attribut-Konfiguration einer lokalen Verbindung; keine Secrets im Attribut. -#[QueueConnection(dsn: 'redis://127.0.0.1:6379/0?prefix=demo')] -final class LocalConnection +// 2. Direkter Konstruktor mit allen relevanten Produktionsoptionen. +// Zugangsdaten und Signierschlüssel liefert die Anwendung, keine implizite Env-Suche. +function connectConfigured(string $username, string $password, string $sharedSecret): PhoreMQ { -} - -function connectWithAttribute(string $sharedSecret): PhoreMQ -{ - return (new ConnectionFactory())->fromAttributes( - LocalConnection::class, - optionsForDevelopment($sharedSecret), - ); -} + $dsn = 'amqps://' . rawurlencode($username) . ':' . rawurlencode($password) + . '@mq.example.org:5671/app'; // /app = namespace; Sonderzeichen percent-encoden. -// 5. Austauschbarer Security-Provider: z. B. später eigener PGP-Provider. -// Der Provider muss Topic/Audience, Keyring und Zeitprüfung implementieren. -function connectWithSecurityProvider(string $dsn, MessageSecurityInterface $security): PhoreMQ -{ return new PhoreMQ($dsn, new ConnectionOptions( - security: $security, + autoCreate: false, // Fachliche Topologie vorher einrichten. + managementUrl: 'https://mq.example.org:15671', // Explizite Topologieprüfung. + maxInFlight: 1, // Höchstens eine offene Zustellung pro Consumer, keine Parallelitätszahl. + security: new HmacSecurity( + sharedSecret: $sharedSecret, + keyId: 'application-1', + audience: 'user-services', + ), )); } -// Eine dieser Varianten EINMAL im Bootstrap aufrufen und das erhaltene Objekt -// für publish/subscribe/request/respond/check/run weiterreichen (z. B. per DI). -// Die Beispiele 02–10 verwenden den einfachen Konstruktor; die Factory liefert denselben Typ. -// Gegen MessageQueueInterface typisierte Anwendungskomponenten bleiben möglich. -// Kein Singleton; der Connector gehört exklusiv zu dieser MQ-Instanz. -// Konstruktor/Factory verbinden sofort; Fehler werfen die Exceptions aus § 11. -// Jede verwendete MQ-Instanz anschließend in finally mit $mq->close() freigeben. - -// 6. Einfacher lokaler Einstieg für Beispiele 02–10 (geplanter file-Adapter). -// Keine Netzwerkdienste oder zusätzlichen ConnectionOptions nötig. -// SQLite-basierte Queue für Prozesse desselben lokalen Benutzers; ext-pdo_sqlite nötig. -// Das Verzeichnis enthält Queue, Fehlerablage, Attachments und einen persistenten -// lokalen HMAC-Key. Private Rechte werden geprüft; unsichere bestehende Pfade abgelehnt. -// Unique Reply-Endpunkte pro Instanz, Auto-Provisionierung nur innerhalb dieses Roots. -// Schema-Bridge wird genutzt, wenn phore/schema installiert ist; DTO-Nutzung ohne -// verfügbare Bridge scheitert weiterhin ausdrücklich. Keine Installation im Konstruktor. -// Alle zusammengehörigen Prozesse verwenden denselben Root; unabhängige Demos einen -// frischen Root wählen. Kein NFS-/Multi-Host-/Produktionsbroker; Redis bleibt Standard. -function connectLocal(): PhoreMQ +// 3. Direkte Adapter-Injektion hält die Interface-Grenze prüfbar. +function connectDirectly(): PhoreMQ { - return new PhoreMQ('file:///tmp/phore-mq-demo'); -} - -// Die folgenden detaillierten lokalen Verbindungsvarianten sind hier zentralisiert. -// In einem Test teilen zwei Connections denselben prozessinternen Broker. -// Auch dieser Konnektor nutzt Codec/Envelope statt PHP-Objektreferenzen. -function inMemoryDemo(): void -{ - $broker = new InMemoryBroker(); - $factory = new ConnectionFactory(); - $options = new ConnectionOptions(security: new UnsignedSecurity(), autoCreate: true); - $sender = $factory->fromConnector(new InMemoryConnector($broker), $options); - $receiver = $factory->fromConnector(new InMemoryConnector($broker), $options); - - try { - $receiver->subscribe('users', 'local-users', function (array $data): void { - printf("Memory: %s\n", $data['userId']); - }, new SubscriptionOptions(durability: Durability::Volatile)); - $sender->publish('users', 'user.created.v1', ['userId' => 'local-1']); - // Höchstens 1 Zustellversuch(e) insgesamt oder 1 s Gesamtbudget; erstes Limit gewinnt. - // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. - $receiver->run(new RunOptions(maxMessages: 1, maxSeconds: 1)); - } finally { - $sender->close(); - $receiver->close(); - } + [$dsn, $options] = demoConnection(); + return new PhoreMQ(new RabbitMQConnector($dsn), $options); } -// Eigener Prozess A: Dev-Broker starten. Elternverzeichnis muss privat sein. -// Volatil: Neustart des Brokers verliert gespeicherte Nachrichten/Subscriptions. -function runUnixBroker(string $socketPath): void +// 4. Optionale Factory ist nur ein Konstruktor-Helfer für dasselbe PhoreMQ. +function connectByFactory(): PhoreMQ { - $broker = new UnixDevBroker(socketPath: $socketPath, socketMode: 0600); - try { - $broker->run(); - } finally { - $broker->close(); - } -} - -// Prozess B (Receiver) und C (Sender) können dieselbe lokale DSN verwenden. -// Der Receiver muss seine Subscription anlegen, bevor der Sender publiziert. -function unixClientDemo(string $socketDsn): void -{ - // Beispiel: unix:///run/user/1000/phore-mq.sock - // Bewusst unsigniert nur für isolierte lokale Entwicklung. - $mq = (new ConnectionFactory())->connect($socketDsn, new ConnectionOptions( - security: new UnsignedSecurity(), - autoCreate: true, - )); - try { - $mq->subscribe('local', 'local-worker', function (array $data): void { - printf("Unix: %s\n", $data['value']); - }, new SubscriptionOptions(durability: Durability::Volatile)); - $mq->publish('local', 'ping.v1', ['value' => 'hello']); - // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. - // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. - $mq->run(new RunOptions(maxMessages: 1, maxSeconds: 5)); - } finally { - $mq->close(); - } + return (new ConnectionFactory())->connect(...demoConnection()); } -// Alternative mit echter Redis-Semantik, ohne eigenen Dev-Broker: -// $factory->connect('redis+unix:///run/redis/redis.sock?db=0', $options); +// 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 index df44493..815772d 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\Programmatic; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\MessageValidationException; @@ -14,8 +18,8 @@ /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 5–6 und 11. * Beispielaufruf nach Implementierung: demo(). - * Dafür ein frisches lokales Queue-Verzeichnis verwenden: zwei neue Subscriptions werden - * vor dem Publish gebunden. Bei wiederverwendetem Verzeichnis kann Backlog anliegen. + * Dafür ein frischen Demo-Namespace verwenden: zwei neue Subscriptions werden + * vor dem Publish gebunden. Bei wiederverwendetem Namespace kann Backlog anliegen. */ // Beliebige eigene Klasse: kein gemeinsames SDK und keine Attribute notwendig. @@ -30,7 +34,7 @@ function demo(): void $registry = new MessageRegistry(); $registry->register('user.created.v1', LocalUserCreated::class, topic: 'users'); - $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(registry: $registry)); + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(registry: $registry))); // Nur das hier gezeigte Klassenmapping ergänzen; Verbindung siehe 01-connect.php. try { diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index 53d7375..a49d23b 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\Attributes; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\Attribute\MessageType; use Phore\MessageQueue\Attribute\Subscribe; use Phore\MessageQueue\PhoreMQ; @@ -62,7 +66,7 @@ function send(MessageQueueInterface $mq): void function demo(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); @@ -77,13 +81,13 @@ function demo(): void // Getrennte Prozesse: Empfänger legt/bindet Subscriptions vor dem ersten Senden // an und ruft run() auf. Sender ruft danach send() auf seiner eigenen Connection -// auf. Beide verwenden dasselbe lokale Queue-Verzeichnis (Vorgaben in 01-connect.php). +// auf. Beide verwenden denselben RabbitMQ-Namespace (Vorgaben in 01-connect.php). // Alternative zur Attributregistrierung: nur den Callback übergeben. // Auf einer eigenen MQ-Instanz statt demo()/registerHandlers() ausführen. function demoCallback(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { $mq->subscribe(function (T_UserCreated $user, MessageContext $context): void { printf("Callback: %s / %s\n", $context->messageId, $user->email); @@ -120,7 +124,7 @@ public function onEntry(T_AuditEntry $entry): void function demoMultipleTopics(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { $handler = static function (T_AuditEntry $entry): void { printf("Audit: %s\n", $entry->text); diff --git a/examples/api-draft/04-files-and-local.php b/examples/api-draft/04-files-and-local.php index 5f5f347..02383bb 100644 --- a/examples/api-draft/04-files-and-local.php +++ b/examples/api-draft/04-files-and-local.php @@ -4,8 +4,14 @@ namespace Examples\MessageQueue\FilesAndLocal; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Attachment; +use Phore\MessageQueue\ConnectionOptions; +use Phore\MessageQueue\PayloadStoreInterface; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\RunOptions; @@ -13,14 +19,15 @@ /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 8–10. - * ZIP-Transport per Dateireferenz; In-Memory/Unix-Verbindungen stehen in 01-connect.php. + * ZIP-Transport per Dateireferenz; RabbitMQ-Verbindung steht in 01-connect.php. * Eingabe-/Ausgabepfad kommen vom Aufrufer, keine Environment-Reads. */ -function zipDemo(string $zipPath, string $outputPath): void +function zipDemo(string $zipPath, string $outputPath, PayloadStoreInterface $payloadStore): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); - // Lokaler Attachment-Store gehört zum file-Profil; Konfiguration siehe 01-connect.php. + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(payloadStore: $payloadStore))); + // Ein von Sender und Empfänger erreichbarer Dateispeicher wird ausdrücklich injiziert. + // RabbitMQ transportiert nur die verifizierte Referenz; kein eingebauter Dateispeicher. try { $mq->subscribe('exports', 'archive-importer', function (array $data, MessageContext $context) use ($outputPath): void { diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php index 1c6a7c6..71a8bb8 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\Rpc; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Attribute\Respond; use Phore\MessageQueue\Attribute\RemoteError; @@ -23,8 +27,8 @@ /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 13–14. * Nach Implementierung: zuerst runServer() in Prozess A starten, - * dann runClient() in Prozess B. Beide nutzen dasselbe lokale Queue-Verzeichnis. - * Der file-Adapter vergibt pro Client einen eigenen Rückkanal; Details in 01-connect.php. + * dann runClient() in Prozess B. Beide nutzen denselben RabbitMQ-Namespace. + * Der RabbitMQ-Adapter vergibt pro Client einen eigenen Rückkanal; Details in 01-connect.php. */ #[MessageType('math.divide.v1', topic: 'calculator')] @@ -76,7 +80,7 @@ public function divide(array $params, RequestContext $request): array function runServer(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { $mq->respond('calculator', 'calculator-workers', [new DivideHandler(), 'divide'], new SubscriptionOptions(type: 'math.divide.v1')); @@ -99,7 +103,7 @@ function runServer(): void function runClient(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { // Publish erkennt das DTO und übernimmt Topic/Typ. Sendet sofort. $sent = $mq->publish(new Divide(12, 3)); diff --git a/examples/api-draft/06-metadata-middleware.php b/examples/api-draft/06-metadata-middleware.php index d8c50be..dcb976e 100644 --- a/examples/api-draft/06-metadata-middleware.php +++ b/examples/api-draft/06-metadata-middleware.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\Metadata; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\RejectMessageException; @@ -17,16 +21,16 @@ /** * API-ENTWURF, noch nicht ausführbar. Proposal § 14. * Zwei optionale Callable-Hooks, keine Middleware-Basisklasse erforderlich. - * Beispielaufruf: demo($traceId), mit frischem lokalen Queue-Verzeichnis. + * Beispielaufruf: demo($traceId), mit frischem Demo-Namespace. */ function demo(string $traceId): void { // Separate Connection ohne Diagnose-Middleware verhindert Fehlerschleifen. - $diagnostics = new PhoreMQ('file:///tmp/phore-mq-demo'); + $diagnostics = new PhoreMQ(...demoConnection()); try { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions( + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions( sendMiddleware: [ // $next: callable(OutgoingMessage): PublishReceipt static function (OutgoingMessage $message, callable $next) use ($traceId): PublishReceipt { @@ -63,7 +67,7 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ } }, ], - )); + ))); try { $diagnostics->subscribe('diagnostics', 'diagnostic-viewer', static function (array $notice, MessageContext $context): void { diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php index ed1298c..058dcdb 100644 --- a/examples/api-draft/07-broadcast-locking.php +++ b/examples/api-draft/07-broadcast-locking.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\BroadcastLocking; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Exception\RejectMessageException; use Phore\MessageQueue\MessageContext; @@ -36,7 +40,7 @@ public function release(string $resource, string $roundId, int $leaseUntilUnix): function runParticipant(string $participantId, LocalLeaseManager $locks): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { // ENTSCHEIDEND: Jede erwartete Instanz hat einen ANDEREN Subscription-Namen. $mq->subscribe('maintenance.locks', 'locks-' . $participantId, @@ -113,7 +117,7 @@ function withAllLocks(array $participants, callable $criticalSection): void 'leaseUntil' => $leaseUntil, ]; - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { // Vor dem Broadcast binden, damit auch sofortige Antworten erfasst werden. $mq->subscribe('maintenance.replies.coordinator-demo', 'lock-coordinator', diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php index 4524650..a308ed8 100644 --- a/examples/api-draft/08-processing-workers.php +++ b/examples/api-draft/08-processing-workers.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\ProcessingWorkers; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\RequestContext; @@ -14,12 +18,12 @@ * API-ENTWURF, noch nicht ausführbar. Proposal § 15.3. * Prozesse A/B/C: runWorker( 'worker-1'/'worker-2'/'worker-3'). * Danach Prozess D: submitJobs(). - * Alle Connections nutzen dasselbe lokale Queue-Verzeichnis. Ein Reply-Endpunkt pro Client. + * Alle Connections nutzen denselben RabbitMQ-Namespace. Ein Reply-Endpunkt pro Client. */ function runWorker(string $workerId): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { // ENTSCHEIDEND: Alle Worker verwenden exakt dieselbe Subscription. // $workerId NICHT an 'text-processors' anhängen, sonst entsteht Fan-out! @@ -46,7 +50,7 @@ static function (array $job, RequestContext $request) use ($workerId): array { function submitJobs(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { $pending = []; foreach ([' erster Job ', ' zweiter Job ', ' dritter Job '] as $text) { @@ -75,6 +79,6 @@ function submitJobs(): void } } -// Crash/Lease-Ablauf/Ack-Verlust können eine erneute Zustellung verursachen. +// 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 index 101c9d8..6ec1246 100644 --- a/examples/api-draft/09-system-check.php +++ b/examples/api-draft/09-system-check.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\SystemCheck; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\RetryableMessageException; @@ -48,7 +52,7 @@ function runExportWorker( ), topic: 'jobs.export', type: 'export.create.v1'); $refresh($state); - $mq = new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(health: new HealthOptions( + $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(health: new HealthOptions( state: $state, refresh: $refresh, refreshIntervalSeconds: 5, @@ -59,7 +63,7 @@ function runExportWorker( '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 { @@ -82,7 +86,7 @@ static function (array $parameters) use ($processExport, $state, $denied): array function frontendConnection(string $instanceId): MessageQueueInterface { - return new PhoreMQ('file:///tmp/phore-mq-demo', new ConnectionOptions(health: new HealthOptions( + 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. @@ -96,7 +100,7 @@ function frontendConnection(string $instanceId): MessageQueueInterface subscriptions: ['audit-service', 'mail-service'], ), ], - ))); + )))); } function checkAtLogin(MessageQueueInterface $mq): array diff --git a/examples/api-draft/10-callback-errors.php b/examples/api-draft/10-callback-errors.php index 7839518..6835f6e 100644 --- a/examples/api-draft/10-callback-errors.php +++ b/examples/api-draft/10-callback-errors.php @@ -4,6 +4,10 @@ namespace Examples\MessageQueue\CallbackErrors; +require_once __DIR__ . '/connection.php'; + +use function Examples\MessageQueue\demoConnection; + use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\SubscriptionOptions; @@ -14,12 +18,12 @@ /** * API-ENTWURF, noch nicht ausführbar. Verbindungsvorgaben: 01-connect.php. - * demo() mit einem frischen lokalen Queue-Verzeichnis aufrufen. + * demo() mit einem frischen Demo-Namespace aufrufen. * Dieses Beispiel verändert keine externen Daten; echte Jobs brauchen Idempotenz. */ function demo(): void { - $mq = new PhoreMQ('file:///tmp/phore-mq-demo'); + $mq = new PhoreMQ(...demoConnection()); try { $mq->subscribe('jobs.demo', 'demo-workers', function (array $job, MessageContext $context): void { if (!is_string($job['mode'] ?? null)) { @@ -45,14 +49,14 @@ function demo(): void $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'unexpected']); // Vorgeschlagener Standard: 1 erster Versuch + höchstens 3 Wiederholungen, - // mit 1/2/4 Sekunden Verzögerung und ±10 % Jitter. Kein enger Requeue-Loop. + // mit 1/2/4 Sekunden Verzögerung und 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); - // file-Profil speichert endgültige Fehler im lokalen FailureStore (siehe 01). + // 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: diff --git a/examples/api-draft/connection.php b/examples/api-draft/connection.php new file mode 100644 index 0000000..f1783e3 --- /dev/null +++ b/examples/api-draft/connection.php @@ -0,0 +1,28 @@ + Date: Sat, 12 Sep 2026 11:30:56 +0200 Subject: [PATCH 11/16] build: require PHP 8.5 and replace Python setup with PHP --- .ai-usage-info.md | 4 +- AGENTS.md | 7 + README.md | 6 +- composer.json | 2 +- deployment/rabbitmq/setup.php | 173 ++++++++++++++++++ deployment/rabbitmq/setup.py | 106 ----------- docs/message-queue-basics-101.md | 2 +- .../proposals/2026-09-12-message-queue-api.md | 135 +++++++------- docs/setup.md | 18 +- examples/api-draft/01-connect.php | 2 +- examples/api-draft/connection.php | 2 +- 11 files changed, 269 insertions(+), 188 deletions(-) create mode 100644 AGENTS.md create mode 100644 deployment/rabbitmq/setup.php delete mode 100644 deployment/rabbitmq/setup.py diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 8cf307d..2ac4e75 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -9,7 +9,7 @@ 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. Docker Compose und das Python- +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. Vorgeschlagener Namespace: `Phore\MessageQueue`; keine implementierten @@ -57,3 +57,5 @@ Ein Broker-Ping allein beweist keinen bereiten Handler. - [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). 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 31b5249..19499a9 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ 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. Docker Compose und das Python- +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 -python3 deployment/rabbitmq/setup.py +php deployment/rabbitmq/setup.php ``` AMQP: `amqp://demo:demo@127.0.0.1:5672/demo`; Management: @@ -74,3 +74,5 @@ Nachträglich initialisieren oder aktualisieren: 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). 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/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/deployment/rabbitmq/setup.py b/deployment/rabbitmq/setup.py deleted file mode 100644 index 2f79041..0000000 --- a/deployment/rabbitmq/setup.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Provision the local RabbitMQ demo from neutral config; no PhoreMQ runtime needed. - -Repeated identical declarations are safe. No resources or messages are deleted. -Only local HTTP management is supported by this development helper. -""" -import argparse -import base64 -import json -import re -from pathlib import Path -from urllib.error import HTTPError, URLError -from urllib.parse import quote, unquote, urlsplit -from urllib.request import Request, build_opener, ProxyHandler, HTTPRedirectHandler - - -class NoRedirect(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -def declarations(config): - if set(config) != {"connection", "options", "topics", "subscriptions"}: - raise ValueError("Expected connection, options, topics and subscriptions") - if not isinstance(config["topics"], list) or not isinstance(config["subscriptions"], list): - raise ValueError("topics and subscriptions must be lists") - name = re.compile(r"[a-zA-Z_][a-zA-Z0-9_.-]{0,99}\Z") - topics = config["topics"] - if any(not isinstance(t, str) or (not name.fullmatch(t) or t.startswith("_phore")) for t in topics): - raise ValueError("Invalid topic name") - if len(set(topics)) != len(topics): - raise ValueError("Duplicate topic") - connection = urlsplit(config["connection"]) - if connection.scheme != "amqp" or connection.hostname not in ("127.0.0.1", "localhost"): - raise ValueError("This helper requires a local amqp DSN") - if connection.port != 5672 or connection.query or connection.fragment or not connection.username or connection.password is None: - raise ValueError("Invalid local connection DSN") - if not connection.path.startswith("/") or not connection.path[1:]: - raise ValueError("DSN must include a namespace") - namespace = unquote(connection.path[1:]) - vhost = quote(namespace, safe="") - actions = [] - for topic in topics: - actions.append(("PUT", f"exchanges/{vhost}/{quote('phore.topic:' + topic, safe='')}", - {"type": "topic", "durable": True, "auto_delete": False, "internal": False, "arguments": {}})) - seen = set() - for sub in config["subscriptions"]: - if not isinstance(sub, dict) or set(sub) != {"topic", "name", "type"}: - raise ValueError("Subscription requires topic, name and type (null means all)") - topic, subscription, kind = sub["topic"], sub["name"], sub["type"] - if topic not in topics or not isinstance(subscription, str) or not name.fullmatch(subscription): - raise ValueError("Invalid subscription or unknown topic") - if kind is not None and (not isinstance(kind, str) or not name.fullmatch(kind)): - raise ValueError("type must be an exact message type or null") - if (topic, subscription) in seen: - raise ValueError("Duplicate subscription") - seen.add((topic, subscription)) - queue = f"phore.sub:{topic}:{subscription}" - failure = f"phore.failure:{topic}:{subscription}" - args = {"x-queue-type": "quorum"} - actions.append(("PUT", f"queues/{vhost}/{quote(failure, safe='')}", - {"durable": True, "auto_delete": False, "arguments": args})) - # A dedicated failure destination never fans failed work out to other subscriptions. - args = {"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.append(("PUT", f"queues/{vhost}/{quote(queue, safe='')}", - {"durable": True, "auto_delete": False, "arguments": args})) - actions.append(("POST", f"bindings/{vhost}/e/{quote('phore.topic:' + topic, safe='')}/q/{quote(queue, safe='')}", - {"routing_key": kind if kind is not None else "#", "arguments": {}})) - return connection, actions - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[2] / "config/message-queue.json") - parser.add_argument("--dry-run", action="store_true", help="Validate and print declarations without connecting") - args = parser.parse_args() - config = json.loads(args.config.read_text()) - connection, actions = declarations(config) # Validate everything before the first write. - if args.dry_run: - print(json.dumps(actions, indent=2)) # Contains no DSN or credentials. - return - credentials = f"{unquote(connection.username)}:{unquote(connection.password)}".encode() - headers = {"Authorization": "Basic " + base64.b64encode(credentials).decode(), "Content-Type": "application/json"} - opener = build_opener(ProxyHandler({}), NoRedirect()) - base = "http://127.0.0.1:15672/api/" - # This fixed loopback endpoint prevents config from redirecting demo credentials. - for method, path, body in actions: - if method == "POST": - with opener.open(Request(base + path, headers=headers), timeout=10) as response: - existing = json.load(response) - if existing and any(b["routing_key"] != body["routing_key"] or b.get("arguments") for b in existing): - raise ValueError("Existing subscription filter differs; use a new subscription or an explicit migration") - with opener.open(Request(base + path, json.dumps(body).encode(), headers, method=method), timeout=10): - pass - print(f"Topology ready: {len(config['topics'])} topics, {len(config['subscriptions'])} subscriptions") - - -if __name__ == "__main__": - try: - main() - except HTTPError as error: - raise SystemExit(f"RabbitMQ management HTTP {error.code}; check readiness, permissions and declaration conflicts") from None - except (ValueError, TypeError, KeyError, OSError, URLError) as error: - raise SystemExit(f"Setup failed: {error}") from None diff --git a/docs/message-queue-basics-101.md b/docs/message-queue-basics-101.md index b5c91d6..afd572d 100644 --- a/docs/message-queue-basics-101.md +++ b/docs/message-queue-basics-101.md @@ -72,7 +72,7 @@ Eine neue Subscription erhält nur Nachrichten ab ihrer Bindung, keine früheren ## 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 Python-Skript. Die PHP-Beispiele verwenden nach Implementierung: +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()); diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 434c68c..718e7bb 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -12,21 +12,24 @@ | 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 | ## § 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. [neu] +**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 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. [neu] +des Entwurfs. Signierung und Dateireferenzen behalten ihre fachlichen Verträge. **Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und Autoloading -bleiben unveränderte Template-Werte; Beispiele benötigen später PHP >=8.3. -Der Docker-Start und das Python-Setup aus [Setup](../setup.md) sind davon -unabhängige, verwendbare Entwicklungsdateien; sie implementieren keine MQ-Library. [neu] +stammen aus der Vorlage; die PHP-Mindestversion ist verbindlich >=8.5. [geändert] +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). [geändert] | Umfang | Entscheidung | |---|---| @@ -35,10 +38,10 @@ unabhängige, verwendbare Entwicklungsdateien; sie implementieren keine MQ-Libra | 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 [neu] | +| 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. [neu] +Die produktive PHP-Client-Abhängigkeit wird bei Implementierung festgelegt. ### § 1.1 Kleine API auf einen Blick @@ -90,7 +93,7 @@ verwaltet die Library. Details und standardisierter Vertrag in § 16. | Subscription | Dauerhafte benannte Sicht auf ein Topic, etwa `billing-users` | | 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 [geändert] | +| 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` @@ -110,7 +113,7 @@ eine unklare Publish-Bestätigung bei Verbindungsabbruch. 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`. [geändert] +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 @@ -118,7 +121,7 @@ Bindung. Ein bestehender 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; `cancel()` beendet nur den lokalen Consumer, -löscht aber weder Subscription noch Rückstand. [neu] +löscht aber weder Subscription noch Rückstand. ## § 3 Abstraktionsschichten und Erweiterungspunkte @@ -133,7 +136,7 @@ löscht aber weder Subscription noch Rückstand. [neu] | `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 | -| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen [geändert] | +| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, 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 @@ -142,7 +145,7 @@ Zeit-/Zielbindung prüfen → Envelope dekodieren → optional Dateien verifizie → 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. [geändert] +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`, @@ -150,7 +153,7 @@ vorgeschlagenen primitiven Operationen sind `ensureTopology(TopologyDefinition, `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. [geändert] +Ungültige oder bereits erledigte Receipts erzeugen eine Settlement-Exception. Ein `MessageSecurityInterface` bietet `protect(string $envelopeBytes, SecurityContext $context): ProtectedFrame` und `verify(ProtectedFrame $frame, @@ -164,7 +167,7 @@ Ein Provider kann auch verschlüsseln; HMAC allein tut dies nicht. [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. [neu] +Ressourcen werden bei Fehlern geschlossen. Ein Adapter gehört exklusiv einem MQ. ```php $mq = new PhoreMQ($dsn, $options); @@ -178,26 +181,26 @@ keine Provider-Registry, keine Verbindungsattribute und keine dynamische Auswahl `__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. [neu] +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` [neu] | +| `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. [neu] +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 und optionalen PayloadStore. Konfiguration wird als Snapshot übernommen; bewusst geteilte Zustandsobjekte wie `HealthState` -bleiben geteilt. Unbekannte Optionen sind Fehler. [neu] +bleiben geteilt. Unbekannte Optionen sind Fehler. `ConnectionOptions::fromArray(array $values, ?ConnectionOptions $overrides = null)` ist ein geplanter Konfigurationshelfer, keine existierende Implementierung. @@ -207,14 +210,14 @@ den Rückkanalmodus aus § 13.1. Keine Klassennamen oder ausführbarer Code aus 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. [neu] +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`: [neu] +explizit. Die Beispiele erzeugen weiterhin ein einzelnes `PhoreMQ`: ```php $mq = new PhoreMQ(...demoConnection()); @@ -226,11 +229,11 @@ 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. [neu] +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. [neu] +ihren Backlog; unabhängige Beispieldurchläufe beginnen mit einem frischen Demo-Broker. ## § 5 Senden, empfangen und Worker-Lebenszyklus @@ -291,7 +294,7 @@ 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. [geändert] +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. @@ -332,7 +335,7 @@ 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. [geändert] +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 @@ -359,7 +362,7 @@ Fehler gehen vor Bestätigung in den FailureStore. `AckMode::Manual` erlaubt 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. [geändert] +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. @@ -367,7 +370,7 @@ 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. [neu] +geworfen statt in einer Endlosschleife verborgen. ## § 6 SDK-Typen, Attribute und strukturelle Kompatibilität @@ -546,7 +549,7 @@ 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. [neu] +nur im Adapter, Deployment und zur Erklärung der konkreten Abbildung. | Öffentlicher Begriff | RabbitMQ-Abbildung im ersten Adapter | |---|---| @@ -557,18 +560,18 @@ nur im Adapter, Deployment und zur Erklärung der konkreten Abbildung. [neu] | 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 [neu] | +| 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. [neu] +interne Namen beginnen mit `_phore`; Anwendungstopologien verwenden sie nicht. `publish` verwendet persistente Frames, Publisher Confirms und `mandatory`. Eine nicht routbare Nachricht wirft `UnroutableMessageException`; 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. [neu] +existieren. Eine Exchange selbst speichert keinen Backlog. `subscribe` validiert/anlegt Exchange, Queue und Binding gemäß `autoCreate`. Identische Definitionen sind wiederholbar. Abweichende Typfilter, Queue-Eigenschaften @@ -579,7 +582,7 @@ 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. [neu] +`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. @@ -587,7 +590,7 @@ Verzögerung erfolgt mit internen Wartequeues fester TTL und Rückführung an di 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). [neu] +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- @@ -595,7 +598,7 @@ 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. [neu] +werden bewusst gesetzt und überwacht; die Demo ist kein Hochverfügbarkeitscluster. ### § 7.1 Was andere PHP-Abstraktionen bereits vorsehen [gelöscht] @@ -607,7 +610,7 @@ 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. [geändert] +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, @@ -633,7 +636,7 @@ 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. [geändert] +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 @@ -641,7 +644,7 @@ 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. [geändert] +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 @@ -655,7 +658,7 @@ Broker-ACLs und sicherer Dateispeicher bleiben zusätzlich erforderlich. [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. [geändert] +`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 @@ -679,7 +682,7 @@ RabbitMQ speichert ausschließlich die Referenz, keine automatisch verwaltete ZI 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. [neu] +`publish`. Ohne Store oder bei zu großem Frame folgt eine eindeutige Exception. ## § 10 Lokale Entwicklung mit RabbitMQ @@ -688,14 +691,14 @@ Die Entwicklung verwendet denselben Adapter wie der spätere Betrieb. 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). [neu] +Ausfallredundanz. Anleitung und ausführbare Befehle: [Setup](../setup.md). -[setup.py](../../deployment/rabbitmq/setup.py) übersetzt die neutrale +[setup.php](../../deployment/rabbitmq/setup.php) übersetzt die neutrale Konfigurationsdatei in RabbitMQ-Deklarationen über dessen HTTP-Management-API. -Es benötigt nur Python 3, keine PHP-Library. `--dry-run` prüft und zeigt die +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. [neu] +entfernen daher keine existierenden Queues. Migrationen sind explizite Vorgänge. [geändert] ## § 11 Exceptions und Diagnose @@ -725,14 +728,14 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `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 [geändert] | +| `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. [neu] +sind keine Nachrichten-Retries. RPC ergänzt `RequestTimeoutException`, `RemoteCommandException`, `InvalidReplyException` und `RpcNotConfiguredException`. Die lokal vom @@ -767,7 +770,7 @@ 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 Policy bleibt über `SubscriptionOptions::retryPolicy` austauschbar. 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. [geändert] +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 @@ -776,7 +779,7 @@ beendet den Loop. Der RabbitMQ-Adapter verwendet die zugehörige Fehlerqueue aus 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. [geändert] +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 @@ -795,13 +798,13 @@ 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. [geändert] +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. [neu] +ausgefallen, Middleware schluckt/erhält Fehler, RPC-Endfehler und manuelles Ack. ## § 12 Paketgrenzen, spätere Prüfungen und Quellen @@ -809,7 +812,7 @@ 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. [geändert] +von Brokerinstallationen verteilen. Nicht in den Kern gehören fachliche DTOs, Business-Workflows, vollständige Job-Scheduler, langfristige Workflow-/RPC-Ergebnisarchive, Cloud-Provisionierung, @@ -817,7 +820,7 @@ 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. [geändert] +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 @@ -830,13 +833,13 @@ 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. [geändert] +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). [neu] +- §§ 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). [geändert] +- § 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 @@ -857,7 +860,7 @@ pro Client unter `replyNamespace` (Demo `_phore.rpc`). Das ist eine ausdrücklic aktivierte Ausnahme zur rein vorab angelegten fachlichen Topologie; passende Configure-/Read-/Write-Rechte für den reservierten Bereich sind erforderlich, auch bei `autoCreate: false`. Der Responder akzeptiert ausschließlich erlaubte -Reply-Ziele im konfigurierten Namespace; Zugang und Identität werden zusätzlich geprüft. [neu] +Reply-Ziele im konfigurierten Namespace; Zugang und Identität werden zusätzlich geprüft. Der interne Reply-Consumer wird vor Publish eingerichtet, einschließlich des Korrelationsregisters. Er teilt seine Queue niemals mit anderen Clients. @@ -868,7 +871,7 @@ Konfigurierbare feste `replyTopic`/`replySubscription` bleiben für einen expliz zentralen Demultiplexer möglich; unabhängige Clients dürfen sie nicht gemeinsam als konkurrierende Consumer verwenden. Der Server kann `allowedReplyTopics` zusätzlich auf konkrete Ziele einschränken. Anzahl, Bytes und Lebensdauer des -Rückkanals bleiben begrenzt. [neu] +Rückkanals bleiben begrenzt. `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. @@ -1132,7 +1135,7 @@ Die öffentliche API bleibt klein und erklärt die Wirkung am Aufruf: `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. [neu] +RabbitMQ-spezifische Klassen werden nur bei direkter Adapter-Injektion benötigt. ### § 14.2 Metadaten außerhalb des fachlichen Payloads @@ -1200,7 +1203,7 @@ 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). [neu] +- [RabbitMQ: RPC mit PHP](https://www.rabbitmq.com/tutorials/tutorial-six-php). ## § 15 An alle Subscriber oder an einen Worker @@ -1217,7 +1220,7 @@ 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. [geändert] +die RabbitMQ-Bindungen: Jede unabhängige Subscription besitzt ihre eigene Queue. ### § 15.2 Lock-Koordination: alle bekannten Teilnehmer antworten @@ -1272,13 +1275,13 @@ mehrere Prozesse mit `respond('jobs.text', 'text-processors', ...)`. 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. [geändert] +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. [neu] +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 @@ -1288,7 +1291,7 @@ Worker kann nach Verlust seines Channels sogar noch weiterlaufen, während ein n 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. [geändert] +Request-Ack. ### § 15.4 Spätere Prüfungen und Quellen @@ -1296,9 +1299,9 @@ 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. [geändert] +Diese Tests gehören zur späteren Implementierung, nicht zum Entwurfs-PR. -- [RabbitMQ Consumers: konkurrierende Consumer und Zustellsteuerung](https://www.rabbitmq.com/docs/consumers). [geändert] +- [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. @@ -1355,7 +1358,7 @@ Exception; `check` untersucht eine bereits erzeugte Connection erneut. | 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 [geändert] | +| 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 @@ -1416,7 +1419,7 @@ 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. [geändert] +und die bestehenden Retry-Grenzen bleiben wirksam. ### § 16.4 Health-Kanal, Ausfälle und Authentifizierung @@ -1562,7 +1565,7 @@ 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. [neu] +Entwurf, kein behaupteter branchenweiter Standard. ### § 16.8 Deklarierte Abhängigkeiten und Listenerdiagnose diff --git a/docs/setup.md b/docs/setup.md index 5aa2175..8196c00 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -4,18 +4,18 @@ PhoreMQ wird zunächst ausschließlich für RabbitMQ umgesetzt. Der Adapter blei hinter einem Interface; eine Brokerauswahl oder Treiberregistrierung gibt es nicht. Die Anwendungsbegriffe sind **Namespace, Topic, Subscription und Nachrichtentyp**. -**Bereits verwendbar:** Docker Compose und das Python-Setup in diesem Guide. +**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 Python 3. Alle Befehle laufen aus dem +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 -python3 deployment/rabbitmq/setup.py +php deployment/rabbitmq/setup.php ``` Der erste Befehl startet RabbitMQ mit Management-Plugin. Der zweite legt die @@ -50,7 +50,7 @@ Wenn die Demo nicht mehr benötigt wird, entfernt dieser Befehl Container und docker compose -f deployment/rabbitmq/compose.yaml down -v ``` -Ein anschließendes `up -d --wait` und `setup.py` ergeben einen frischen Demo-Broker. +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) @@ -97,7 +97,7 @@ Ein Client in einem anderen Container desselben Compose-Netzes verwendet | `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.py` verarbeitet ausschließlich `connection`, `topics` und `subscriptions`; +`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 @@ -130,11 +130,11 @@ nicht routbare Publishes ausdrücklich ab. [RabbitMQ Exchanges](https://www.rabb ## 4. Dynamisch oder per Setup-Skript? -Beides ist möglich, aber nur das Python-Skript ist bereits vorhanden: +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.py` ausführen | Beim Start registrieren und mit `autoCreate: true` deklarieren | +| 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 | @@ -169,7 +169,7 @@ 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 Python-Skript +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 @@ -184,7 +184,7 @@ nicht einfach neu deklariert werden. [RabbitMQ Queue-Deklarationen](https://www. ## 5. Prüfung und Grenzen ```bash -python3 deployment/rabbitmq/setup.py --dry-run +php deployment/rabbitmq/setup.php --dry-run ``` Der Dry Run validiert die Topologiedaten und zeigt die geplanten Operationen, diff --git a/examples/api-draft/01-connect.php b/examples/api-draft/01-connect.php index 7028b0f..6845a5b 100644 --- a/examples/api-draft/01-connect.php +++ b/examples/api-draft/01-connect.php @@ -15,7 +15,7 @@ /** * API-ENTWURF: Die importierten MQ-Klassen existieren noch nicht. - * Der Docker-Broker und setup.py sind unabhängig davon verwendbar: docs/setup.md. + * Der Docker-Broker und setup.php sind unabhängig davon verwendbar: docs/setup.md. * RabbitMQ ist der einzige Adapter. Keine Treiberregistrierung oder Brokerwahl. */ diff --git a/examples/api-draft/connection.php b/examples/api-draft/connection.php index f1783e3..2d759af 100644 --- a/examples/api-draft/connection.php +++ b/examples/api-draft/connection.php @@ -12,7 +12,7 @@ * Explizite Felder in $overrides ergänzen/überschreiben die Basisoptionen; * ausgelassene Felder behalten die Werte aus der Konfigurationsdatei. * JSON darf nur dokumentierte Optionswerte enthalten, keine PHP-Klassennamen. - * Die Topologielisten liest setup.py; autoCreate erlaubt zusätzliche Demo-Bindungen. + * Die Topologielisten liest setup.php; autoCreate erlaubt zusätzliche Demo-Bindungen. * @return array{string, ConnectionOptions} */ function demoConnection(?ConnectionOptions $overrides = null): array From e1a1e99732ec82b883731b8c98a53b62851e9d8d Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 11:39:39 +0200 Subject: [PATCH 12/16] docs: let publishers report missing queue configuration --- .ai-usage-info.md | 7 ++++ .../proposals/2026-09-12-message-queue-api.md | 39 ++++++++++++++++--- docs/setup.md | 17 +++++++- examples/api-draft/02-programmatic.php | 23 +++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 2ac4e75..a83334c 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -59,3 +59,10 @@ Ein Broker-Ping allein beweist keinen bereiten Handler. - [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. diff --git a/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 718e7bb..73262c5 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -13,6 +13,7 @@ | 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 | ## § 1 Abstract und Lieferumfang @@ -25,11 +26,11 @@ 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. [geändert] +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). [geändert] +zu schreiben; verbindliche Projektregeln stehen in [AGENTS.md](../../AGENTS.md). | Umfang | Entscheidung | |---|---| @@ -120,7 +121,7 @@ Eine neu angelegte Subscription empfängt erst Nachrichten ab Erstellung ihrer Bindung. Ein bestehender 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; `cancel()` beendet nur den lokalen Consumer, +explizit ihre dynamische Anlage beim Registrieren von Listenern; `cancel()` beendet nur den lokalen Consumer, löscht aber weder Subscription noch Rückstand. ## § 3 Abstraktionsschichten und Erweiterungspunkte @@ -568,11 +569,37 @@ 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 Nachricht wirft `UnroutableMessageException`; eine positive +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. [neu] + +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. [neu] + +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. [neu] + `subscribe` validiert/anlegt Exchange, Queue und Binding gemäß `autoCreate`. Identische Definitionen sind wiederholbar. Abweichende Typfilter, Queue-Eigenschaften oder Namensbindungen werfen `TopologyConflictException`; kein automatisches @@ -698,7 +725,7 @@ 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. [geändert] +entfernen daher keine existierenden Queues. Migrationen sind explizite Vorgänge. ## § 11 Exceptions und Diagnose @@ -714,7 +741,7 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. | `InvalidDsnException` | Ungültiger Port oder unbekannte Option; Konfiguration korrigieren | | `MissingDependencyException` | RabbitMQ-Client oder benötigte Schema-Bridge fehlt; vor Workerstart abbrechen | | `TopologyConflictException` / `TopologyVerificationException` | Deklaration widerspricht bestehender Topologie oder kann nicht vollständig geprüft werden | -| `UnroutableMessageException` | Keine passende Subscription für die veröffentlichte Nachricht | +| `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 | diff --git a/docs/setup.md b/docs/setup.md index 8196c00..e7e2880 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -90,7 +90,7 @@ Ein Client in einem anderen Container desselben Compose-Netzes verwendet | `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 der geplanten Library die dynamische Anlage fachlicher Ressourcen; Standard false, Demo true | +| `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 | @@ -200,3 +200,18 @@ 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. diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php index 815772d..2ac68df 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -11,6 +11,7 @@ use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\MessageValidationException; +use Phore\MessageQueue\Exception\QueueConfigurationMissingException; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageRegistry; use Phore\MessageQueue\SubscriptionOptions; @@ -103,3 +104,25 @@ function demo(): void $mq->close(); } } + +// Separates Sender-Beispiel nach Implementierung; der Publisher provisioniert nichts. +// Fehlt users oder die passende Bindung, kann die Anwendung den Zustand anzeigen. +function publishFromFrontend(): void +{ + $mq = new PhoreMQ(...demoConnection()); + try { + $mq->publish('users', 'user.created.v1', [ + 'userId' => 'u-789', + 'email' => 'user@example.org', + ]); + } catch (QueueConfigurationMissingException $error) { + // reason: TOPIC_MISSING oder NO_MATCHING_SUBSCRIPTION. + printf("Queue-Konfiguration fehlt für %s / %s (%s).\n", + $error->topic, $error->messageType, $error->reason); + echo "Möglicherweise wurde der zuständige Listener-Dienst noch nicht initialisiert.\n"; + // Kein automatisches Neuanlegen oder erneutes Senden. + // Eine vorhandene Queue ohne aktiven Worker löst diesen Fehler nicht aus. + } finally { + $mq->close(); + } +} From 57ac67369f56c7203d9f4fe3049fd0db4e173241 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 12:04:50 +0200 Subject: [PATCH 13/16] docs: define queue profiles and RPC lifecycle across container restarts --- .ai-usage-info.md | 10 + README.md | 10 + docs/message-queue-basics-101.md | 24 ++ .../proposals/2026-09-12-message-queue-api.md | 214 ++++++++++++++---- docs/setup.md | 38 ++++ examples/api-draft/03-attributes.php | 4 + examples/api-draft/05-rpc.php | 42 +++- examples/api-draft/08-processing-workers.php | 3 +- examples/api-draft/10-callback-errors.php | 9 +- examples/api-draft/11-queue-options.php | 131 +++++++++++ 10 files changed, 430 insertions(+), 55 deletions(-) create mode 100644 examples/api-draft/11-queue-options.php diff --git a/.ai-usage-info.md b/.ai-usage-info.md index a83334c..cbb1aeb 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -66,3 +66,13 @@ 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. diff --git a/README.md b/README.md index 19499a9..d90442d 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,13 @@ 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. diff --git a/docs/message-queue-basics-101.md b/docs/message-queue-basics-101.md index afd572d..4a2cbb0 100644 --- a/docs/message-queue-basics-101.md +++ b/docs/message-queue-basics-101.md @@ -178,3 +178,27 @@ Bei Callback-Exceptions sieht der Entwurf begrenzte Wiederholungen mit wachsende `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 index 73262c5..3633fc6 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -14,16 +14,17 @@ | 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 | ## § 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 Subscriptions, +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. +des Entwurfs. Signierung und Dateireferenzen behalten ihre fachlichen Verträge. [geändert] **Die PHP-API ist noch nicht implementiert.** Composer-Metadaten und Autoloading stammen aus der Vorlage; die PHP-Mindestversion ist verbindlich >=8.5. @@ -35,7 +36,7 @@ zu schreiben; verbindliche Projektregeln stehen in [AGENTS.md](../../AGENTS.md). | Umfang | Entscheidung | |---|---| | Transport | Genau ein RabbitMQ-Adapter hinter `ConnectorInterface` | -| Zustellung | Dauerhafte Subscriptions, Quorum Queues, Publisher Confirms, Ack nach Handler-Erfolg | +| 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 | @@ -91,7 +92,7 @@ verwaltet die Library. Details und standardisierter Vertrag in § 16. | 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 | Dauerhafte benannte Sicht auf ein Topic, etwa `billing-users` | +| 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 | @@ -107,22 +108,23 @@ alle passenden benannten Subscriptions; „an einen“ einen ausgewählten Worke innerhalb derselben Subscription. Die Subscription-Topologie bestimmt das Verhalten, kein zusätzlicher Broadcast-Schalter beim Senden. Beispiele in § 15. -Der Grundvertrag lautet **at least once innerhalb der konfigurierten +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`. +Fachliche Seiteneffekte benötigen eine stabile fachliche Idempotenz-ID; Wiederzustellungen behalten zusätzlich dieselbe `messageId`. [geändert] `subscribe()` bindet eine benannte Subscription und prüft ihren Vertrag. Eine neu angelegte Subscription empfängt erst Nachrichten ab Erstellung ihrer -Bindung. Ein bestehender Rückstand bleibt bei Worker-Neustarts erhalten. +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 Subscription noch Rückstand. +löscht aber weder dauerhafte Subscription noch Rückstand. Flüchtige exklusive +Queues werden beim Ende ihrer Verbindung beziehungsweise ihres letzten Consumers entfernt. [geändert] ## § 3 Abstraktionsschichten und Erweiterungspunkte @@ -137,7 +139,7 @@ löscht aber weder Subscription noch Rückstand. | `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 | -| `RetryPolicy` / `FailureStoreInterface` | Vorübergehende Fehler wiederholen, endgültige Fehler sicher ablegen | +| `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 @@ -199,9 +201,9 @@ 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 und optionalen PayloadStore. Konfiguration wird als +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. +bleiben geteilt. Unbekannte Optionen sind Fehler. [geändert] `ConnectionOptions::fromArray(array $values, ?ConnectionOptions $overrides = null)` ist ein geplanter Konfigurationshelfer, keine existierende Implementierung. @@ -288,9 +290,8 @@ 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 `retryPolicy`. -Fachliche Subscriptions sind immer dauerhaft; es gibt keine Cursor-, Replay- -oder wechselbaren Haltbarkeitsmodi. +`payloadClass` als lokale Zielklasse, `ackMode` und `queue: QueueOptions`. +Profile legen Haltbarkeit und Retry fest (§ 7.2); Cursor und Replay sind nicht vorgesehen. [geändert] 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 @@ -557,7 +558,7 @@ nur im Adapter, Deployment und zur Erklärung der konkreten Abbildung. | 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` | Dauerhafte Quorum Queue `phore.sub:users:audit-users` | +| 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 | @@ -583,7 +584,7 @@ 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. [neu] +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 @@ -592,24 +593,24 @@ 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. [neu] +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. [neu] +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 `TopologyConflictException`; kein automatisches +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. +`TopologyVerificationException`, keine Behauptung erfolgreicher Vollprüfung. [geändert] Retry-Veröffentlichungen gehen ausschließlich über ein internes Ziel zurück an dieselbe Subscription, niemals erneut über die fachliche Topic-Exchange. @@ -629,6 +630,74 @@ werden bewusst gesetzt und überwacht; die Demo ist kein Hochverfügbarkeitsclus ### § 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. [neu] + +`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). [neu] + +| 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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + +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. [neu] + ## § 8 Transparente Sicherheit Default-Provider bei konfiguriertem Shared Secret ist **HMAC-SHA-256** mit @@ -740,7 +809,7 @@ Payload, Secret, signierter Download-Link oder Receipt im normalen Fehlertext. |---|---| | `InvalidDsnException` | Ungültiger Port oder unbekannte Option; Konfiguration korrigieren | | `MissingDependencyException` | RabbitMQ-Client oder benötigte Schema-Bridge fehlt; vor Workerstart abbrechen | -| `TopologyConflictException` / `TopologyVerificationException` | Deklaration widerspricht bestehender Topologie oder kann nicht vollständig geprüft werden | +| `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 | @@ -777,7 +846,8 @@ Command-Fehler und ändert die Settlement-Entscheidung nicht. 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 nach folgender Policy. +`Throwable`s an der Handlergrenze ab und entscheidet bei dauerhaften Profilen +nach folgender Policy; flüchtiger Broadcast folgt § 7.2. [geändert] | Callback-Ergebnis | Standard im Entwurf | |---|---| @@ -789,15 +859,15 @@ Exception vor Settlement: kein Erfolgs-Ack. Die Runtime fängt behandelbare | 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, mit 1, 2 und 4 Sekunden Verzögerung. Feste Wartequeues halten +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 Policy bleibt über `SubscriptionOptions::retryPolicy` -austauschbar. Diese Defaults sind unsere Designentscheidung, keine Zusage des +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. +der Job wird verzögert wieder verfügbar. [geändert] Nach endgültiger Ablehnung oder ausgeschöpften Versuchen wird zuerst der FailureStore sicher bestätigt, dann die Ursprungszustellung beendet. Ohne @@ -880,25 +950,27 @@ DTO. Ein skalarer Wert wird explizit als `['value' => ...]` verpackt. ### § 13.1 Einmalige Konfiguration und Aufruf -`ConnectionOptions::rpc` nimmt `RpcConnectionOptions` entgegen. Bei -`enabled: true` erzeugt der Adapter vor dem ersten antwortfähigen Publish ein -zufällig eindeutiges Reply-Topic samt exklusiver, automatisch gelöschter Classic-Reply-Queue -pro Client unter `replyNamespace` (Demo `_phore.rpc`). Das ist eine ausdrücklich -aktivierte Ausnahme zur rein vorab angelegten fachlichen Topologie; passende -Configure-/Read-/Write-Rechte für den reservierten Bereich sind erforderlich, -auch bei `autoCreate: false`. Der Responder akzeptiert ausschließlich erlaubte -Reply-Ziele im konfigurierten Namespace; Zugang und Identität werden zusätzlich geprüft. - -Der interne Reply-Consumer wird vor Publish eingerichtet, einschließlich des -Korrelationsregisters. Er teilt seine Queue niemals mit anderen Clients. -Der Adapter verwendet reguläre Reply-Queues, kein verlustbehaftetes Direct Reply-to. -Replies können nach Client-Verbindungsabbruch verloren gehen; offene Aufrufe -enden mit Verbindungsfehler oder Timeout. Dies ist kein dauerhaftes RPC-Ergebnisarchiv. -Konfigurierbare feste `replyTopic`/`replySubscription` bleiben für einen expliziten -zentralen Demultiplexer möglich; unabhängige Clients dürfen sie nicht gemeinsam -als konkurrierende Consumer verwenden. Der Server kann `allowedReplyTopics` -zusätzlich auf konkrete Ziele einschränken. Anzahl, Bytes und Lebensdauer des -Rückkanals bleiben begrenzt. +`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. [geändert] + +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. [geändert] + +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). [geändert] `RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. @@ -952,14 +1024,15 @@ 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. -Auf dem Server folgen Antwort-Publish und dessen Bestätigung **vor** dem Ack -des Requests. Bei unklarer Antwortannahme bleibt der Request wiederholbar. +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. +Exactly-once-Garantie der Queue und kein still aktivierter globaler Cache. [geändert] Ein Timeout begrenzt nur das lokale Warten: Der Server kann noch arbeiten oder bereits fertig sein. Vor Handlerstart wird die geschützte Deadline @@ -1148,6 +1221,51 @@ 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. [neu] + +| 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. [neu] + +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. [neu] + +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. [neu] + ## § 14 Metadaten, Middleware und API-Entscheidung [Beispiel 06](../../examples/api-draft/06-metadata-middleware.php) zeigt diff --git a/docs/setup.md b/docs/setup.md index e7e2880..5b9c47f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -215,3 +215,41 @@ 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. diff --git a/examples/api-draft/03-attributes.php b/examples/api-draft/03-attributes.php index a49d23b..4b2bcf3 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -10,6 +10,8 @@ use Phore\MessageQueue\Attribute\MessageType; use Phore\MessageQueue\Attribute\Subscribe; +use Phore\MessageQueue\Attribute\Queue; +use Phore\MessageQueue\QueueProfile; use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\SubscriptionOptions; use Phore\MessageQueue\Exception\MessageMappingException; @@ -25,6 +27,8 @@ // Feste Gruppe: mehrere Worker mit dieser Vorgabe TEILEN die Zustellungen. #[MessageType('user.created.v1', topic: 'users', subscription: 'sdk-users')] +// Queue-Vorgaben werden beim Subscriber geprüft/angelegt, niemals beim publish. +#[Queue(profile: QueueProfile::WorkQueue, revision: 1, maxAttempts: 4, retryDelaySeconds: 10)] final class T_UserCreated { public string $userId; diff --git a/examples/api-draft/05-rpc.php b/examples/api-draft/05-rpc.php index 71a8bb8..865b447 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -16,19 +16,50 @@ use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\Rpc\AwaitOptions; -use Phore\MessageQueue\MessageQueueInterface; +use Phore\MessageQueue\Exception\ConnectionException; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\Notice; use Phore\MessageQueue\Rpc\RemoteCommandException; use Phore\MessageQueue\Rpc\RequestContext; use Phore\MessageQueue\Rpc\RequestTimeoutException; use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\QueueOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 13–14. * Nach Implementierung: zuerst runServer() in Prozess A starten, * dann runClient() in Prozess B. Beide nutzen denselben RabbitMQ-Namespace. - * Der RabbitMQ-Adapter vergibt pro Client einen eigenen Rückkanal; Details in 01-connect.php. + * RPC-Ablauf (geplante Library, nicht manuell in der Anwendung nachzubauen): + * 1. Vor publish: private exklusive Classic-Reply-Queue + eindeutiges Binding + * an gemeinsamer interner Exchange anlegen und Reply-Consumer bestätigen lassen. + * 2. Neue requestId lokal registrieren, dann Request mit replyTo/requestId senden. + * 3. Ein Worker verarbeitet; return bzw. sichere Exception wird zum Reply. + * 4. Reply an genau dieses replyTo senden und bestätigen lassen, dann Request ack. + * 5. await liest den Rückkanal und ordnet anhand requestId zu; kein Hintergrundthread. + * + * Mehrere Publisher: je Connection eigene Queue, mehrere Calls je Queue per ID. + * Gleicher Nachrichtentyp ist KEIN gemeinsamer Rückkanal. Worker-Replikate teilen + * calculator-workers; Publisher-Replikate teilen niemals ihre Reply-Queue. + * + * Container-Neustart: Der Publisher verliert lokale Pending-Objekte. RabbitMQ + * löscht die exklusive Queue nach erkanntem Verbindungsverlust (nicht zwingend + * sofort beim Crash). close() räumt sie auf, Binding wird mit entfernt; die eine + * gemeinsame interne Exchange bleibt. Neuer Container bekommt einen neuen Namen. + * Ein einzelner await-Timeout löscht die gemeinsam genutzte Queue NICHT. + * Nach Wire-Deadline werden Pending-Einträge entfernt, späte Replies verworfen. + * + * Worker-Crash vor Request-Ack kann Arbeit erneut zustellen: auch nach return + * oder nach erfolgreicher Geschäftsaktion! Division ist rein und wiederholbar. + * Bei Buchungen/Exports operationId und Ergebnis extern dauerhaft/atomar speichern; + * nie nur einen Array-Cache im Container verwenden. Nach Publisher-Neustart mit + * derselben operationId bewusst neu anfragen, aber neuer requestId/replyTo. + * Der Worker sendet das gespeicherte Ergebnis an das AKTUELLE replyTo zurück. + * Unklare Publish-Bestätigung oder Timeout beweist nicht, dass nichts passiert ist. + * Ist das alte Reply-Ziel nachweislich gelöscht, keine Geschäftsaktion allein + * deswegen wiederholen; Ergebnis sichern und verwaisten Request abschließen. + * Bei unklarem Reply-Publish kein Ack. Details/Fehlerfenster: Proposal § 13.6. + * Docker: demoConnection nutzt Host-Loopback; in getrennten Containern gemeinsame + * Konfiguration auf rabbitmq:5672 und http://rabbitmq:15672 umstellen (Setup). */ #[MessageType('math.divide.v1', topic: 'calculator')] @@ -83,7 +114,7 @@ function runServer(): void $mq = new PhoreMQ(...demoConnection()); try { $mq->respond('calculator', 'calculator-workers', [new DivideHandler(), 'divide'], - new SubscriptionOptions(type: 'math.divide.v1')); + new SubscriptionOptions(type: 'math.divide.v1', queue: QueueOptions::rpc())); // Gleichwertige Alternative mit Attributen, NICHT zusätzlich registrieren: // $mq->registerHandlers(new DivideHandler()); @@ -169,6 +200,11 @@ function runClient(): void // Der Client darf auch eine andere lokale Klasse mit demselben RemoteError-Namen // registrieren. Keine entfernten PHP-Klassennamen, Stacktraces oder unserialize(). + } catch (ConnectionException $connectionError) { + // Transportausfall: offene Calls dieser Connection sind nicht fortsetzbar. + // Kein automatisches erneutes publish; bei Schreiboperationen erst den + // persistenten operationId-/Ergebnisstatus prüfen. Supervisor darf neu starten. + throw $connectionError; } catch (RequestTimeoutException $timeout) { // await wirft RequestTimeoutException bei abgelaufener lokaler Wartefrist // oder ursprünglicher Antwortdeadline ohne rechtzeitig empfangenes finales Ergebnis. diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php index a308ed8..4089633 100644 --- a/examples/api-draft/08-processing-workers.php +++ b/examples/api-draft/08-processing-workers.php @@ -13,6 +13,7 @@ use Phore\MessageQueue\Rpc\RequestContext; use Phore\MessageQueue\Rpc\RequestOptions; use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\QueueOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 15.3. @@ -41,7 +42,7 @@ static function (array $job, RequestContext $request) use ($workerId): array { // Reine, wiederholbare Verarbeitung ohne externe Seiteneffekte. return ['normalized' => $text, 'bytes' => strlen($text), 'sha256' => hash('sha256', $text)]; - }, new SubscriptionOptions(type: 'text.process.v1')); + }, new SubscriptionOptions(type: 'text.process.v1', queue: QueueOptions::rpc())); $mq->run(); } finally { $mq->close(); diff --git a/examples/api-draft/10-callback-errors.php b/examples/api-draft/10-callback-errors.php index 6835f6e..b8e8b89 100644 --- a/examples/api-draft/10-callback-errors.php +++ b/examples/api-draft/10-callback-errors.php @@ -11,6 +11,7 @@ use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\QueueOptions; use Phore\MessageQueue\Exception\RetryableMessageException; use Phore\MessageQueue\Exception\RejectMessageException; use Phore\MessageQueue\Exception\FailureStoreException; @@ -42,14 +43,16 @@ function demo(): void // 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')); + }, 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']); $mq->publish('jobs.demo', 'demo.process.v1', []); $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'unexpected']); - // Vorgeschlagener Standard: 1 erster Versuch + höchstens 3 Wiederholungen, - // mit 1/2/4 Sekunden Verzögerung und ohne Jitter. Kein enger Requeue-Loop. + // 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. diff --git a/examples/api-draft/11-queue-options.php b/examples/api-draft/11-queue-options.php new file mode 100644 index 0000000..41e6bf6 --- /dev/null +++ b/examples/api-draft/11-queue-options.php @@ -0,0 +1,131 @@ +=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('EMPTY_TEXT', '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. + $mq->respond('text', 'text-workers', static function (array $command): array { + if (!is_string($command['text'] ?? null) || $command['text'] === '') { + throw new CommandFailedException('EMPTY_TEXT', 'Ein nicht leerer Text wird benötigt.'); + } + 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 runEvents(): 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. + // retentionSeconds: 300 würde eine dauerhafte Subscription mit maximal + // 5 Minuten Wartezeit erzeugen. Dafür jeder Empfängergruppe eigenen Namen + // geben; gleiche Namen teilen dann Arbeit. Kein Replay für neue Gruppen. + $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(); + } +} From 080a871fafd4ee5ef47a46661f76270717c0d324 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 12:27:38 +0200 Subject: [PATCH 14/16] docs: add internal RabbitMQ Compose and anonymous authentication how-to --- .ai-usage-info.md | 2 + deployment/rabbitmq/HOWTO.md | 168 +++++++++++++++++++++ deployment/rabbitmq/anonymous.conf | 6 + deployment/rabbitmq/compose.anonymous.yaml | 8 + deployment/rabbitmq/compose.internal.yaml | 31 ++++ deployment/rabbitmq/internal.conf | 5 + docs/setup.md | 2 + 7 files changed, 222 insertions(+) create mode 100644 deployment/rabbitmq/HOWTO.md create mode 100644 deployment/rabbitmq/anonymous.conf create mode 100644 deployment/rabbitmq/compose.anonymous.yaml create mode 100644 deployment/rabbitmq/compose.internal.yaml create mode 100644 deployment/rabbitmq/internal.conf diff --git a/.ai-usage-info.md b/.ai-usage-info.md index cbb1aeb..2722319 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -76,3 +76,5 @@ Work/RPC verwenden standardmäßig vier Versuche mit jeweils zehn Sekunden Absta 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. diff --git a/deployment/rabbitmq/HOWTO.md b/deployment/rabbitmq/HOWTO.md new file mode 100644 index 0000000..4f97faa --- /dev/null +++ b/deployment/rabbitmq/HOWTO.md @@ -0,0 +1,168 @@ +# 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 nur den Konfigurationsmount. 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. + +## 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; kopiere internal.conf daneben +und passe den Mountpfad an. 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/anonymous.conf b/deployment/rabbitmq/anonymous.conf new file mode 100644 index 0000000..383299e --- /dev/null +++ b/deployment/rabbitmq/anonymous.conf @@ -0,0 +1,6 @@ +listeners.tcp.default = 5672 +# RabbitMQ >=4.3: Client sendet keine Credentials, muss ANONYMOUS auswählen. +auth_mechanisms.1 = ANONYMOUS +# Interne Zuordnung; muss zum initialisierten Benutzer aus Compose passen. +anonymous_login_user = mq-demo +anonymous_login_pass = mq-demo-password diff --git a/deployment/rabbitmq/compose.anonymous.yaml b/deployment/rabbitmq/compose.anonymous.yaml new file mode 100644 index 0000000..9e33560 --- /dev/null +++ b/deployment/rabbitmq/compose.anonymous.yaml @@ -0,0 +1,8 @@ +# NUR zusammen mit compose.internal.yaml verwenden; HOWTO.md zeigt den Start. +# Der Mount ersetzt anhand desselben Container-Zielpfads die Basis-Konfiguration. +services: + rabbitmq: + volumes: + - ./anonymous.conf:/etc/rabbitmq/conf.d/90-app.conf:ro + # Kein Benutzer/Passwort im AMQP-Client erforderlich: SASL ANONYMOUS wählen. + # Der Broker behält die interne Identität aus der Basis-Compose. diff --git a/deployment/rabbitmq/compose.internal.yaml b/deployment/rabbitmq/compose.internal.yaml new file mode 100644 index 0000000..a70254a --- /dev/null +++ b/deployment/rabbitmq/compose.internal.yaml @@ -0,0 +1,31 @@ +# 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 + volumes: + - data:/var/lib/rabbitmq + - ./internal.conf:/etc/rabbitmq/conf.d/90-app.conf:ro + 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/internal.conf b/deployment/rabbitmq/internal.conf new file mode 100644 index 0000000..38423bb --- /dev/null +++ b/deployment/rabbitmq/internal.conf @@ -0,0 +1,5 @@ +# AMQP im Container-Netz; kein Management-Plugin im Image rabbitmq:4.3. +listeners.tcp.default = 5672 +# Bewusst nur Passwort-Anmeldung im Basisbeispiel, kein implizites ANONYMOUS. +auth_mechanisms.1 = PLAIN +anonymous_login_user = none diff --git a/docs/setup.md b/docs/setup.md index 5b9c47f..876a269 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -253,3 +253,5 @@ 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. From 59510b6de76bda70eb4ef100d5c8648565476b6d Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 12:37:31 +0200 Subject: [PATCH 15/16] docs: configure RabbitMQ authentication directly in Compose --- .ai-usage-info.md | 2 ++ deployment/rabbitmq/HOWTO.md | 40 ++++++++++++++++++++-- deployment/rabbitmq/anonymous.conf | 6 ---- deployment/rabbitmq/compose.anonymous.yaml | 13 ++++--- deployment/rabbitmq/compose.internal.yaml | 5 ++- deployment/rabbitmq/internal.conf | 5 --- 6 files changed, 51 insertions(+), 20 deletions(-) delete mode 100644 deployment/rabbitmq/anonymous.conf delete mode 100644 deployment/rabbitmq/internal.conf diff --git a/.ai-usage-info.md b/.ai-usage-info.md index 2722319..c68847a 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -78,3 +78,5 @@ 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. diff --git a/deployment/rabbitmq/HOWTO.md b/deployment/rabbitmq/HOWTO.md index 4f97faa..805f939 100644 --- a/deployment/rabbitmq/HOWTO.md +++ b/deployment/rabbitmq/HOWTO.md @@ -31,7 +31,7 @@ Identifikation einzelner Dienste. Diese Variante ist für das isolierte Testnetz docker compose -p phore-anonymous -f deployment/rabbitmq/compose.internal.yaml -f deployment/rabbitmq/compose.anonymous.yaml up -d --wait ``` -Die zweite Datei ersetzt nur den Konfigurationsmount. Sie aktiviert ANONYMOUS und +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** @@ -45,11 +45,45 @@ und am [Konfigurationsschema](https://github.com/rabbitmq/rabbitmq-server/blob/v 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; kopiere internal.conf daneben -und passe den Mountpfad an. Ergänze bei den bestehenden Diensten: +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: diff --git a/deployment/rabbitmq/anonymous.conf b/deployment/rabbitmq/anonymous.conf deleted file mode 100644 index 383299e..0000000 --- a/deployment/rabbitmq/anonymous.conf +++ /dev/null @@ -1,6 +0,0 @@ -listeners.tcp.default = 5672 -# RabbitMQ >=4.3: Client sendet keine Credentials, muss ANONYMOUS auswählen. -auth_mechanisms.1 = ANONYMOUS -# Interne Zuordnung; muss zum initialisierten Benutzer aus Compose passen. -anonymous_login_user = mq-demo -anonymous_login_pass = mq-demo-password diff --git a/deployment/rabbitmq/compose.anonymous.yaml b/deployment/rabbitmq/compose.anonymous.yaml index 9e33560..60ddb26 100644 --- a/deployment/rabbitmq/compose.anonymous.yaml +++ b/deployment/rabbitmq/compose.anonymous.yaml @@ -1,8 +1,11 @@ # NUR zusammen mit compose.internal.yaml verwenden; HOWTO.md zeigt den Start. -# Der Mount ersetzt anhand desselben Container-Zielpfads die Basis-Konfiguration. +# Ersetzt den gesamten Parameterwert aus der Basis-Compose, kein Config-Mount. services: rabbitmq: - volumes: - - ./anonymous.conf:/etc/rabbitmq/conf.d/90-app.conf:ro - # Kein Benutzer/Passwort im AMQP-Client erforderlich: SASL ANONYMOUS wählen. - # Der Broker behält die interne Identität aus der Basis-Compose. + 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 index a70254a..8f2d382 100644 --- a/deployment/rabbitmq/compose.internal.yaml +++ b/deployment/rabbitmq/compose.internal.yaml @@ -10,9 +10,12 @@ services: 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 - - ./internal.conf:/etc/rabbitmq/conf.d/90-app.conf:ro networks: - mq # Keine ports: Host/LAN erhalten keinen veröffentlichten Broker-Port. diff --git a/deployment/rabbitmq/internal.conf b/deployment/rabbitmq/internal.conf deleted file mode 100644 index 38423bb..0000000 --- a/deployment/rabbitmq/internal.conf +++ /dev/null @@ -1,5 +0,0 @@ -# AMQP im Container-Netz; kein Management-Plugin im Image rabbitmq:4.3. -listeners.tcp.default = 5672 -# Bewusst nur Passwort-Anmeldung im Basisbeispiel, kein implizites ANONYMOUS. -auth_mechanisms.1 = PLAIN -anonymous_login_user = none From ea96caf1531418acd471200b9bbb64c7ef8f9adf Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Sat, 12 Sep 2026 17:49:14 +0200 Subject: [PATCH 16/16] docs: review MQ API around explicit application workflows and shared send options --- .ai-usage-info.md | 2 + README.md | 2 + .../proposals/2026-09-12-message-queue-api.md | 210 ++++++++++++------ docs/setup.md | 2 + examples/api-draft/01-connect.php | 3 +- examples/api-draft/02-programmatic.php | 158 +++++++------ examples/api-draft/03-attributes.php | 43 ++-- examples/api-draft/04-files-and-local.php | 10 +- examples/api-draft/05-rpc.php | 198 +++++++++-------- examples/api-draft/06-metadata-middleware.php | 23 +- examples/api-draft/07-broadcast-locking.php | 17 +- examples/api-draft/08-processing-workers.php | 18 +- examples/api-draft/09-system-check.php | 20 +- examples/api-draft/10-callback-errors.php | 14 +- examples/api-draft/11-queue-options.php | 31 ++- examples/api-draft/README.md | 82 +++++++ examples/api-draft/connection.php | 4 + 17 files changed, 528 insertions(+), 309 deletions(-) create mode 100644 examples/api-draft/README.md diff --git a/.ai-usage-info.md b/.ai-usage-info.md index c68847a..e13f06d 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -80,3 +80,5 @@ 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/README.md b/README.md index d90442d..8c84042 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,5 @@ Work/RPC verwenden standardmäßig vier Versuche mit jeweils zehn Sekunden Absta 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/docs/proposals/2026-09-12-message-queue-api.md b/docs/proposals/2026-09-12-message-queue-api.md index 3633fc6..2a212f8 100644 --- a/docs/proposals/2026-09-12-message-queue-api.md +++ b/docs/proposals/2026-09-12-message-queue-api.md @@ -15,6 +15,7 @@ | 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 @@ -24,7 +25,7 @@ Die frameworkunabhängige PHP-Library bietet Topics, dauerhafte und flüchtige S 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. [geändert] +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. @@ -51,34 +52,44 @@ 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 liefern ein `SendResult`; nur dessen -optional aufgerufenes `await` wartet auf eine fachliche Antwort. +`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); // Einmal erzeugen; DSN oder Connector. -$mq->publish('users', 'user.created.v1', ['userId' => 'u-1']); -$mq->subscribe('users', 'billing-users', function (array $event): void { /* ... */ }); +$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(); +} +``` -$reply = $mq->publish('calculator', 'math.divide.v1', ['a' => 12, 'b' => 3]) - ->await(timeoutSeconds: 5); // Rückkanal einmal in ConnectionOptions konfigurieren. -echo $reply->payload['quotient']; // 4; wartet ausdrücklich auf eine entfernte Antwort. +Separater Anwendungsprozess, nachdem der Worker bereit ist: [neu] -$mq->respond('calculator', 'calculator-workers', function (array $params): array { - return ['quotient' => $params['a'] / $params['b']]; // Kurzform; vollständige Fehlerprüfung in Beispiel 05. -}, new SubscriptionOptions(type: 'math.divide.v1')); -$mq->run(); +```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(); +} ``` -Diese Zeilen illustrieren getrennte Sender-/Empfängerprozesse, kein sequenziell -ausführbares Skript; der Responder muss vor dem Request laufen. Konstruktor -bzw. Factory und `close()` gehören zum Verbindungslebenszyklus. `publish($dto)` ist -die Objektform derselben Sendemethode mit automatischem Mapping; Attribute registrieren dieselben -Handler. Es gibt keine zweite RPC-Client-Fassade, kein eigenes Promise-Framework, -keinen Container-Zwang und kein mehrdeutiges `dispatch(..., true)`. Erweiterungen -kommen über Optionsobjekte und zwei Middleware-Hooks; Signierung, Codec und -Konnektoren sind Infrastruktur-Schnittstellen, keine Pflicht im täglichen Code. -`request` bleibt eine optionale explizite RPC-Komfortform, ist für das Warten -nach `publish` aber nicht mehr erforderlich. +`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 @@ -115,7 +126,7 @@ eine unklare Publish-Bestätigung bei Verbindungsabbruch. 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`. [geändert] +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 @@ -124,7 +135,7 @@ 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. [geändert] +Queues werden beim Ende ihrer Verbindung beziehungsweise ihres letzten Consumers entfernt. ## § 3 Abstraktionsschichten und Erweiterungspunkte @@ -203,7 +214,7 @@ Optionen schlägt früh mit `InvalidConfigurationException` fehl. (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. [geändert] +bleiben geteilt. Unbekannte Optionen sind Fehler. `ConnectionOptions::fromArray(array $values, ?ConnectionOptions $overrides = null)` ist ein geplanter Konfigurationshelfer, keine existierende Implementierung. @@ -248,9 +259,9 @@ 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 $topic, string $type, array|object $params, - ?RequestOptions $options = null): SendResult; -respond(string $topic, string $subscription, callable $handler, +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, @@ -291,7 +302,7 @@ 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. [geändert] +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 @@ -303,21 +314,26 @@ 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 wird nur als -existierender Funktionsname akzeptiert, niemals als automatisch entdeckter -Topic-Handler. Eine Subscription in der zweiten Position ergänzt bei der +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. +keinen Callback und ist kein gültiger Aufruf. [geändert] -`subscribe` registriert und bindet, `run` startet den blockierenden Empfang. +`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. +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 @@ -354,7 +370,7 @@ 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 ausführlich in Beispiel 02 und am RPC-Loop in 05. +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 @@ -610,7 +626,7 @@ 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. [geändert] +`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. @@ -633,14 +649,14 @@ werden bewusst gesetzt und überwacht; die Demo ist kein Hochverfügbarkeitsclus ### § 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. [neu] +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). [neu] +stehen in [11-queue-options.php](../../examples/api-draft/11-queue-options.php). | Feld | Bedeutung und Default | |---|---| @@ -661,14 +677,14 @@ 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. [neu] +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. [neu] +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`, @@ -677,7 +693,7 @@ 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. [neu] +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: @@ -685,7 +701,7 @@ Fehlend: mit autoCreate anlegen. Identisch: wiederverwenden. Widersprüchlich: `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. [neu] +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 @@ -696,7 +712,7 @@ 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. [neu] +kein Listener darf bestehende Policies überschreiben. ## § 8 Transparente Sicherheit @@ -847,7 +863,7 @@ direkt im Handler; [Beispiel 06](../../examples/api-draft/06-metadata-middleware 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. [geändert] +nach folgender Policy; flüchtiger Broadcast folgt § 7.2. | Callback-Ergebnis | Standard im Entwurf | |---|---| @@ -867,7 +883,7 @@ 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. [geändert] +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 @@ -957,23 +973,29 @@ 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. [geändert] +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. [geändert] +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). [geändert] +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] -`RequestOptions` ergänzt `timeoutSeconds` (Default 30 Sekunden ab `request`, -nicht ab `await`), `metadata`, optional `responseClass` und `onNotice`. `SendResult::await(?AwaitOptions $options = null, ?float $timeoutSeconds = null, ?string $responseClass = null, ?callable $onNotice = null, ?array $errorTypes = null): Reply` verarbeitet @@ -983,10 +1005,9 @@ 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. -`RequestOptions::responseClass`/`onNotice` liefern lediglich die Anfangswerte -für dieselben Await-Einstellungen. `PendingReply` entfällt als separater +`PendingReply` entfällt als separater Rückgabetyp im Entwurf; bestehende `request(...)->await()`-Beispiele bleiben -gültig. +gültig. [geändert] `Reply` besitzt schreibgeschützte `payload`, `metadata` und `notices`. `responseClass` hydriert `payload` strukturell nach § 6, ohne die PHP-Klasse @@ -1032,7 +1053,7 @@ 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. [geändert] +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 @@ -1066,10 +1087,10 @@ 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 `INTERNAL_ERROR` gemeldet und intern +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. +Empfang und Deadline-Verarbeitung fort. [geändert] ### § 13.4 Sofort senden, anschließend optional warten @@ -1157,8 +1178,8 @@ 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 -`RequestOptions::timeoutSeconds` als ursprüngliche Remote-Frist übernimmt; -eine zweite Promise-/Worker-API entsteht dadurch nicht. +`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 @@ -1228,7 +1249,7 @@ 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. [neu] +requestId, nicht an ein allgemeines Ergebnis-Topic. | Ereignis | Verhalten und Konsequenz | |---|---| @@ -1248,7 +1269,7 @@ die verbleibende Antwortfrist als TTL. Das Pending-Register nimmt höchstens 100 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. [neu] +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 @@ -1259,12 +1280,12 @@ 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. [neu] +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. [neu] +QueueOptions-, Cleanup- und Fehlerverträge oben sind Entscheidungen dieser Library. ## § 14 Metadaten, Middleware und API-Entscheidung @@ -1284,13 +1305,13 @@ RabbitMQ-spezifische Klassen werden nur bei direkter Adapter-Injektion benötigt ### § 14.2 Metadaten außerhalb des fachlichen Payloads -`PublishOptions(metadata: [...])` und `RequestOptions(metadata: [...])` +`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. +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 @@ -1789,3 +1810,60 @@ der vollständige Bericht hat zusätzlich die Felder aus § 16.5): 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 index 876a269..31a8f16 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -255,3 +255,5 @@ 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 index 6845a5b..6bea049 100644 --- a/examples/api-draft/01-connect.php +++ b/examples/api-draft/01-connect.php @@ -25,7 +25,7 @@ function connectDemo(): PhoreMQ return new PhoreMQ(...demoConnection()); } -// 2. Direkter Konstruktor mit allen relevanten Produktionsoptionen. +// 2. Explizite Verbindung: diese Funktion verbindet bereits beim Aufruf. // Zugangsdaten und Signierschlüssel liefert die Anwendung, keine implizite Env-Suche. function connectConfigured(string $username, string $password, string $sharedSecret): PhoreMQ { @@ -57,6 +57,7 @@ function connectByFactory(): PhoreMQ return (new ConnectionFactory())->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. diff --git a/examples/api-draft/02-programmatic.php b/examples/api-draft/02-programmatic.php index 2ac68df..0e918ea 100644 --- a/examples/api-draft/02-programmatic.php +++ b/examples/api-draft/02-programmatic.php @@ -7,122 +7,116 @@ require_once __DIR__ . '/connection.php'; use function Examples\MessageQueue\demoConnection; - use Phore\MessageQueue\PhoreMQ; +use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\ConnectionOptions; use Phore\MessageQueue\Exception\MessageValidationException; use Phore\MessageQueue\Exception\QueueConfigurationMissingException; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\MessageRegistry; +use Phore\MessageQueue\MessageQueueInterface; use Phore\MessageQueue\SubscriptionOptions; +use Phore\MessageQueue\RunOptions; -/** - * API-ENTWURF, noch nicht ausführbar. Proposal §§ 5–6 und 11. - * Beispielaufruf nach Implementierung: demo(). - * Dafür ein frischen Demo-Namespace verwenden: zwei neue Subscriptions werden - * vor dem Publish gebunden. Bei wiederverwendetem Namespace kann Backlog anliegen. - */ - -// Beliebige eigene Klasse: kein gemeinsames SDK und keine Attribute notwendig. +// API-ENTWURF, PHP >=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 demo(): void +function runUserWorker(): void { $registry = new MessageRegistry(); $registry->register('user.created.v1', LocalUserCreated::class, topic: 'users'); - $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(registry: $registry))); - // Nur das hier gezeigte Klassenmapping ergänzen; Verbindung siehe 01-connect.php. - try { - // Array: für diesen Typ validiert der registrierte Contract die Struktur. - $mq->subscribe('users', 'audit-users', function (array $data, MessageContext $context): void { - printf("Audit: %s / %s\n", $context->messageId, $data['userId']); + $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')); - // Eigene lokale DTO-Klasse: Reflection erkennt den ersten Parameter. - // Sender darf andere Klasse/anderen Namespace oder ein Array verwenden. - $mq->subscribe('users', 'billing-users', function (LocalUserCreated $user): void { - printf("Billing: %s / %s\n", $user->userId, $user->email); + $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')); - - // Weiteres Topic im selben Worker, völlig ohne Schema/DTO-Zuordnung. - $mq->subscribe('telemetry', 'audit-telemetry', function (array $data): void { - printf("Telemetry: %s\n", json_encode($data, JSON_THROW_ON_ERROR)); - }); - - // Manuelles Topic und fachlicher Typ; zusätzlicher Schlüssel ist kompatibel. - $mq->publish('users', 'user.created.v1', [ - 'userId' => 'u-123', - 'email' => 'user@example.org', - 'displayName' => 'Optionales neues Feld', - ]); - - // Run-Optionen (Sekunden sind Laufzeiten, keine Pollingintervalle): - // maxMessages: höchstens so viele abgeschlossene fachliche Zustellversuche - // in DIESEM run(), über alle registrierten Subscriptions zusammen. - // Fan-out an zwei Gruppen zählt zweimal; Redelivery zählt erneut. - // Auch behandelte Retry-/Reject-/Validierungsfehler zählen, nicht nur Erfolge. - // Interne Health-/RPC-Replies und leere Polls zählen nicht. - // maxSeconds: Gesamtbudget ab run()-Start, inklusive Warten und Verarbeitung. - // idleTimeoutSeconds: optional; beendet nach so langer zusammenhängender - // Wartezeit ohne fachliche Zustellung. Beginnt beim Eintritt ins Warten neu; - // Handlerlaufzeit zählt nicht als Leerlauf. Beispiel: run(idleTimeoutSeconds: 2). - // Das zuerst erreichte Limit beendet normal: keine Timeout-Exception. - // Laufende synchrone Handler werden nicht hart abgebrochen; maxSeconds kann - // deshalb überschritten werden. Nach Fristablauf startet kein weiterer Handler. - // minMessages gibt es nicht: keine garantierte Mindestzahl erzwingen. - // Ohne gesetztes Limit gilt für diese Grenze unbegrenzt; run() läuft bis stop() - // oder einem Infrastrukturfehler. Limits müssen positiv sein (kein 0/-1). - // Dieselben Felder sind alternativ in RunOptions verfügbar (siehe 05-rpc.php). - // Zwei unabhängige Subscriptions verarbeiten je dieselbe Nachricht. - $mq->run(maxMessages: 2, maxSeconds: 10); // Höchstens 2 Versuche oder 10 s, ggf. weniger. - - // Typobjekt ohne Attribute: publish löst das programmatische Mapping auf. - $user = new LocalUserCreated(); - $user->userId = 'u-456'; - $user->email = 'other@example.org'; - $mq->publish($user); - $mq->run(maxMessages: 2, maxSeconds: 10); // Höchstens 2 Versuche oder 10 s, ggf. weniger. - - // Ohne Contract registrierter Typ: JSON-Daten, keine Schema-Hydration. - $mq->publish('telemetry', 'heartbeat.v1', ['service' => 'billing']); - $mq->run(maxMessages: 1, maxSeconds: 10); // Höchstens 1 Versuch oder 10 s. - - // Aussagekräftiger lokaler Fehler, bevor die Nachricht versendet wird. - try { - $mq->publish('users', 'user.created.v1', ['userId' => 'missing-email']); - } catch (MessageValidationException $exception) { - // Erwartet: user.created.v1: $.email: required property is missing - printf("Ungültige Nachricht: %s\n", $exception->getMessage()); - } + // 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(); } } -// Separates Sender-Beispiel nach Implementierung; der Publisher provisioniert nichts. -// Fehlt users oder die passende Bindung, kann die Anwendung den Zustand anzeigen. -function publishFromFrontend(): void +function publishUserCreated(string $userId, string $email): void { $mq = new PhoreMQ(...demoConnection()); try { - $mq->publish('users', 'user.created.v1', [ - 'userId' => 'u-789', - 'email' => 'user@example.org', - ]); + $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) { - // reason: TOPIC_MISSING oder NO_MATCHING_SUBSCRIPTION. printf("Queue-Konfiguration fehlt für %s / %s (%s).\n", $error->topic, $error->messageType, $error->reason); - echo "Möglicherweise wurde der zuständige Listener-Dienst noch nicht initialisiert.\n"; - // Kein automatisches Neuanlegen oder erneutes Senden. - // Eine vorhandene Queue ohne aktiven Worker löst diesen Fehler nicht aus. + // 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 index 4b2bcf3..d350929 100644 --- a/examples/api-draft/03-attributes.php +++ b/examples/api-draft/03-attributes.php @@ -13,6 +13,7 @@ use Phore\MessageQueue\Attribute\Queue; use Phore\MessageQueue\QueueProfile; use Phore\MessageQueue\PhoreMQ; +use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\SubscriptionOptions; use Phore\MessageQueue\Exception\MessageMappingException; use Phore\MessageQueue\Exception\InvalidHandlerException; @@ -53,54 +54,51 @@ public function onRawCreated(array $data): void } } -function send(MessageQueueInterface $mq): void +function publishUserCreated(MessageQueueInterface $mq): void { $user = new T_UserCreated(); $user->userId = 'u-789'; $user->email = 'sdk-user@example.org'; - $mq->publish($user); // Liest MessageType, validiert und serialisiert. + $mq->publish($user, options: new PublishOptions(reply: false)); // Liest MessageType, validiert und serialisiert. - // Gleichwertige explizite API, etwa für eine andere Anwendung ohne SDK: +} + +// 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 demo(): void +function runAttributeWorker(): void { $mq = new PhoreMQ(...demoConnection()); try { // Attributvariante: Resolver liest Methodensignatur und DTO-Metadaten. $mq->registerHandlers(new UserHandlers()); - send($mq); - // Höchstens 4 Zustellversuch(e) insgesamt oder 10 s Gesamtbudget; erstes Limit gewinnt. - // Normale Rückkehr, keine Mindestzahl/Timeout-Exception; Details in 02-programmatic.php. - $mq->run(maxMessages: 4, maxSeconds: 10); + $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 send() auf seiner eigenen Connection +// 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 demo()/registerHandlers() ausführen. -function demoCallback(): void +// 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. - send($mq); - // Empfangsschleife für registrierte Handler, kein verzögertes publish. - // Höchstens 2 Zustellversuch(e) 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); + $mq->run(); // Alternative zum Attribut-Worker, gleicher Empfangsvertrag. } finally { $mq->close(); } @@ -142,10 +140,10 @@ function demoMultipleTopics(): void // 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); - $mq->publish('audit.billing', 'audit.entry.v1', $entry); + $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 Zustellversuch(e) insgesamt oder 10 s Gesamtbudget; erstes Limit gewinnt. + // 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 { @@ -160,6 +158,7 @@ function demonstrateConflicts(MessageQueueInterface $mq): 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. } @@ -167,6 +166,7 @@ function demonstrateConflicts(MessageQueueInterface $mq): void 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]. } @@ -175,10 +175,11 @@ function demonstrateConflicts(MessageQueueInterface $mq): void 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(); // Lokales Binding lösen; dauerhafte Gruppe bleibt bestehen. + $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 index 02383bb..9369416 100644 --- a/examples/api-draft/04-files-and-local.php +++ b/examples/api-draft/04-files-and-local.php @@ -14,7 +14,6 @@ use Phore\MessageQueue\PayloadStoreInterface; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\PublishOptions; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\SubscriptionOptions; /** @@ -23,13 +22,14 @@ * Eingabe-/Ausgabepfad kommen vom Aufrufer, keine Environment-Reads. */ -function zipDemo(string $zipPath, string $outputPath, PayloadStoreInterface $payloadStore): void +function transferArchive(string $zipPath, string $outputPath, PayloadStoreInterface $payloadStore): void { $mq = new PhoreMQ(...demoConnection(new ConnectionOptions(payloadStore: $payloadStore))); // Ein von Sender und Empfänger erreichbarer Dateispeicher wird ausdrücklich injiziert. // RabbitMQ transportiert nur die verifizierte Referenz; kein eingebauter Dateispeicher. try { + // Lokale Ein-Prozess-Demonstration: zuerst Empfangsvertrag einrichten. $mq->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. @@ -37,14 +37,16 @@ function zipDemo(string $zipPath, string $outputPath, PayloadStoreInterface $pay 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 Zustellversuch(e) insgesamt oder 30 s Gesamtbudget; erstes Limit gewinnt. + // 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(new RunOptions(maxMessages: 1, maxSeconds: 30)); + $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 index 865b447..8c2e681 100644 --- a/examples/api-draft/05-rpc.php +++ b/examples/api-draft/05-rpc.php @@ -14,21 +14,18 @@ use Phore\MessageQueue\Rpc\RemoteException; use Phore\MessageQueue\Attribute\MessageType; use Phore\MessageQueue\PublishOptions; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\Rpc\AwaitOptions; -use Phore\MessageQueue\Exception\ConnectionException; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\Notice; use Phore\MessageQueue\Rpc\RemoteCommandException; use Phore\MessageQueue\Rpc\RequestContext; use Phore\MessageQueue\Rpc\RequestTimeoutException; use Phore\MessageQueue\SubscriptionOptions; -use Phore\MessageQueue\QueueOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal §§ 13–14. * Nach Implementierung: zuerst runServer() in Prozess A starten, - * dann runClient() in Prozess B. Beide nutzen denselben RabbitMQ-Namespace. + * dann divideFromApplication() in Prozess B. Beide nutzen denselben RabbitMQ-Namespace. * RPC-Ablauf (geplante Library, nicht manuell in der Anwendung nachzubauen): * 1. Vor publish: private exklusive Classic-Reply-Queue + eindeutiges Binding * an gemeinsamer interner Exchange anlegen und Reply-Consumer bestätigen lassen. @@ -109,115 +106,134 @@ public function divide(array $params, RequestContext $request): array } } +// 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', queue: QueueOptions::rpc())); - - // Gleichwertige Alternative mit Attributen, NICHT zusätzlich registrieren: - // $mq->registerHandlers(new DivideHandler()); - // maxMessages: maximal 100 Zustellversuche insgesamt, nicht je Subscription. - // maxSeconds: bis zu 60 s Gesamtbudget inklusive Leerlauf; erstes Limit gewinnt. - // Ein laufender synchroner Handler darf noch fertig werden, ggf. über die 60 s. - // Erreichen dieser Grenzen ist normale Rückkehr, keine Timeout-Exception. - // minMessages ist nicht vorgesehen; weniger als 100 Versuche sind zulässig. - $mq->run(maxMessages: 100, maxSeconds: 60); - // Alternativen: run(new RunOptions(maxMessages: 100, maxSeconds: 60)) - // oder run(new RunOptions(maxSeconds: 60), maxMessages: 100). - // Direkte Werte überschreiben dieselben Optionsfelder, ohne das Objekt zu ändern. + 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(); } } -function runClient(): void +// 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 { - // Publish erkennt das DTO und übernimmt Topic/Typ. Sendet sofort. - $sent = $mq->publish(new Divide(12, 3)); - // Andere lokale Arbeit wäre hier möglich; der Broker hat die Nachricht bereits. - // timeoutSeconds: maximal 5 s LOKALES Warten ab diesem await-Aufruf, - // begrenzt durch die verbleibende, schon beim Publish gesetzte Antwortfrist. - // Bereits vorhandene finale Antwort: sofortige Rückgabe ohne neue Sendung. - // Kein finales Result/Error bis dahin: RequestTimeoutException (catch unten), - // niemals null/false oder ein leeres scheinbar erfolgreiches Reply. - $reply = $sent->await(timeoutSeconds: 5); - printf("Ergebnis: %s\n", $reply->payload['quotient']); // 4 - - // Gleicher Aufruf in einer Zeile; await sendet NICHT erneut. - $reply = $mq->publish(new Divide(15, 3))->await(timeoutSeconds: 5); - - // Ohne Warten: ignoriertes SendResult verzögert oder verhindert das Senden nicht. - $mq->publish(new Divide(20, 4)); - // Der Responder arbeitet trotzdem; seine nicht benötigte Antwort läuft ab/wird verworfen. - - // Programmatische Variante, Metadaten vor Publish, Warteoptionen erst bei await. - $pending = $mq->publish('calculator', 'math.divide.v1', ['a' => 12, 'b' => 0.5], new PublishOptions( - reply: true, // Rückkanal zwingend: fehlende Konfiguration scheitert VOR Publish. - replyTimeoutSeconds: 15, // Wire-Deadline ab Senden; await verlängert sie nicht. - metadata: ['app.traceId' => 'trace-demo-42', 'app.locale' => 'de-DE'], - )); - $reply = $pending->await(new AwaitOptions(timeoutSeconds: 10), - timeoutSeconds: 5, // Direkter Wert überschreibt hier die 10 Sekunden. - // onNotice: Zwischenmeldungen ausgeben; sie beenden await nicht und - // setzen weder die lokale Wartefrist noch die Remote-Deadline zurück. + $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); }, ); - printf("Ergebnis: %s, Worker: %s\n", $reply->payload['quotient'], $reply->metadata['app.worker']); - // reply->notices enthält die finale Zusammenfassung; nicht doppelt ausgeben. - // responseClass: lokale DTO-Klasse für reply->payload; vor Rückgabe strukturell - // prüfen/hydrieren. Ohne Angabe Array; benötigt bei DTOs die Schema-Bridge. - // Beispiel: await(responseClass: LocalResult::class). - // Reine Events können mit PublishOptions(reply: false) ohne Antwortaufwand senden. - - // 1. Allgemeine Fehlerbehandlung: kein Fehler-Topic abonnieren erforderlich. - try { - $mq->publish(new Divide(12, 0))->await(timeoutSeconds: 5); - } catch (RemoteCommandException $error) { - // Kein lokales Mapping: generische Exception, aber gleiche freigegebene Meldung. - printf("Remote-Fehler [%s]: %s\n", $error->errorCode, $error->getMessage()); - } + // 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(); + } +} - // 2. Gewünschte lokale Exception-Klasse ausdrücklich freigeben. +// 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 { - $mq->publish(new Divide(12, 0))->await( - timeoutSeconds: 5, - errorTypes: [DivisionByZero::class], - ); - } catch (DivisionByZero $error) { - // Gleiche SDK-Klasse und Meldung wie auf dem Server, lokal neu erzeugt. - printf("Division korrigieren: %s\n", $error->getMessage()); - } catch (RemoteCommandException $error) { - // Unbekannter anderer Fehler bleibt ein generischer Remote-Fehler. - printf("RPC fehlgeschlagen: %s\n", $error->getMessage()); + $reply = $sent->await(timeoutSeconds: 2); + } catch (RequestTimeoutException) { + $reply = $sent->await(timeoutSeconds: 5); // Kein zweiter Request. } - // Alternativ await(new AwaitOptions(errorTypes: [DivisionByZero::class])). - // Der Client darf auch eine andere lokale Klasse mit demselben RemoteError-Namen - // registrieren. Keine entfernten PHP-Klassennamen, Stacktraces oder unserialize(). - - } catch (ConnectionException $connectionError) { - // Transportausfall: offene Calls dieser Connection sind nicht fortsetzbar. - // Kein automatisches erneutes publish; bei Schreiboperationen erst den - // persistenten operationId-/Ergebnisstatus prüfen. Supervisor darf neu starten. - throw $connectionError; - } catch (RequestTimeoutException $timeout) { - // await wirft RequestTimeoutException bei abgelaufener lokaler Wartefrist - // oder ursprünglicher Antwortdeadline ohne rechtzeitig empfangenes finales Ergebnis. - // Der Fehler enthält requestId. Er ist kein RemoteCommandException: - // ein bekannter fachlicher Fehler des Responders ist eine andere Ursache. - // Ein Timeout stoppt das entfernte Command NICHT und sendet es nicht erneut. - // Falls die ursprüngliche Antwortfrist noch läuft, kann derselbe SendResult - // erneut await() aufrufen. Nicht publish() wiederholen: das wäre ein neuer Job. - printf("Keine rechtzeitige Antwort für Request %s\n", $timeout->requestId); + 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(); } } -// request(...)->await() bleibt eine explizite RPC-Komfortform (Beispiel 08). -// subscribe-Handler antworten nicht automatisch: Ohne respond endet await im Timeout. +// 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 index dcb976e..f2bebbc 100644 --- a/examples/api-draft/06-metadata-middleware.php +++ b/examples/api-draft/06-metadata-middleware.php @@ -15,16 +15,15 @@ use Phore\MessageQueue\Middleware\OutgoingMessage; use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\PublishReceipt; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 14. * Zwei optionale Callable-Hooks, keine Middleware-Basisklasse erforderlich. - * Beispielaufruf: demo($traceId), mit frischem Demo-Namespace. + * Beispielaufruf: processOrderWithDiagnostics($traceId), mit frischem Demo-Namespace. */ -function demo(string $traceId): void +function processOrderWithDiagnostics(string $traceId): void { // Separate Connection ohne Diagnose-Middleware verhindert Fehlerschleifen. $diagnostics = new PhoreMQ(...demoConnection()); @@ -52,6 +51,7 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ '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', @@ -88,12 +88,13 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ // 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 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // 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(new RunOptions(maxMessages: 1, maxSeconds: 5)); + $mq->run(maxMessages: 1, maxSeconds: 5); // Eine Warning direkt als gewöhnliches Event versenden: keine neue API nötig. $diagnostics->publish('diagnostics', 'diagnostic.v1', [ @@ -101,15 +102,19 @@ static function (mixed $payload, MessageContext $context, callable $next) use ($ 'code' => 'OPTIONAL_DATA_MISSING', 'message' => 'Optionale Auftragsdaten fehlen.', ], new PublishOptions( + reply: false, correlationId: 'request-42', metadata: ['app.traceId' => $traceId], )); - // Höchstens 1 Zustellversuch(e) insgesamt oder 5 s Gesamtbudget; erstes Limit gewinnt. + // 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(new RunOptions(maxMessages: 1, maxSeconds: 5)); + $diagnostics->run(maxMessages: 1, maxSeconds: 5); - // Für den Fehlerpfad oben: payload ['orderId' => 'order-43', 'mode' => 'reject']. - // Der Handlerfehler bleibt erhalten; die Middleware sendet separat level=error. + // 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(); diff --git a/examples/api-draft/07-broadcast-locking.php b/examples/api-draft/07-broadcast-locking.php index 058dcdb..9e2a74a 100644 --- a/examples/api-draft/07-broadcast-locking.php +++ b/examples/api-draft/07-broadcast-locking.php @@ -12,14 +12,13 @@ use Phore\MessageQueue\Exception\RejectMessageException; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\PublishOptions; -use Phore\MessageQueue\RunOptions; use Phore\MessageQueue\SubscriptionOptions; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 15.2. - * Zuerst zwei Prozesse mit runParticipant(..., 'service-a', $localLocksA) - * und runParticipant(..., 'service-b', $localLocksB) starten/provisionieren. - * Danach EINEN Koordinator mit withAllLocks(..., ['service-a', 'service-b'], $work). + * Zuerst zwei Prozesse mit runParticipant('service-a', $localLocksA) + * und runParticipant('service-b', $localLocksB) starten/provisionieren. + * Danach EINEN Koordinator mit withAllLocks(['service-a', 'service-b'], $work). * Jede Instanz, die antworten soll, braucht eine eigene Subscription/Teilnehmer-ID. * Kooperative Entwicklungsakteure: Produktions-Identitätsprüfung siehe Proposal. */ @@ -76,7 +75,7 @@ static function (array $command, MessageContext $context) use ($mq, $participant 'participant' => $participantId, 'held' => $held, 'leaseUntil' => $command['leaseUntil'], - ], new PublishOptions(correlationId: $command['roundId'])); + ], new PublishOptions(reply: false, correlationId: $command['roundId'])); // Erst erfolgreiche Rückkehr bestätigt Acquire; bei Retry bleibt // tryAcquire mit derselben Runde idempotent. }); @@ -139,19 +138,19 @@ static function (array $state, MessageContext $context) use ( $rejected = true; } if ($rejected || count($states) === count($participants)) { - $mq->stop(); + $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(correlationId: $roundId)); + 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(new RunOptions(maxSeconds: $remaining)); + $mq->run(maxSeconds: $remaining); } if ($rejected || count($states) !== count($participants) @@ -166,7 +165,7 @@ static function (array $state, MessageContext $context) use ( try { // Auch bei negativer Antwort/Timeout teilweise erworbene Locks freigeben. $mq->publish('maintenance.locks', 'lock.release.v1', $command, - new PublishOptions(correlationId: $roundId)); + 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.'); diff --git a/examples/api-draft/08-processing-workers.php b/examples/api-draft/08-processing-workers.php index 4089633..fb721e5 100644 --- a/examples/api-draft/08-processing-workers.php +++ b/examples/api-draft/08-processing-workers.php @@ -11,9 +11,10 @@ use Phore\MessageQueue\PhoreMQ; use Phore\MessageQueue\Rpc\CommandFailedException; use Phore\MessageQueue\Rpc\RequestContext; -use Phore\MessageQueue\Rpc\RequestOptions; +use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\SubscriptionOptions; -use Phore\MessageQueue\QueueOptions; +use Phore\MessageQueue\Rpc\RemoteCommandException; +use Phore\MessageQueue\Rpc\RequestTimeoutException; /** * API-ENTWURF, noch nicht ausführbar. Proposal § 15.3. @@ -42,7 +43,7 @@ static function (array $job, RequestContext $request) use ($workerId): array { // Reine, wiederholbare Verarbeitung ohne externe Seiteneffekte. return ['normalized' => $text, 'bytes' => strlen($text), 'sha256' => hash('sha256', $text)]; - }, new SubscriptionOptions(type: 'text.process.v1', queue: QueueOptions::rpc())); + }, new SubscriptionOptions(type: 'text.process.v1')); $mq->run(); } finally { $mq->close(); @@ -58,7 +59,7 @@ function submitJobs(): void // 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 RequestOptions(timeoutSeconds: 15)); + new PublishOptions(replyTimeoutSeconds: 15)); } foreach ($pending as $call) { @@ -66,14 +67,19 @@ function submitJobs(): void // 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. - $reply = $call->await(); // Dispatcher ordnet auch frühere Antworten korrekt zu. + 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']); } - // Jeder Job hat ein Ergebnis eines Workers. Es ist zulässig, dass ein + // 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(); diff --git a/examples/api-draft/09-system-check.php b/examples/api-draft/09-system-check.php index 6ec1246..653e89e 100644 --- a/examples/api-draft/09-system-check.php +++ b/examples/api-draft/09-system-check.php @@ -105,11 +105,9 @@ function frontendConnection(string $instanceId): MessageQueueInterface function checkAtLogin(MessageQueueInterface $mq): array { - $connection = $mq->check(); // Ausschließlich Broker/lokale Konfiguration. - // Alle deklarierten Anforderungen in EINEM begrenzten Check, pro Ziel mit Listenerliste. - $system = $mq->check(options: new CheckOptions(requireDeclared: true, timeoutSeconds: 3)); - // Alternativ nur die Exportfunktion; Anforderungen werden aus Konfiguration übernommen: - $export = $mq->check('jobs.export', 'export.create.v1')->toArray(); + // 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' => [ @@ -120,11 +118,17 @@ function checkAtLogin(MessageQueueInterface $mq): array 'code' => $issue['code'], 'message' => $issue['publicMessage'], ], $export['issues']), ]]]; - // $system->toArray()['targets'] enthält zusätzlich jedes benötigte Topic/Typ-Paar. - // Jeder Zielbericht enthält consumers samt readiness, issues und optional diagnostics. - // Im echten Loginpfad einen passenden Check wählen; alle drei dienen hier dem API-Vergleich. } +// 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', diff --git a/examples/api-draft/10-callback-errors.php b/examples/api-draft/10-callback-errors.php index b8e8b89..c9773cb 100644 --- a/examples/api-draft/10-callback-errors.php +++ b/examples/api-draft/10-callback-errors.php @@ -9,6 +9,7 @@ use function Examples\MessageQueue\demoConnection; use Phore\MessageQueue\PhoreMQ; +use Phore\MessageQueue\PublishOptions; use Phore\MessageQueue\MessageContext; use Phore\MessageQueue\SubscriptionOptions; use Phore\MessageQueue\QueueOptions; @@ -19,10 +20,10 @@ /** * API-ENTWURF, noch nicht ausführbar. Verbindungsvorgaben: 01-connect.php. - * demo() mit einem frischen Demo-Namespace aufrufen. + * processFailureScenarios() mit einem frischen Demo-Namespace aufrufen. * Dieses Beispiel verändert keine externen Daten; echte Jobs brauchen Idempotenz. */ -function demo(): void +function processFailureScenarios(): void { $mq = new PhoreMQ(...demoConnection()); try { @@ -31,6 +32,9 @@ function demo(): void // 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.'); @@ -47,9 +51,9 @@ function demo(): void maxAttempts: 4, retryDelaySeconds: 2, // Erstversuch zählt mit; jeweils 2 s warten. ))); - $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'temporary']); - $mq->publish('jobs.demo', 'demo.process.v1', []); - $mq->publish('jobs.demo', 'demo.process.v1', ['mode' => 'unexpected']); + $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. diff --git a/examples/api-draft/11-queue-options.php b/examples/api-draft/11-queue-options.php index 41e6bf6..15c3dda 100644 --- a/examples/api-draft/11-queue-options.php +++ b/examples/api-draft/11-queue-options.php @@ -39,7 +39,7 @@ 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('EMPTY_TEXT', 'Der Text darf nicht leer sein.'); + throw new CommandFailedException(errorCode: 'EMPTY_TEXT', publicMessage: 'Der Text darf nicht leer sein.'); } return ['text' => trim($command->text)]; } @@ -63,9 +63,13 @@ 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) || $command['text'] === '') { - throw new CommandFailedException('EMPTY_TEXT', 'Ein nicht leerer Text wird benötigt.'); + 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( @@ -90,7 +94,7 @@ function runProgrammaticWorker(): void } } -function runEvents(): void +function runLiveScreen(): void { $mq = new PhoreMQ(...demoConnection()); try { @@ -99,9 +103,7 @@ function runEvents(): void }, 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. - // retentionSeconds: 300 würde eine dauerhafte Subscription mit maximal - // 5 Minuten Wartezeit erzeugen. Dafür jeder Empfängergruppe eigenen Namen - // geben; gleiche Namen teilen dann Arbeit. Kein Replay für neue Gruppen. + // Dauerhafte Gruppe mit Offline-Puffer: runRetainedScreen() unten. $mq->run(); } finally { $mq->close(); @@ -129,3 +131,18 @@ function runWithDefaults(): void $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 index 2d759af..2dcc5ee 100644 --- a/examples/api-draft/connection.php +++ b/examples/api-draft/connection.php @@ -8,6 +8,10 @@ /** * API-ENTWURF: ConnectionOptions::fromArray ist noch nicht implementiert. + * Gibt nur [DSN, Optionen] zurück: kein Connect, keine Topologieanlage. + * new PhoreMQ(...demoConnection()) verbindet anschließend sofort. + * Die Demo aktiviert RPC: publish kann deshalb später await unterstützen. + * Reine Events setzen in den Beispielen ausdrücklich reply: false. * Einmalige Demo-Konfiguration für alle Beispiele; keine Environment-Reads. * Explizite Felder in $overrides ergänzen/überschreiben die Basisoptionen; * ausgelassene Felder behalten die Werte aus der Konfigurationsdatei.