Skip to content

Commit 2db0462

Browse files
committed
refactor(graphql): separate subscription registry and publication state
Store item payload fingerprints separately from registrations and coordinate mutations with an injected Symfony Lock factory. Keep collection publications untracked and acknowledge prepared updates only after successful publication or dispatch. Remove legacy subscription-manager adapters and document cache reset and shared-store configuration requirements. BREAKING CHANGE: Custom subscription managers must implement operation-aware registration, getUpdates(), and acknowledge().
1 parent 899bd98 commit 2db0462

25 files changed

Lines changed: 1366 additions & 653 deletions

‎composer.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@
112112
"doctrine/inflector": "^2.0",
113113
"psr/cache": "^1.0 || ^2.0 || ^3.0",
114114
"psr/container": "^1.0 || ^2.0",
115+
"psr/log": "^1.0 || ^2.0 || ^3.0",
115116
"symfony/deprecation-contracts": "^3.1",
116117
"symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0",
117118
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0",
119+
"symfony/lock": "^6.4 || ^7.0 || ^8.0",
118120
"symfony/property-access": "^6.4 || ^7.0 || ^8.0",
119121
"symfony/property-info": "^6.4 || ^7.1 || ^8.0",
120122
"symfony/serializer": "^6.4 || ^7.0 || ^8.0",
@@ -157,7 +159,6 @@
157159
"phpstan/phpstan-phpunit": "^2.0",
158160
"phpstan/phpstan-symfony": "^2.0",
159161
"phpunit/phpunit": "^12.2",
160-
"psr/log": "^1.0 || ^2.0 || ^3.0",
161162
"ramsey/uuid": "^4.7",
162163
"ramsey/uuid-doctrine": "^2.0",
163164
"soyuka/contexts": "^3.3.10",

‎src/GraphQl/README.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,63 @@ The [GraphQL](https://graphql.org/) component of the [API Platform](https://api-
1010
> [report issues](https://github.com/api-platform/core/issues) and
1111
> [send Pull Requests](https://github.com/api-platform/core/pulls)
1212
> in the [core API Platform repository](https://github.com/api-platform/core).
13+
14+
## Subscription state and publication
15+
16+
The subscription registry stores IDs and selected fields. Individual-item payloads
17+
are represented by SHA-256 fingerprints in separate cache entries; collection
18+
subscriptions do not retain payloads or fingerprints. IDs and Mercure topics are
19+
unchanged.
20+
21+
Symfony wires two cache pools:
22+
23+
- `api_platform.graphql.cache.subscription`: the registration registry;
24+
- `api_platform.graphql.cache.subscription_fingerprint`: fingerprints, inheriting
25+
the registry pool's adapter configuration by default.
26+
27+
`SubscriptionStore` receives these pools and a Symfony `LockFactory` through
28+
injection. The `api_platform.graphql.subscription.lock_factory` alias points to
29+
Symfony's `lock.factory`, configured through `framework.lock`. It can instead
30+
reference a named lock factory when subscriptions need their own lock backend.
31+
API Platform does not construct or select a lock store. Query-only applications
32+
can leave locking disabled; registering subscriptions requires a configured
33+
factory and fails explicitly if it is missing. There is no unlocked fallback.
34+
35+
When sharing subscription caches between servers, configure a shared lock backend
36+
(such as Redis) accessible to every registration and publication worker. Both cache
37+
pools must also use shared, coherent storage. Cache pools expose no portable way
38+
to derive their connection for the Lock component; configure the cache and lock
39+
backends together. Local locks are suitable only when all writers share the same
40+
local storage and lock scope.
41+
42+
Registration and deletion lock their registry bucket. Successful item publications
43+
update their fingerprint under a separate per-subscription lock. Normalization,
44+
hashing, and Mercure/Messenger calls occur outside those locks. Versioned
45+
comparison detects fingerprints changed in the meantime. Conflicting completions
46+
invalidate the fingerprint so the next event is not incorrectly suppressed.
47+
Fingerprint write failures are logged and the stale fingerprint is discarded on
48+
a best-effort basis so delivery continues to other subscribers. Delete cleanup
49+
failures are also logged without dropping the delete recipients. Cache failures
50+
can leave stale entries if invalidation also fails; restore cache health and clear
51+
subscription state when necessary.
52+
53+
Concurrent identical publications can still occur; this is change suppression,
54+
not an exactly-once delivery or ordering guarantee.
55+
56+
The Doctrine publisher iterates `SubscriptionManagerInterface::getUpdates()`,
57+
publishes each prepared update, then explicitly calls `acknowledge()` for that update.
58+
The manager handles normalization; the store owns fingerprint comparison and
59+
acknowledgement. Preparing or iterating updates does not advance their fingerprints.
60+
A fingerprint advances only after successful hub acceptance for synchronous delivery
61+
or Messenger dispatch acceptance for asynchronous delivery. Worker retries remain
62+
Messenger's responsibility; acceptance does not imply client delivery.
63+
64+
Custom subscription managers must implement the complete `SubscriptionManagerInterface`:
65+
registration receives the subscription operation, `getUpdates()` prepares updates
66+
for that operation, and `acknowledge()` records successful publication. The previous
67+
`getPushPayloads()` contract is removed.
68+
69+
The cache layout is internal and has no migration path. When upgrading, stop old
70+
registration and publication workers, clear subscription state, and re-establish
71+
subscriptions. Old and new workers must not share this registry. Fingerprint cache
72+
eviction can cause an additional publication without losing registrations.

‎src/GraphQl/State/Processor/SubscriptionProcessor.php‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
namespace ApiPlatform\GraphQl\State\Processor;
1515

1616
use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface;
17-
use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface;
1817
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface;
1918
use ApiPlatform\Metadata\GraphQl\Subscription;
2019
use ApiPlatform\Metadata\Operation;
@@ -41,11 +40,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
4140
return $data;
4241
}
4342

44-
if ($this->subscriptionManager instanceof OperationAwareSubscriptionManagerInterface) {
45-
$subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($context, $data, $operation);
46-
} else {
47-
$subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($context, $data);
48-
}
43+
$subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($context, $data, $operation);
4944

5045
if ($subscriptionId) {
5146
if (!$this->mercureSubscriptionIriGenerator) {

‎src/GraphQl/Subscription/OperationAwareSubscriptionManagerInterface.php‎

Lines changed: 0 additions & 29 deletions
This file was deleted.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Subscription;
15+
16+
/**
17+
* A registration and its publication state at the time it was read.
18+
*
19+
* @internal
20+
*/
21+
final readonly class RegisteredSubscription
22+
{
23+
/** @param array{hash: string, version: string}|null $fingerprint */
24+
public function __construct(public string $key, public string $id, public array $fields, public bool $collection, public ?array $fingerprint)
25+
{
26+
}
27+
}

0 commit comments

Comments
 (0)