Verified against Semitexa Ultimate 2026.09.19.1020
Runtime State Lifecycle
Semitexa runs as a long-lived Swoole worker process: the container is built once at boot and serves N requests before the worker is restarted. That model is fast — but it means any module-level static state lives across requests by default, and getting the lifecycle wrong is a class of bug that looks fine in unit tests and breaks in production once a worker has been alive for a few minutes.
This page is the definitive taxonomy. It explains, with framework-class examples, which kinds of state must reset every request, which must survive every request, and which only reset on demand from tests or admin tools.
The four state classes
Every mutable static store in the framework falls into exactly one of these classes.
1. Per-request state — must reset after every request and queued message
Holds data that is about the current request — the active HTTP Request, the authenticated principal, decisions cached for the current user. Carrying any of this into the next request is a security bug (one user's principal answering another user's question) or a correctness bug (a stale request reused after the connection closed).
Members of this class self-register a clear() callback with Semitexa\Core\Lifecycle\PerRequestStateRegistry. The framework calls PerRequestStateRegistry::resetAll() in the finally block of Application::handleRequest and QueueWorker::processPayload, so the reset happens on every code path — including 4xx, 5xx, and uncaught exceptions.
| Class | Registry name | Notes |
|---|---|---|
Semitexa\Core\Lifecycle\CurrentRequestStore |
current_http_request_store |
Active Request, set by PreHydrationAuthGate so AuthHandlers can read headers before payload hydration. |
Semitexa\Rbac\Application\Service\RbacDecisionCache |
rbac_decision_cache |
Resolved per-user grant set. Coroutine-local in Swoole, static fallback in CLI. |
Semitexa\Auth\Context\AuthContextStore |
auth_context_store |
Authenticated principal + AuthResult. Coroutine-local in Swoole, static fallback in CLI/queue/tests. |
Semitexa\Tenancy\Context\TenantContextStore |
tenant_context_store |
Active TenantContext. Coroutine-local in Swoole, static fallback in CLI/queue. Both set() and setFallback() are per-execution APIs; the framework wipes after every request and queued message. |
Adding to this class is a deliberate choice: the new store must be unsafe to carry across requests and must already have a cheap clear() operation. Self-register lazily on the first set() call so workers that never touch the store pay zero overhead.
2. Per-worker state — must survive across requests within a worker
Holds data that is about the worker process, not about a single request. Replay/idempotency sets, immutable metadata caches, connection pools, route discovery results. Resetting any of this between requests would either break correctness (replay protection clears, second copy of an event executes twice) or destroy Semitexa's "build once, serve N" performance model (re-discovering routes on every request).
Members of this class do not register with PerRequestStateRegistry. They live in module-level static properties that survive for the worker's lifetime.
| Class | Why it must survive |
|---|---|
Semitexa\Webhooks\Auth\InMemoryWebhookReplayStore |
Replay detection is meaningless if the seen-set clears every request. The whole point is "have I seen this event_id already in my lifetime as a worker?" |
Semitexa\Core\Discovery\AttributeDiscovery |
Route table is derived from the codebase; never changes mid-process. |
Semitexa\Core\Discovery\ClassDiscovery |
Composer classmap. Worker reads it once at boot. |
Semitexa\Core\Pipeline\HandlerReflectionCache |
Per-handler reflection. Re-deriving on every request would dominate the request budget. |
Semitexa\Authorization\Application\Service\PayloadAccessPolicyResolver |
Per-class access metadata. Immutable for the lifetime of the loaded class. |
In tests these stores still need a deterministic empty starting point — see Class 4 (test-only resettable) for how that's wired.
3. Persistent / shared state — survives across workers and process restarts
Lives in a database, Redis, queue broker, or other external system. Survives worker restarts, deploys, and full process replacement. Examples: MachineCredential repository, the production webhook replay store (DB-backed), the outbound delivery repository, the queue itself, the event ledger.
The framework holds no opinion on the lifecycle of these stores — they're owned by their backing service. Tests reset them via transaction rollback or schema teardown, not in-process state clearing.
4. Test-only resettable state — never reset by the framework, reset on demand
A subset of Class 2 (per-worker state) plus the demo grant stores. These survive across requests in production (because Class 2 says they must), but tests need a known-empty starting point between independent test runs.
Members of this class self-register with Semitexa\Core\Lifecycle\TestStateResetRegistry. Tests call TestStateResetRegistry::resetAllForTesting() in setUp / tearDown. The framework never calls this registry from the request lifecycle.
| Class | Why test-resettable, not per-request |
|---|---|
Semitexa\Webhooks\Auth\InMemoryWebhookReplayStore |
Per-worker by design (Class 2); tests use the shared registry to get one-call cleanup alongside demo stores. |
Semitexa\Modules\AuthDemo\Application\Service\AuthDemoPermissionStore |
Demo grant store — production grants come from a real repository. Tests seed/clear per scenario. |
Semitexa\Modules\AuthDemo\Application\Service\AuthDemoCapabilityStore |
Same pattern, capability domain. |
Semitexa\Modules\WebhookDemo\Application\Service\WebhookDemoEventStore |
Side-effect ledger for idempotency tests. Surviving across requests is the point. |
Semitexa\Modules\WebhookDemo\Application\Service\WebhookDemoServiceCapabilityStore |
Service-domain capability seeds for webhook tests. |
The two registries are separate on purpose. A code search for PerRequestStateRegistry::resetAll should never find a Class-2 store, and a search for TestStateResetRegistry::resetAllForTesting should never appear inside Application or QueueWorker. The naming makes accidental cross-wiring loud.
What runs the per-request reset, and when
Two framework code paths run the per-request reset chain:
Semitexa\Core\Application::handleRequest
Wraps the entire request pipeline (tenancy, session, locale, route execution) in a try { … } finally { … }. The finally block runs three resets in this exact order:
} finally {
PerRequestStateRegistry::resetAll(); // 1. registered per-request stores
$this->requestScopedContainer->reset(); // 2. request-scoped container cache + ExecutionContext
CoroutineLocal::endRequest(); // 3. coroutine-local CLI fallback
}
The order is intentional:
PerRequestStateRegistry::resetAll()first — registered callbacks (RbacDecisionCache,CurrentRequestStore,AuthContextStore, future ones) may still need to read request-scoped services during their cleanup. Running this first guarantees the request-scoped container is still alive when callbacks fire.requestScopedContainer->reset()second — wipes the per-request cache (Request, Session, CookieJar, Auth/Tenant/Locale context) and tells the underlyingSemitexaContainerto clear itsExecutionContext(per-coroutine in Swoole, CLI fallback in tests/queues). After this point no service can resolve a per-request binding.CoroutineLocal::endRequest()last — generic cross-cutting CLI-fallback cleanup. May be touched by any of the above resets.
The whole chain runs even when:
- the handler throws an uncaught exception
- the route returns 4xx (unauthorized, validation failure)
- the route returns 5xx (mapped exception, internal error)
- the lifecycle short-circuits via an early response (tenancy redirect, locale redirect)
- a per-request reset callback throws (callbacks are individually
try/catch-wrapped — see "Reset-callback failures are isolated" below)
Semitexa\Core\Queue\QueueWorker::processPayload
Same try { … } finally { PerRequestStateRegistry::resetAll(); CoroutineLocal::endRequest(); } wrap, but the queue worker does NOT instantiate Application and does NOT use the request-scoped container — it processes messages through its own handler-resolution path. So requestScopedContainer->reset() is not part of the queue lifecycle by design. The per-message contract is "static stores reset, coroutine-local cleared" — sufficient because no Request/Session/CookieJar object exists in the queue path.
If you add a third entry-point (background task runner, scheduled job processor), match whichever of the two shapes applies — HTTP-style if it instantiates Application, queue-style if it doesn't. Add a regression test in packages/semitexa-core/tests/Integration/RequestScopedContainerLifecycleTest.php (HTTP-style) or packages/semitexa-core/tests/Integration/StateLifecycleTest.php (queue-style) that asserts the new entry-point clears the right registries.
What runs the test-only reset, and when
TestStateResetRegistry::resetAllForTesting() is called only from:
- Test
setUp/tearDownfor any test that touches demo stores or the in-memory replay store.
- Future dev/admin commands that explicitly want to reset worker state (e.g.
bin/semitexa dev:reset-state).
There is no production code path that calls it. The method name (resetAllForTesting, not resetAll) is part of the contract — a confused caller cannot mistake it for the per-request hook.
Root container vs request-scoped container
Two distinct containers exist at runtime:
Root container — Semitexa\Core\Container\SemitexaContainer
Built once per worker by ContainerFactory::create(). Holds:
- Framework services (router, discovery, attribute scanners, exception mapper).
- Module bindings (repositories, services, payload handlers as prototypes).
- Worker-scoped readonly singletons (e.g.
OrmManager,ConnectionRegistry,RedisConnectionPool).
Execution-scoped classes — anything marked #[ExecutionScoped] or implied (#[AsPayloadHandler], #[AsEventListener], #[AsPipelineListener], #[AsAuthHandler]) — live in the root container as prototypes. Each get() returns a fresh clone of the prototype with mutable properties injected from the current ExecutionContext. There is no per-request instance cache for these; the same prototype is cloned on demand.
Never reset the root container per request. It would defeat Swoole's "build once, serve N requests" model.
Request-scoped container — Semitexa\Core\Container\RequestScopedContainer
Wraps the root container with a per-request cache. Holds the request-scoped instances that are unique to the current request:
Semitexa\Core\Request(the active HTTP request)Semitexa\Core\Session\SessionInterfaceSemitexa\Core\Cookie\CookieJarInterfaceSemitexa\Core\Tenant\TenantContextInterfaceSemitexa\Core\Auth\AuthContextInterfaceSemitexa\Core\Locale\LocaleContextInterface- Anything else a phase has explicitly
set()-ed for the current request.
Setting these triggers SemitexaContainer::setExecutionContext(...), which writes them into a coroutine-local store the cloned execution-scoped services read from at injection time.
Application::handleRequest's finally block calls requestScopedContainer->reset() to wipe both the cache and the underlying ExecutionContext. In Swoole HTTP mode every request creates a new Application() (so a fresh RequestScopedContainer), but the same Application instance handles many requests in CLI / queue / test mode — without the explicit reset, the previous request's bindings would still be observable via requestScopedContainer->get().
What belongs in request scope
- The five framework context interfaces above and any per-request value derived from them.
- Anything that holds current request identity (request id, headers, current user/tenant/locale).
What must never be request-scoped
- Connection pools (
RedisConnectionPool,OrmManager) — re-acquiring a connection per request defeats pooling. - Discovery, reflection, route, and access-policy caches — derived from the codebase, not request data.
- Replay/idempotency stores — must outlive a single request.
- Persistent repositories — owned by their backing service.
How to test request-scoped services
Two approaches, both pin the same contract:
- Dispatch through
Application::handleRequestand observe the request-scoped container before and after.$app->requestScopedContainer->has(Request::class)returnsfalseoncehandleRequestreturns. Seepackages/semitexa-core/tests/Integration/RequestScopedContainerLifecycleTest.phpfor the canonical patterns. - Add a tiny
#[ExecutionScoped]probe service that records its instance id (spl_object_id($this)) on construction and exposes a getter. Resolve it from the request-scoped container in two sequential dispatches and assert the id differs — proves the framework returns a fresh clone per execution rather than reusing the previous request's instance.
Swoole vs CLI: why this matters more than it looks
In Swoole HTTP mode, every request runs in its own coroutine. Swoole\Coroutine::getContext() is automatically destroyed when the coroutine ends, so any state stored via CoroutineLocal or Coroutine::getContext() directly is auto-cleaned with no framework involvement.
In CLI mode (PHPUnit, queue worker, console commands), there is no coroutine context. Class-1 stores fall through to a static fallback (AuthContextStore::$fallbackUser, RbacDecisionCache::$staticFallback, etc.). Without PerRequestStateRegistry, that fallback would persist across Application::handleRequest calls inside the same process — invisible in production Swoole, blatant in tests and queue workers.
AuthContextStore is the canonical example of why every Class-1 store needs explicit registration, not just coroutine-local storage: in CLI / queue / test mode the static fallback would persist across requests indefinitely without a PerRequestStateRegistry::resetAll() hook. The store self-registers on first set, so the framework wipes the fallback deterministically.
Adding a new stateful store
Use this checklist:
- Pick a class (1, 2, 3, or 4) by answering "what would happen if this state survived to the next request?"
- Class 1: register with
PerRequestStateRegistrylazily on firstset(). Provide a cheapclear(). - Class 2: do nothing extra — module-level static state survives by default.
- Class 3: persistence layer is responsible; framework has no hook.
- Class 4: register with
TestStateResetRegistrylazily on first write. Production code never calls the test registry. - Add a lifecycle test in
packages/semitexa-core/tests/Integration/StateLifecycleTest.phpfor the new store. Pin both directions: "must reset" or "must survive".
Tenant context lifecycle
Semitexa\Tenancy\Context\TenantContextStore exposes three writers:
set(TenantContextInterface $context)— primary per-request API, used byTenancyPhaseafter the resolver picks a tenant. Locks the context as immutable for the rest of the request when called inside a Swoole coroutine.setFallback(TenantContextInterface $context)— CLI / queue worker entry point, used byTenantAwareJobSerializer::unwrapAndRestore()to restore tenant identity for a queued job, and byTenantRunCommandviaswapFallback()for the CLItenant:runflow.swapFallback(?TenantContextInterface $context)— atomic swap returning the previous value; for tenant-switching dev tools that want to restore the previous tenant on exit.
Despite the naming, all three are per-execution APIs. There is no scenario in production code today where a tenant identity is meant to persist past the unit of work that established it. Three production call sites confirm this:
TenancyPhaseresolves the tenant per HTTP request and callsset(). The next request resolves again.TenantAwareJobSerializer::unwrapAndRestore()callssetFallback()per queued job. The next job calls it again with its own tenant.TenantRunCommandcallsset()on entry and clears in its ownfinally. The framework wipe between any nestedApplication::handleRequestdispatches is harmless — the inner request would resolve its own tenant via the resolver chain.
TenantContextStore self-registers with PerRequestStateRegistry so the framework wipes both the coroutine-local context and the static fallback after every Application::handleRequest and QueueWorker::processPayload. Without this, tenant A's request could leave its TenantContext in the static fallback for a later anonymous request — a multi-tenant safety bug in CLI / queue / test mode where the same Application instance handles many requests.
In Swoole HTTP mode, Coroutine::getContext() already auto-cleans per request, so the leak surfaces only in CLI / queue / test environments. The per-request registration covers both axes.
Locale context lifecycle
Semitexa\Locale\Context\LocaleContextStore is not registered with PerRequestStateRegistry. The framework's LocalePhase runs unconditionally on every dispatch and calls LocaleBootstrapper::resolve(), which always re-initializes setLocale($defaultLocale), setFallbackLocale(...), setUrlPrefixEnabled(...), and setDefaultLocale(...) from configuration. Each request's locale is therefore overwritten before any handler runs — the previous request's locale cannot be observed.
This is correct but relies on LocalePhase always running. If a future entry-point bypasses LocalePhase (e.g. a custom queue path that calls LocaleContextStore::setLocale('uk') directly), the static fallback would persist across executions. If that pattern emerges, the fix is the same as CurrentRequestStore / AuthContextStore / TenantContextStore: lazy self-registration with PerRequestStateRegistry.
Tenant-aware RBAC: cache key
Semitexa\Rbac\Application\Service\RbacDecisionCache keys are composed by SubjectGrantResolver::cacheKey($subjectType, $identifier, $tenantId). The format is:
{tenantId|'-'}:{subjectType}:{identifier}
Tenant id sits first so a future per-tenant cache scan / invalidation can prefix-match cheaply. null tenant — CLI tasks without tenant:run, single-tenant deployments, system tasks — is encoded as a literal - so untenanted entries cannot accidentally compare equal to a legitimately tenanted entry whose id happens to be empty. Subject type sits second so a User principal partner-x and a Service principal partner-x continue to occupy distinct cache slots (cycle 10's collision fix).
The resolver reads the active tenant via Semitexa\Core\Tenant\TenantContextStoreInterface::tryGet() and the cross-package OrganizationLayer surface — no dependency on the concrete Semitexa\Tenancy\Context\TenantContext. That keeps semitexa-rbac decoupled from semitexa-tenancy.
The same tenant id is forwarded to ServiceCapabilityProviderInterface::getCapabilitiesForService($serviceId, $tenantId) so production providers can scope grants by tenant in the same call. Providers that ignore the parameter remain backward-compatible (the demo provider does so when registering grants via setForService()); providers that use it (the demo provider's setForServiceInTenant() path) prove cross-tenant isolation in tests like TenantAwareWebhookPipelineTest::service_capability_grant_in_tenant_A_does_not_authorize_tenant_B.
Tenant-aware webhook pipeline
The webhook package holds tenant identity in three places, each with a distinct tenant-safety contract:
| Surface | Tenant scoping |
|---|---|
webhook_endpoint_definitions.tenant_id |
Column populated; lookup findByEndpointKey() is currently global. Operators must keep endpoint keys unique within their deployment OR register per-tenant lookup paths. Documented gap. |
webhook_inbox.tenant_id / webhook_outbox.tenant_id |
Column populated end-to-end from the resolved endpoint. webhook:cleanup --tenant=<id> filters deletes by this column. The unique constraints (dedupe_key for inbox; (endpoint_definition_id, idempotency_key) for outbox) inherit isolation from the dedupe-key composition (which prefixes tenant) and from per-tenant endpoint definition ids. |
webhook_replay_keys |
No tenant_id column. Tenant is folded into the key STRING via WebhookReplayKeyFactory::compose($namespace, $eventId, $tenantId) → tenant:{id}:{namespace}:{eventId}. Cross-backing safety: the same shape works for in-memory, Redis (SET NX EX), and MySQL (INSERT IGNORE) without backing-specific schema changes. |
webhook_attempts |
No tenant_id column. Audit log scoped by parent inbox/outbox id. webhook:cleanup --tenant=<id> skips attempts cleanup; operators run a separate global pass. |
Tenant in webhook authentication
WebhookAuthHandler reads the active tenant via the framework's TenantContextStoreInterface (resolved by TenancyPhase BEFORE the auth phase runs) and stamps it on WebhookPrincipal::$tenantId. The principal's getId() stays the receiver name only — the subject identity is the receiver, not the tenant — but the tenant is now visible to:
- Replay key construction:
SignedWebhookHandlercomposes its replay key viaWebhookReplayKeyFactory::compose(...)with the principal's tenant id, so two tenants posting the sameX-Webhook-Event-Idto the same receiver no longer collide on a shared replay store. - Secret resolution:
WebhookSignatureVerifiercallsWebhookSecretResolverInterface::resolve($secretRef, $tenantId). The defaultEnvWebhookSecretResolveris tenant-blind (env var → secret) for back-compat with single-tenant deployments and demo modules. Multi-tenant production deployments wire their own resolver to look up per-tenant secrets from a vault / secrets manager — no other code change required. - Audit logs / handler layers: every consumer that reads the principal sees the tenant id without re-querying the store.
Inbound dedupe key
InboundDedupeKeyFactory::generate(...) already prefixes its output with tenant:{id}: when a tenant id is provided. Two tenants posting the same provider event id to the same endpoint produce distinct dedupe_key strings, so the unique constraint on webhook_inbox.dedupe_key enforces per-tenant isolation without requiring (tenant_id, dedupe_key) to be in the index.
Outbound idempotency
The unique constraint is (endpoint_definition_id, idempotency_key). Two tenants registering distinct endpoint definitions (different UUID endpoint_definition_id) are isolated by construction. Two tenants sharing a single endpoint definition (intentional or accidental) are NOT isolated by the constraint — the same idempotency_key collapses into one delivery. Production deployments either:
- give each tenant its own
endpoint_definition_id(recommended); - fold tenant id into
idempotency_keyat the publisher; - treat the shared endpoint as intentionally global.
Worker claim and endpoint lookup — currently global, deferred
OutboundDeliveryRepository::claimAndLease() does NOT filter by tenant. One global worker pool drains all tenants' outboxes — delivery payloads carry their own tenant_id as metadata, but the worker's claim picks up the next due row regardless of tenant. Same for WebhookEndpointDefinitionRepository::findByEndpointKey() — it returns the first match. Both are intentionally deferred (cycle 24): per-tenant workers and per-tenant endpoint lookup require schema decisions (UNIQUE (tenant_id, endpoint_key) vs operator-managed uniqueness) that warrant their own focused cycle. TenantAwareWebhookPipelineTest::worker_claim_and_endpoint_definition_lookup_remain_global_today pins the current contract so a future change does not happen silently.
webhook:cleanup --tenant
bin/semitexa webhook:cleanup --tenant=<id> restricts the per-table delete to rows whose tenant_id matches (inbox + outbox) or whose key carries the tenant:{id}: prefix (replay keys). webhook_attempts is global only — tenant-scoped runs surface a — (skipped in tenant-scoped run) cell for that table and emit a note prompting the operator to run a separate global pass. Dry-run mode runs the same filter via SELECT COUNT(*).
Recommended deployment models
The framework supports the same shared-DB tenant strategy across all webhook surfaces; per-tenant schema or per-tenant DB is also viable but lives at the deployment layer:
| Model | Replay store | Secrets | Cleanup | When to choose |
|---|---|---|---|---|
| Shared DB + tenant_id columns (default) | MySQL (folded prefix) or Redis (TTL) | Env vars or per-tenant resolver | webhook:cleanup --tenant=<id> |
Single application instance serving multiple tenants. The audit + tests in cycle 24 cover this model. |
| Schema-per-tenant | Same as above per schema | Per-tenant via deployment routing | Per-schema cleanup runs | Strong tenant isolation requirement; comfortable with N schemas. Each schema owns its own webhook tables and the framework treats each as single-tenant. |
| DB-per-tenant | Per-tenant connection pool | Per-tenant via deployment routing | Per-tenant cron schedule | Maximum tenant isolation. The framework requires no changes — the connection pool is the boundary. |
Operational caveats
- Endpoint key uniqueness is operator-managed. Until a
(tenant_id, endpoint_key)UNIQUE index lands, two tenants registeringendpoint_key='stripe'collide infindByEndpointKey()and the publisher / receiver will route to whichever was registered first. - Replay key cleanup cadence. When tenant scoping folds into the key string, the
tenant:{id}:prefix lives in the samewebhook_replay_keystable as untenanted keys.cleanupExpired()without--tenantcleans both; with--tenant=<id>it only touches the prefixed rows. A deployment that mixes tenanted and untenanted writers must run BOTH a tenant-scoped pass per tenant AND a global pass for the legacy bare-key rows. - Service capability provider migration. Existing custom providers that implement the old
getCapabilitiesForService(string $serviceId): arraysignature continue to work — the new?string $tenantId = nullparameter is additive. Providers ignoring the new arg ARE NOT tenant-safe in multi-tenant deployments; production deployments should audit and update them.
Concurrent coroutines under Swoole
Swoole runs every HTTP request in its own coroutine inside a single worker process. Multiple coroutines run simultaneously — preempted only at I/O await points, never mid-statement. Every per-request lifecycle invariant rests on this single load-bearing assumption: per-request state must be coroutine-aware.
The concurrent-coroutine isolation tests run two HTTP requests in two real concurrent coroutines on the same worker and pin the contract end-to-end:
Per-coroutine isolation primitives
| Store | Mechanism | Coroutine-isolated |
|---|---|---|
CurrentRequestStore |
CoroutineLocal → Swoole\Coroutine::getContext() per coroutine |
yes |
AuthContextStore |
Swoole\Coroutine::getContext() per coroutine |
yes |
TenantContextStore |
CoroutineLocal::set() per coroutine |
yes |
RbacDecisionCache |
Swoole\Coroutine::getContext()[KEY] per coroutine |
yes |
CoroutineLocal |
Swoole\Coroutine::getContext() per coroutine |
yes |
SemitexaContainer::ExecutionContext |
CoroutineLocal::set(EXECUTION_CONTEXT_KEY) per coroutine |
yes |
CLI / queue / test mode falls back to static fields per store — but each static is independently keyed (no cross-store contamination), and the framework's per-request reset wipes them after every Application::handleRequest and QueueWorker::processPayload.
Cleanup callback isolation
The finally chain (PerRequestStateRegistry::resetAll() → requestScopedContainer->reset() → CoroutineLocal::endRequest()) runs per coroutine. Cleanup in coroutine A does NOT clear coroutine B's state:
PerRequestStateRegistry::resetAll()invokes each registered callback. Every registered callback (CurrentRequestStore::clear,RbacDecisionCache::clear,AuthContextStore::clear,TenantContextStore::clear) reaches intoSwoole\Coroutine::getContext()orCoroutineLocal— both of which target the current coroutine only. The callbacks DO null out static fallback fields that are shared across coroutines, but no other coroutine writes to those fields when running in coroutine mode, so the wipe is a no-op for everyone except the current coroutine.requestScopedContainer->reset()clears theRequestScopedContainerinstance cache. This is instance-shared — meaning every Application instance has its own cache, but two coroutines using the same Application instance would share the cache. Production Swoole createsnew Application()per request (seeSwooleBootstrap.php), so the cache is naturally per-coroutine via instance separation. Sharing one Application across concurrent coroutines is unsafe by design — the framework documents the production model and pins it.CoroutineLocal::endRequest()clears only the current coroutine'sSwoole\Coroutine::getContext()slot. Sibling coroutines keep their own context.
RBAC cache coroutine isolation
RbacDecisionCache stores grants in Swoole\Coroutine::getContext()[KEY][userId]. Two coroutines authenticating the same userId with different tenant grants get independent cache entries — pinned by rbac_decision_cache_is_coroutine_isolated in packages/semitexa-authorization/tests/Integration/ConcurrentCoroutineIsolationTest.php. The intra-request multi-tenant collision risk noted above is unchanged: if a single coroutine evaluates the same userId under two tenants in sequence within one request, the cache would still collide. Across coroutines there is no risk.
Webhook replay/idempotency atomicity
Composing seen() and markSeen() as separate operations is a check-then-act race: two coroutines can both observe seen() === false before either calls markSeen(), and both proceed to execute the once-only side effect. WebhookReplayStoreInterface::markIfFirstSeen() exposes the atomic claim that production callers should use instead.
The atomic-claim API is markIfFirstSeen(string $key, ?int $ttlSeconds = null): bool — exactly one caller wins, optional TTL applied via the backing's native expiry mechanism.
In-tree implementations:
InMemoryWebhookReplayStore(default for tests + single-worker demos): single PHP statement combiningisset()check + assignment. Swoole coroutines do not preempt mid-statement, so the operation is atomic by construction. Honors TTL via lazy expiry — entries are marked with an absoluteexpires_attimestamp and treated as not-seen on the next touch after the deadline.RedisWebhookReplayStore(production for Redis-backed deployments): atomic viaSET key value NX EX <ttl>. The Redis server guarantees one-winner semantics across all connected clients — multiple workers, multiple processes, multiple machines pointing at the same Redis DB. TTL is server-side; the key disappears automatically after the replay window.MySqlWebhookReplayStore(production for MySQL/MariaDB-backed deployments): atomic viaINSERT IGNORE INTO webhook_replay_keys (replay_key, first_seen_at, expires_at) VALUES (...). The PRIMARY KEY constraint onreplay_keycollapses check-and-claim into a single MySQL statement; affected-row count is the boolean answer. Atomicity holds across every PHP worker, every PHP process, every machine pointing at the same database. Expired keys remain blocking untilcleanupExpired()removes them — a delete-then-insert policy would reintroduce a check-then-act race (two writers could both observe an expired row, both delete it, both insert their own — both win, both fire side effects). Operators schedulecleanupExpired()periodically (cron, queue job, admin command); the framework never relies on lazy expiry for correctness.- PostgreSQL-backed implementation (recommended shape, not shipped): the same atomic-insert pattern with
INSERT ... ON CONFLICT DO NOTHINGandaffected_rowsinspection.MySqlWebhookReplayStoreis the reference design.
SignedWebhookHandler reads replayWindowSeconds from #[AsWebhookReceiver] and passes it to markIfFirstSeen(). The framework does not issue seen() + markSeen() anywhere in production request-processing code; the contract docstring marks the two-call pattern as unsafe so it cannot be reintroduced silently.
Replay window / TTL behavior
| TTL passed | InMemoryWebhookReplayStore | RedisWebhookReplayStore | MySqlWebhookReplayStore |
|---|---|---|---|
null |
PHP_INT_MAX (forever for the worker's lifetime) |
1 year default — Redis without expiry would leak unbounded | NULL expires_at — row is permanent until manual deletion |
| positive int | absolute expires_at = now() + ttl; lazy expiry on next touch |
server-side EX ttl |
expires_at = now() + ttl written to row; expired rows still BLOCK until cleanupExpired() |
0 or negative |
treated as immediate expiry | clamped to 1 second (Redis EX requires ≥ 1) |
clamped to 0 seconds (expires_at = first_seen_at) — still blocks until cleanup |
Choosing a backing
| InMemory | Redis | MySQL | |
|---|---|---|---|
| Atomic across workers | no — single worker only | yes | yes |
| Atomic across processes | no | yes | yes |
| Atomic across machines | no | yes (same Redis) | yes (same DB) |
| Schema setup | none | none | one table |
| Native TTL | lazy (PHP-side) | server-side EX |
none — operator runs cleanupExpired() |
| Delete-on-expiry race risk | none (single worker) | none (Redis owns expiry) | none (Option B blocks until cleanup) |
| Best for | tests, demos | high-throughput multi-worker production | DB-only deployments, audit-friendly |
Service binding
InMemoryWebhookReplayStore is the framework default — registered with #[SatisfiesServiceContract]. RedisWebhookReplayStore and MySqlWebhookReplayStore are intentionally NOT marked with the attribute; production deployments wire whichever they choose explicitly in their container bootstrap or a dedicated factory. Rationale: the choice of replay store (and which Redis / which database / which schema) is a deployment decision, not framework magic. Tests and demos use the in-memory default; production overrides explicitly.
Schema (MySQL)
MySqlWebhookReplayStore requires the webhook_replay_keys table. The ORM-managed shape lives at packages/semitexa-webhooks/src/Application/Db/MySQL/Model/WebhookReplayKeyResourceModel.php. Production deployments materialize it via the framework's schema-sync mechanism (bin/semitexa orm:diff to preview, orm:sync to apply). Equivalent raw SQL:
CREATE TABLE webhook_replay_keys (
replay_key VARCHAR(191) NOT NULL,
first_seen_at DATETIME NOT NULL,
expires_at DATETIME NULL,
PRIMARY KEY (replay_key),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The replay_key column is the natural primary key — there is no surrogate id. Atomicity of markIfFirstSeen() depends on the PRIMARY KEY constraint; any schema change must preserve uniqueness.
Webhook dedupe layers — defense in depth
The framework has THREE related-but-distinct dedupe layers in the webhook path:
WebhookReplayStoreInterface(inbound, request-level). Idempotency guard typically keyed by event-id with a TTL. Block duplicate request processing early at the receiver. Backings: in-memory (default), Redis, MySQL.InboundDeliveryRepositoryInterface::insertOrMatchDedupe(inbound, persistent). Inbox audit dedupe with per-row lifecycle (Received→Verified/RejectedSignature→Processing→Processed/Failed/DuplicateIgnored). Records every inbound delivery for forensics, replay, and protects DB-backed inbox processing from double-execution.OutboundDeliveryRepositoryInterface::insertOrMatchIdempotency(outbound, persistent). Publisher-side dedupe — two publishers with the same(endpoint_definition_id, idempotency_key)produce exactly one outbound delivery row, never two.
All three exist intentionally. They differ in what they protect:
| Replay store | Inbound dedupe | Outbound idempotency | |
|---|---|---|---|
| Direction | inbound | inbound | outbound |
| Scope | one event-id within the replay window | every inbound delivery, kept indefinitely | one published event per (endpoint, idempotency_key) |
| Backing | in-memory / Redis / DB | always DB | always DB |
| Side-effect blocked | the handler's once-only side effect | the inbound audit row itself | duplicate outbound delivery row + duplicate transport send |
| Identity | event-id | dedupe_key |
(endpoint_definition_id, idempotency_key), optional |
| Cleanup | TTL or cleanupExpired() |
deleteTerminalOlderThan() retention |
deleteTerminalOlderThan() retention |
| Failure mode if missing | duplicate side effect at the receiver | duplicate audit row + duplicate processing | duplicate outbound delivery + duplicate transport send |
Defense in depth is intentional: the replay store is fast and short-lived; the persistent dedupe layers are durable and audit-friendly. Don't remove one because the others exist.
Webhook pipeline concurrency model
The four atomic layers (publisher idempotency, worker claim/lease, worker CAS finalization, inbound replay/dedupe) are designed to compose. Under concurrent pressure they cooperate to deliver a strict end-to-end contract: one logical webhook event produces exactly one transport send and exactly one downstream side effect.
| Layer | Atomic primitive | Side effect blocked under concurrency |
|---|---|---|
| Publisher | INSERT IGNORE + UNIQUE (endpoint_definition_id, idempotency_key) |
duplicate outbound delivery row |
| Worker claim | conditional UPDATE … WHERE status IN (…) AND lease_expired |
duplicate claim of the same delivery |
| Worker CAS finalization | UPDATE … WHERE status = 'delivering' AND lease_owner = :worker_id |
stale worker overwriting a row that another worker reclaimed |
| Inbound replay store | markIfFirstSeen() (in-memory: single PHP statement; Redis: SET NX EX; MySQL: INSERT IGNORE + PRIMARY KEY) |
duplicate handler invocation at the receiver edge |
| Inbound dedupe | INSERT IGNORE + UNIQUE (dedupe_key) |
duplicate inbox audit row + duplicate processing |
None of these layers replaces another. Each protects against a different failure mode:
- Without publisher idempotency, two app paths firing the same logical event create two outbound rows, and even perfect worker claim/lease will send two transport requests.
- Without worker claim/lease, two workers process the same outbound row and both call the transport.
- Without CAS finalization, a stale worker that finishes a long transport call after losing its lease overwrites the row written by the new owner — corrupting status, attempt count, and response data.
- Without the replay store at the receiver, a duplicate transport request (e.g. retry storm, multi-region fan-out) runs the receiver's once-only handler twice.
- Without inbound dedupe, the persistent inbox audit accumulates duplicate rows and the receiver's processing path runs twice for the same event.
Concurrency testing strategy
Deterministic concurrency tests run in their own PHP process via #[RunTestsInSeparateProcesses] + #[PreserveGlobalState(false)]. Each test:
- Creates its own
webhook_outbox(andwebhook_replay_keys/webhook_inboxwhere relevant) tables insetUp(), drops them intearDown(). No cross-test DB pollution. - Wakes the ORM's
MapperRegistryon the main coroutine before anySwoole\Coroutine\run()block — the registry is lazily built and concurrent first-touches can race on initialization. - Coordinates coroutine release via
Swoole\Coroutine\Channelstart-gates and done-channels. No timing-dependent sleeps for synchronization. - Asserts concrete DB invariants: row counts by idempotency key, attempt counts, transport send counts, final statuses, lease ownership. Response status alone is not sufficient proof.
The capstone tests (packages/semitexa-webhooks/tests/Integration/WebhookPipelineConcurrencyStressTest.php) cover:
- N concurrent publishers with the same idempotency key → exactly one delivery row
- N concurrent publishers with different keys → exactly one delivery per key
- Same key on different endpoints → one delivery per endpoint
- N concurrent workers racing one pending delivery → exactly one transport send + one attempt count bump
- Sequential publisher-then-worker pipeline with concurrent burst → one delivery, one send, one attempt log
- Stale finalization rejection after forced lease expiry → row remains under the new owner
- Sustained burst across multiple distinct keys → one delivery + one send per key
Operational caveats
- In-memory stores are per-process.
InMemoryWebhookReplayStoreand the in-memory delivery fixtures provide atomic-claim semantics within ONE PHP worker process via Swoole's no-mid-statement-preemption guarantee, but they do NOT span workers. Multi-worker production deployments MUST use the MySQL- or Redis-backed implementations. The in-memory variants are for tests, demos, and single-process operation only. - The pipeline atomicity contract relies on the database UNIQUE constraints actually existing.
orm:syncmaterializes them automatically once the model attributes are loaded, but production deployments that have an existingwebhook_outboxorwebhook_inboxschema must add the indexes (and clean up any pre-existing duplicate rows) before upgrading the publisher / receiver code. - Concurrency happens on the database, not in PHP. PHP coroutines cooperate; the load-bearing atomic operations happen in the SQL engine. A test that uses an in-memory fixture across simulated "workers" is testing PHP behavior, not production behavior. Stress tests must use the real MySQL repositories to mean anything.
Outbound publisher idempotency
Two concurrent publishers calling WebhookPublisher::publish() for the same logical event must not produce two outbound delivery rows. The publisher honors an OPTIONAL idempotency contract:
- NULL idempotency key: every publish creates a fresh delivery. MySQL UNIQUE indexes treat NULLs as distinct, so multiple NULL keys coexist by design. Use this when the producer doesn't care about duplicate publishes (or when the dedupe is enforced upstream).
- Non-NULL idempotency key: unique per
(endpoint_definition_id, idempotency_key)via theuniq_webhook_outbox_endpoint_idempotencyconstraint onwebhook_outbox. The first publish inserts; any subsequent publish with the same pair returns the existing row unchanged. Same idempotency key on a different endpoint is a separate delivery (producers can fan one logical event out to multiple endpoints with one shared key).
Atomicity comes from the database: OutboundDeliveryRepositoryInterface::insertOrMatchIdempotency() does a single INSERT and converts a duplicate-key violation into "matched existing" by re-fetching the row that won. A findByIdempotencyKey() then insert() sequence is forbidden — it's the same check-then-act race the inbound dedupe and replay store fixes already removed.
Callers can detect "matched existing" by comparing the returned delivery's id to the input id; a mismatch means the row already existed under another id. The publisher itself ignores the result — duplicate publishes silently no-op, which is the expected user-facing semantics.
Recommended PostgreSQL pattern: INSERT ... ON CONFLICT (endpoint_definition_id, idempotency_key) DO NOTHING RETURNING *, fall back to SELECT if RETURNING is empty (the conflict path).
Migration caveat: production deployments with an existing webhook_outbox table may have duplicate (endpoint_definition_id, idempotency_key) rows from before the constraint existed. orm:sync will fail to create the unique index until those duplicates are cleaned up. Sample cleanup query:
DELETE w1 FROM webhook_outbox w1
INNER JOIN webhook_outbox w2
ON w1.endpoint_definition_id = w2.endpoint_definition_id
AND w1.idempotency_key = w2.idempotency_key
AND w1.idempotency_key IS NOT NULL
AND w1.created_at > w2.created_at;
Inspect the duplicates before running — they may represent real data loss.
Outbound delivery claim-and-lease atomicity
The outbound delivery worker uses a claim-and-lease pattern so multiple workers can poll the same webhook_outbox table without sending the same delivery twice. The repository's claimAndLease() method does the entire claim — status flip, lease assignment, and attempt-count increment — in a single conditional UPDATE:
UPDATE webhook_outbox
SET lease_owner = :worker_id,
lease_expires_at = :lease_expires_at,
status = 'delivering',
attempt_count = attempt_count + 1,
last_attempt_at = NOW()
WHERE id = :id
AND (
(status IN ('pending','retry_scheduled')
AND next_attempt_at <= NOW()
AND (lease_expires_at IS NULL OR lease_expires_at < NOW()))
OR
(status = 'delivering'
AND lease_expires_at IS NOT NULL
AND lease_expires_at < NOW())
)
Two workers competing on the same row only get one winner — the second one's UPDATE sees status = 'delivering' with a lease still owned by Worker A and the WHERE clause does not match, so rowCount = 0 and the second worker treats it as "no work available". When Worker A's lease expires (because A crashed, hung, or partitioned), the same query allows another worker to reclaim — the OR (status = 'delivering' AND lease_expired) branch is the recovery path.
attempt_count is bumped IN THE SAME UPDATE so reclaims don't lose count. A PHP-side $delivery->attemptCount++ after the claim would race: two workers reclaiming an expired lease in sequence would both read the same DB value and both write the same incremented value.
Finalization is compare-and-swap. After the transport call, the worker calls one of three CAS methods on the repository:
UPDATE webhook_outbox
SET status = 'delivered'/'retry_scheduled'/'failed',
…response/retry/error fields…,
lease_owner = NULL,
lease_expires_at = NULL
WHERE id = :id
AND status = 'delivering'
AND lease_owner = :worker_id
If the lease has been reclaimed by another worker (because the original worker held the request too long), the WHERE doesn't match and the CAS returns false. The worker treats false as a lost-lease outcome — it logs the event, returns the outcome, and does NOT try to update the row again. The transport request was sent (the receiver's replay store dedupes it) but no DB clobber happens.
The legacy "load delivery → mutate → save by id" pattern is forbidden for status transitions. It would overwrite a row another worker already finalized. Use the CAS methods (markDeliveredIfOwned, markRetryScheduledIfOwned, markFailedIfOwned) for every state transition that follows a claim.
Recommended PostgreSQL pattern: the same SQL works with PostgreSQL with one tweak — UPDATE … RETURNING * to fetch the just-claimed row in one round trip. The conditional WHERE remains the atomic check. SELECT … FOR UPDATE SKIP LOCKED is an alternative for high-contention queues but the conditional UPDATE pattern above scales fine for typical webhook traffic.
Inbound delivery dedupe atomicity
A naive findByDedupeKey() then insert() sequence is the same check-then-act race shape as the webhook replay store. InboundDeliveryRepository::insertOrMatchDedupe avoids it the same way:
- Schema-level UNIQUE constraint on
webhook_inbox.dedupe_key(declared via#[Index(columns: 'dedupe_key', unique: true, name: 'uniq_webhook_inbox_dedupe_key')]onWebhookInboxResourceModel). The DB engine guarantees one-winner semantics for concurrent inserts. - Try-insert + catch-duplicate in the repository. The first writer's INSERT succeeds; the loser's INSERT raises a unique-constraint violation, caught and converted into the "matched existing" branch by re-fetching the row via
findByDedupeKey()and callingmarkDuplicateIgnored(). - Real DB errors are NOT swallowed. Only an actual unique-constraint violation (SQLSTATE 23000 / MySQL error 1062, checked through the previous-exception chain) maps to a match. Table-missing, syntax errors, connection failures, permission errors all propagate so callers see a real failure instead of a silent "every webhook duplicated".
Production deployments that have an existing webhook_inbox table with no unique index on dedupe_key MUST add the index before upgrading. The schema-sync mechanism (bin/semitexa orm:diff to preview, orm:sync to apply) emits the necessary CREATE UNIQUE INDEX statement automatically once the framework model is loaded.
Multi-app deployments
If multiple applications share one MySQL database AND one webhook_replay_keys table, replay-key collisions across apps would corrupt each other's deduplication. Production deployments running multiple apps against shared infrastructure must either:
- give each app its own database (cleanest)
- give each app its own
webhook_replay_keys-prefixed table - agree on a key prefix convention (e.g.
app-name:webhook:demo:signed:<event-id>) so the application namespace is encoded in thereplay_keyitself
The store does not enforce prefixing — that is a deployment decision.
Cross-worker implications
In-memory replay protection only works within ONE worker process. Two workers receiving the same retried webhook simultaneously would each have their own in-memory store and both could process the side effect. Production deployments that rely on once-only side effects (DB writes, outbound API calls, money movements) MUST use a shared backing — Redis or DB — so the atomic claim spans all workers.
The RedisWebhookReplayStore integration test (packages/semitexa-webhooks/tests/Integration/RedisWebhookReplayStoreTest.php) explicitly simulates this with two RedisConnectionPool instances pointing at the same Redis DB and pins exactly-one-winner across the two pools.
Persistence retention and cleanup
The webhook tables grow unbounded without operator intervention. Five surfaces accumulate rows:
| Surface | Backing | Cleanup mechanism |
|---|---|---|
webhook_replay_keys |
MySQL | webhook:cleanup (manual, runs MySqlWebhookReplayStore::cleanupExpired()) |
| Replay keys | Redis | server-side TTL — automatic, no cleanup work needed |
| Replay keys | in-memory | test-only — process exit drops the table |
webhook_inbox |
MySQL | webhook:cleanup (terminal statuses only) |
webhook_outbox |
MySQL | webhook:cleanup (terminal statuses only) |
webhook_attempts |
MySQL | webhook:cleanup (append-only audit log) |
The bin/semitexa webhook:cleanup command is the single operator entry point. It composes the per-table delete order, the dry-run preview, and the --batch-size cap into one invocation safe to schedule from cron.
# Preview what would be deleted (no rows touched)
bin/semitexa webhook:cleanup --dry-run
# Apply with a per-call cap (rerun until backlog drains)
bin/semitexa webhook:cleanup --batch-size=1000
# Override the configured retention window (defaults to WEBHOOK_RETENTION_DAYS / 30)
bin/semitexa webhook:cleanup --retention-days=14
A representative cron line for a once-an-hour cleanup:
17 * * * * /var/app/bin/semitexa webhook:cleanup --batch-size=5000 >>/var/log/webhook-cleanup.log 2>&1
Safety contract — what cleanup will never delete
Status-aware deletes are the load-bearing part of the contract. The retention window alone is not a sufficient filter — a row "older than the cutoff" may still represent live work.
- Outbound (
webhook_outbox): onlyDelivered,Failed,Cancelledrows are eligible.Pending,RetryScheduled,Deliveringrows are PRESERVED unconditionally — they still represent live work, even ifcreated_atis ancient. As defense-in-depth, any row carrying an unexpiredlease_expires_atis also preserved so a worker mid-finalize cannot have its row pulled out from under it. - Inbound (
webhook_inbox): onlyProcessed,Failed,RejectedSignature,DuplicateIgnoredrows are eligible.Received,Verified,Processingrows are PRESERVED — those are still in flight or queued for processing. - Replay keys (
webhook_replay_keys): rows withexpires_at IS NULL(intentionally permanent keys passed by callers with$ttlSeconds = null) are PRESERVED. Rows withexpires_atin the future are PRESERVED. Only rows whose TTL has already passed are eligible. - Attempts (
webhook_attempts): append-only audit log. Truncating cannot affect live work, so all rows older than the cutoff are eligible.
These filters live in the repository methods (deleteTerminalOlderThan for outbox/inbox, cleanupExpired for the replay store) so anyone bypassing the CLI command and calling the persistence layer directly inherits the same guarantees.
Dry-run and batch size
--dry-run runs the same status / cutoff / lease filter via SELECT COUNT(*) instead of DELETE, then emits the same summary table. No rows are mutated. Operators use this to size cleanup runs and confirm the filter is doing what they expect before applying.
--batch-size=N (default 1000, 0 means unbounded) caps the per-table delete count via SQL LIMIT N. Large backlogs are drained by re-running the command — typically the same cron schedule that fires the cleanup also re-fires until the dry-run count converges to zero. The cap exists so a single delete transaction stays short, doesn't escalate into a row lock storm, and doesn't hold the InnoDB undo log open longer than necessary.
Output discipline
The summary surfaces row counts only — never payload bodies, header contents, secrets, or tenant-identifying material. Payload bodies live in webhook_inbox.raw_body and webhook_outbox.payload_json; the cleanup command never reads them. This is intentional so the cron output (typically captured to a log file with broad read access) cannot leak sensitive transport data.
Picking a backing for the replay store and what it implies for cleanup
| In-memory | Redis | MySQL | |
|---|---|---|---|
| Cleanup mechanism | none (process exit) | server-side TTL via EX |
webhook:cleanup (cron) |
| Operator action required | none (test-only) | none | schedule the command |
| Expired keys still block until cleanup? | n/a | no — Redis evicts on TTL | yes — Option B blocks until cleanup runs |
The MySQL backing is the only one where cleanup cadence is operator-visible. The blocking-until-cleanup contract is intentional (it removes the delete-then-insert race), but it does mean an under-scheduled webhook:cleanup makes the replay window effectively longer than replayWindowSeconds advertises. A 24-hour replay window with daily cleanup is fine; a 5-minute replay window with daily cleanup is not — the window in practice is 24 hours.
Swoole-specific anti-patterns
- Sharing one
Applicationinstance across concurrent coroutines.RequestScopedContainer::$requestScopedCacheis instance-shared; two coroutines would race on the cache and the first cleanup would wipe the second's still-active state. Production Swoole creates a freshnew Application()per request viaSwooleBootstrap. Custom code paths must follow the same pattern. seen()thenmarkSeen()for replay protection. UsemarkIfFirstSeen()instead.- Storing
Request/AuthContext/ tenant / locale in static singleton fields without coroutine indirection. Two coroutines would overwrite each other. Always go throughCoroutineLocalorSwoole\Coroutine::getContext(). - Calling
Application::handleRequestcleanup from outside the framework. The finally chain assumes it runs on the same coroutine that established the per-request state. Calling it from a sibling coroutine wipes the wrong state. - PHPUnit tests that exercise Swoole runtime without
#[RunTestsInSeparateProcesses]. AfterSwoole\Coroutine\run()returns, shared resources (likeOrmManager's connection pool channels) can be left in a state that crashes subsequent non-coroutine tests with an uncatchable Swoole-level fatal. Forking a process per Swoole test isolates the runtime teardown — seepackages/semitexa-authorization/tests/Integration/ConcurrentCoroutineIsolationTest.phpfor the canonical pattern. - Calling Swoole
Channelmethods inside__destruct. PHP shutdown tears down the Swoole runtime before destructors run; subsequent Channel method calls raiseSwoole\Error: must call constructor firstas a true PHP fatal that bypasses try/catch. Use aregister_shutdown_functionflag to skip Channel ops in the shutdown phase — seeSemitexa\Orm\Adapter\ConnectionPoolfor the pattern.
Common anti-patterns (and why they're bugs)
- Storing
Requestin a singleton service property. The next request will see the previous request's headers, body, query, and cookies. InjectRequestvia#[InjectAsMutable]on an#[ExecutionScoped]service instead — the framework injects the right one per execution. - Storing
AuthContextInterfacein a singleton service property. Same shape — leaks the previous request's principal. Use#[ExecutionScoped]+#[InjectAsMutable], or read fromAuthContextStore::getUser()(which is per-request via the store'sPerRequestStateRegistryregistration). - Mutable singleton fields holding tenant / user / locale. Same anti-pattern, different domain. Always use the framework context interfaces resolved per request.
- Clearing metadata caches per request. Defeats Semitexa's "build once" performance model. Metadata caches are derived from the codebase and don't change between requests.
- Using static mutable state for request data. Static stores survive across requests by default. If the data is per-request, register a
clear()callback withPerRequestStateRegistry. - Calling
requestScopedContainer->reset()from inside a handler. The framework owns the lifecycle; resetting from inside a request would dispose the container while the current handler is still using it. Don't do this. - Skipping the per-request reset in a custom entry-point. Any new entry-point that calls
Application::handleRequestdirectly is fine — the reset is built intohandleRequest. But a new entry-point that bypassesApplicationand calls handlers itself must run the sametry/finallyreset chain. - Reading
TenantContextStore::shared()->tryGet()from a singleton-scoped service property. The wrong tenant will be returned on the next request once the per-request reset wipes the store. Resolve the tenant per request — either injectTenantContextInterfaceinto an#[ExecutionScoped]service (the framework injects the right one fromExecutionContext) or calltryGet()at request entry and pass the value through. - Calling
setFallback()at boot time and expecting the tenant to persist across requests. Despite the name, the fallback is per-execution. If you need a long-lived tenant identity for the entire worker (rare — mostly a misuse), use a different mechanism thanTenantContextStore. The store is per-request by contract. - Adding tenant-keyed entries to
RbacDecisionCachewithout adjusting the cache key. The cache key isuserIdtoday. Two tenants' grants for the sameuserIdwould collide if the same request evaluated both. The per-request reset shields against cross-request leaks; intra-request multi-tenant resolution would need a tenant-aware key.
Don't put these in PerRequestStateRegistry
- Route discovery cache (
AttributeDiscovery,RouteRegistry) — re-discovery defeats Swoole's "build once, serve N" model. - Reflection metadata (
HandlerReflectionCache,PayloadAccessPolicyResolver) — derived from the codebase, never from request data. - Class discovery (
ClassDiscovery) — Composer classmap snapshot; immutable per-process. - Replay/idempotency stores (
InMemoryWebhookReplayStore) — clearing them defeats replay protection. Class 4: register withTestStateResetRegistryinstead. - Side-effect ledgers used for test assertions (
WebhookDemoEventStore) — same answer. - Demo grant stores (
AuthDemoPermissionStore,AuthDemoCapabilityStore,WebhookDemoServiceCapabilityStore) — same answer.
Reset-callback failures are isolated
Both registries invoke each callback inside a try { $reset(); } catch (\Throwable) { } block. A misbehaving cache cannot break the rest of the reset chain or, in PerRequestStateRegistry's case, break the next request. The trade-off is that a permanently-broken reset callback leaves its cache "dirty" — strictly worse than the leak you were fixing in the happy path, but better than crashing every subsequent request handled by the same worker process.
If a reset callback can throw, fix the root cause in the cache itself rather than relying on the swallow.
Diagnostics
Both registries expose registeredNames(): list<string> for diagnostics:
PerRequestStateRegistry::registeredNames();
// → ['rbac_decision_cache', 'current_http_request_store', 'auth_context_store']
TestStateResetRegistry::registeredNames();
// → ['auth_demo_permission_store', 'auth_demo_capability_store',
// 'webhook_demo_event_store', 'webhook_demo_service_capability_store',
// 'in_memory_webhook_replay_store']
Use these in lifecycle assertions to prove the right kind of state is wired into the right registry. packages/semitexa-core/tests/Integration/StateLifecycleTest.php does exactly this — including negative assertions that no replay or demo store accidentally appears in PerRequestStateRegistry.