Skip to content

feat(graphql): added support for graphql subscriptions to work for actions - #6904

Draft
psihius wants to merge 38 commits into
api-platform:mainfrom
psihius:feature/graphql-subscription-for-all-operations
Draft

psihius wants to merge 38 commits into
api-platform:mainfrom
psihius:feature/graphql-subscription-for-all-operations

Conversation

@psihius

@psihius psihius commented Jan 9, 2025 •

Copy link
Copy Markdown
Contributor
Q A
Branch? 4.0 or main
Tickets None that I found
License MIT
Doc PR

This PR makes create and delete operations issue mercure updates for GraphQL subscriptions.
It also adds the ability to configure "restrictive updates" - https://api-platform.com/docs/core/mercure/#dispatching-restrictive-updates-security-mode - implementation follows the same idea, just one that fits GraphQL subscriptions.

Some things that need feedback/work/ideas:

  • Private fields config option probably should also use expression language same way topics do
  • Caching item of the subscriptionCollection is going to become quite sizeable when you have a lot of segmentation based on owners of the data and I don't have a good idea how to segment that - operation context is very restricted in the doctrine listener and I don't have a good knowledge of the internals to properly construct one, but I did my best. Best idea I have is to read the object's data and use it as part of the cache key (the private fields option). One problem is when deleting data - when in publishUpdate, the object data is already gone since it constructs and passes and stdObject with very limited data, so I do not have context at that point.
  • Mercure payload format probably needs updating - with it now containing all 3 types of operations, I think it needs a field that contains what type of operation it was - create, update or deleted so mercure consumers can decide what to do
  • Deleted stdObject contains too little information, kind'a want ability to retain some of the data in there, like for example the parent relation field that points to the parent ID the record was deleted for. No ideas as of now how to implement that besides adding more configurations to mercure and modifying publish listener's deleteObject if branch to retain extra fields from the resource, kind'a like i did with private_fields in the SubscriptionManager and graphql object context.

This is a WIP because I need feedback on the direction taken and suggestions for improving some aspects of it.

This code is being used in production, works for our use case, but it's obviously far from ideal and I want to improve it and make it standard part of ApiPlatform.

The goal for this is to make managing items on frontend easy purely via mercure updates via graphql subscriptions. In our case it's a chat like frontend that needs to not only receive updates, but also new data (new messages, new chats) and data removal messages. This puts GraphQL API on par with functionality of the REST API where you can have restrictive data updates AND you can subscribe to collections which include all 3 types of data events.

@psihius

psihius commented Jan 15, 2025

Copy link
Copy Markdown
Contributor Author

@soyuka @dunglas could you provide some feedback please, so I can decide how I want to continue working on this? :)

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Private fields config option probably should also use expression language same way topics do

Not sure to understand this functionality, did I miss something?

  • Caching item of the subscriptionCollection is going to become quite sizeable when you have a lot of segmentation based on owners of the data and I don't have a good idea how to segment that

I don't think we should send collection updates, only items inside a collection should be sufficient.

When a data is deleted you can still send an update on its IRI (as mercure topic) with a null value? I'm not sure why data should be part of a cache key, instead we should use IRIs just as in REST? Or is it that you want to send a mercure update with only the fields selected in the GraphQl Query?

  • Mercure payload format probably needs updating - with it now containing all 3 types of operations, I think it needs a field that contains what type of operation it was - create, update or deleted so mercure consumers can decide what to do

IMO, we should always consider a mercure update as a modified element, it's a new one if you don't have it in your referential, or else it's updated. You can detect it's deleted when the value is null.

  • Deleted stdObject contains too little information, kind'a want ability to retain some of the data in there, like for example the parent relation field that points to the parent ID the record was deleted for.

Not sure why we would need this. It makes things really complicated when we look for data associations, when do you stop looking for relations?

Comment thread src/GraphQl/Subscription/SubscriptionManager.php
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php
Comment thread src/GraphQl/Subscription/SubscriptionManagerInterface.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php
Comment thread src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php Outdated
@psihius

psihius commented Jan 17, 2025 •

Copy link
Copy Markdown
Contributor Author
  • Private fields config option probably should also use expression language same way topics do

Not sure to understand this functionality, did I miss something?

https://api-platform.com/docs/core/mercure/#dispatching-restrictive-updates-security-mode - this, but for the graphql subscription. It's a way to restrict data pushing to specific user/resource/whatever way you want to restrict access based on resource field values. I use it to restrict access between accounts in SaaS service.

  • Caching item of the subscriptionCollection is going to become quite sizeable when you have a lot of segmentation based on owners of the data and I don't have a good idea how to segment that

I don't think we should send collection updates, only items inside a collection should be sufficient.
This already does not happen. When a resource already exists, it hits the individual resource URI cache, so it doesn't access the collection cache item.

$iri = $this->iriConverter->getIriFromResource($object);
// Find based on IRI if there's an item already cached. If resource is not being created - this always returns the subscription array, meaning the collection iri is never accessed.
$subscriptions = $this->getSubscriptionsFromIri($iri);
if ($subscriptions === []) {
    // Get subscriptions from collection Iri
    $subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri));
}

As you can see here, it first tries to get subscriptions from full IRI. If it's an update operation - the item is already in cache, if not - it goes to collection cache (which would happen only for create operation).
As I understand, the underlying cache is system cache, so data is permanently in storage, meaning it will access collection cache only for create action, never for update or delete.

When a data is deleted you can still send an update on its IRI (as mercure topic) with a null value? I'm not sure why data should be part of a cache key, instead we should use IRIs just as in REST? Or is it that you want to send a mercure update with only the fields selected in the GraphQl Query?
Because that data is used to find the correct subscription id that has exactly the same field set with the same values in the private fields section.

if ($private && $privateFields && $previousObject) {
    foreach ($options['private_fields'] as $privateField) {
        $fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject);
    }
}
$subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri));
$subscriptions = [];
if ($subscriptionsCacheItem->isHit()) {
    $subscriptions = $subscriptionsCacheItem->get();
    foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) {
        if ($subscriptionFields === $fields) {
            return $subscriptionId;
        }
    }
}

See that second foreach there? It filters out based on field combo subscriptions that are not addressed to the specific combo of fields. The _private_field$field item makes sure with it's value that you send updates to a subscription id that is restricted to the same field value. IF you use owner_id aka the user who owns it, then you effectively restrict pushing the update only to subscriptions that were created by that user only.

There's the corresponding change in SubscriptionManager::retrieveSubscriptionId which gets called in SubscriptionProcessor which is where the subscription id is created for a given IRI, that subscription id is created from fields that were selected as return data for the subscription. In there I also inject those private field and their data from the resource in question, which affects the value of generated subscription id. The subscription id always is generated as a resource wide subscription, it's the same id regardless what IRI you pass as long as it's the same resource type. The cache just was stored per resource IRI (aka per item), so I just added also the collection level cache item so you can push updates for "create" operation, since that adds a new item and you need to know what subscriptions you need to push that update into.

  • Mercure payload format probably needs updating - with it now containing all 3 types of operations, I think it needs a field that contains what type of operation it was - create, update or deleted so mercure consumers can decide what to do

IMO, we should always consider a mercure update as a modified element, it's a new one if you don't have it in your referential, or else it's updated. You can detect it's deleted when the value is null.

I did modify the output for the "delete" operation to contain operationType: deleted - the one receiving mercure update can check for that. It just makes things easier, but you can totally figure out that it's a delete operation just by the fact it sends you IRI only and that's it, no other data.
The current data format was designed only for Update operation, that's why I think it might need updating.

  • Deleted stdObject contains too little information, kind'a want ability to retain some of the data in there, like for example the parent relation field that points to the parent ID the record was deleted for.

Not sure why we would need this. It makes things really complicated when we look for data associations, when do you stop looking for relations?
Yeah, I think it's not worth it. In my case it was when a chat message is deleted, I kind'a wanted to know what chat it belongs to. But I also receive an update for chat itself that something change, so I can re-query messages. But my idea was that if I know what chat id it is, I don't need to maintain the whole chat message => chat mapping array, which might get very big.

@stale

stale Bot commented Mar 18, 2025

Copy link
Copy Markdown

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale Bot added the stale label Mar 18, 2025
@soyuka soyuka added enhancement and removed stale labels Mar 21, 2025
@soyuka

soyuka commented Mar 21, 2025

Copy link
Copy Markdown
Member

I definitely need time to review this I think it's still something that we should integrate.

@psihius

psihius commented Mar 21, 2025

Copy link
Copy Markdown
Contributor Author

I definitely need time to review this I think it's still something that we should integrate.

Feel free to reach out on Symfony Slack into my DM's or tag me on #api-platform channel about this whole thing. I will be able to do changes/fixes to this whole thing next week cause I think we found a small bug to fix, so I will be touching the code :)

@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch from ead72e1 to c9cefd8 Compare April 24, 2025 06:39
@psihius

psihius commented Apr 24, 2025 •

Copy link
Copy Markdown
Contributor Author

I found some flaws in my previous implementation, split out completely the handling of collection updates and so that collection subscriptions generate their own subscription ID instead of piggybacking on individual item subscriptions.
Subscription operation got a new parameter that's by default false to maintain BC called "collection: bool" which controls if the given subscription is an individual item subscription or a whole collection subscription, now you can choose to use one or the other or both at the same time if you with it by defining two subscriptions :)

I've also added relationship handling for mercure updates, because those have never been handled so you could not any -to-many relationship data on an update, even if you asked for it in the GraphQL query. Nothing complicated in there, but it works and will allow people to have simple related payloads to be included with the mercure update payload.

There is more I would do, but it would require some major amount of time and effort that I simply just don't have: I would split out mercure update pipeline completely from graphql processing pipeline because part of GraphQL library work is happening at the response time once it;s out of ApiPlatform's graphql internals and as you can see from my latest commit, there are some things that have been missing because mercure updates happen as a side channel as a doctrine event listener.
Delete updates also do not have certain data I would like them to have, for example it would be nice to know an items parent related resource (like deleting a message on a chat - knowing the chat id would be very beneficial)
I also really need someone familiar with the internals to help me sort out the normalizers - why for mercure updates it uses item normalizer and not object normalizer for example? :) I get that it has to do with some serializer processor priority, but that stuff requires major digging and stumbling about (and debugging this giant and it's configuration is.... well... hard :D )

@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch 6 times, most recently from decfc5b to 9e61e92 Compare April 25, 2025 12:38
@soyuka

soyuka commented Apr 30, 2025

Copy link
Copy Markdown
Member

don't hesitate to add functional tests to help me understand the needs behind that, there are tests inside tests/Functional that are easier to setup then behat :)

@psihius

psihius commented Apr 30, 2025 •

Copy link
Copy Markdown
Contributor Author

don't hesitate to add functional tests to help me understand the needs behind that, there are tests inside tests/Functional that are easier to setup then behat :)

Yeah, I've been slowly working on it.

In other news, my last commit is more of a temporary fix for our production system, but the cache pool that's used there right now needs to be replaced. The system cache pool does not have locking and protection from parallel writing, which under 20+ users, started to overwrite each other's changes.
It worked for individual IRI items because there was very little writing done and mostly reading (you usually had 1-2 records in the cache, depending on how varied your fields were in the subscriptions you use). But on a collection cache, there was a lot of initial writing and it ended up being a writing race condition. So the pool needs to be replaced with one that supports locking and/or protection against parallel writing. System pool does not have those as far as I can tell.

I've also added private field data to deleted object data in the doctrine listener, probably same needs to happen in ODM listener. I needed to read deleted item fields and pass them on to the subscription manager.

@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch from ef602bf to cea37ca Compare June 26, 2025 14:22
Comment thread src/GraphQl/Serializer/ItemNormalizer.php Outdated
Comment thread src/GraphQl/Serializer/ItemNormalizer.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/GraphQl/Subscription/SubscriptionManager.php Outdated
Comment thread src/Metadata/GraphQl/Subscription.php Outdated
Comment thread src/Metadata/GraphQl/Subscription.php Outdated
Comment thread src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php Outdated
@psihius

psihius commented Jul 3, 2025

Copy link
Copy Markdown
Contributor Author

Thanks for the review, I will adjust to feedback.

I do have a question about collections, if I implement CollectionOperationInterface for the collection subscription, I assume the system will allow operating on collections via the reference to the resource aka /api/resource_items on the backend? Right now current code works only on individual items, which has security implications that you have to have an item and it's a bit of a hack to subscribe (you can't do that unless you have at least one record in the resource that's available to the current user).

I would like some references what to read and what files to dig into to explore proper collection subscription implementation and what helpers and components do I need to look at, since I'm not very familiar with broader internals. Like I did not know and would not find IdentifiersExtractor without the suggestion for quite a while if I continued to dig on my own :)

@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch 6 times, most recently from 0e1eb28 to 6d97b96 Compare March 22, 2026 02:49
@psihius

psihius commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Performed a rework of the feature based on feedback and did some internal cache related changes that changed how this works quite a bit.

Documentation PR: api-platform/docs#2267

The biggest change is that collection subscriptions are now modeled as their own GraphQL operation type via SubscriptionCollection, instead of trying to stretch the old single-item subscription model with extra flags. In practice, item and collection subscriptions are now exposed separately on the GraphQL Subscription root type, so the client explicitly chooses which subscription operation it is using.

Current behavior

  • Item subscriptions receive update and delete
  • Collection subscriptions receive create, update, and delete
  • create / update pushes contain the affected item normalized with the fields requested in the subscription query
  • Initial collection subscription registration returns subscription metadata only, with the wrapped item field being null
  • Delete uses one explicit envelope for both item and collection subscriptions:
{
  "type": "delete",
  "payload": {
    "id": "/books/1",
    "iri": "https://example.com/books/1",
    "type": "Book"
  }
}

I kept the delete payload explicit on purpose. For item subscriptions you could argue null would be enough because the subscription is already tied to one IRI, but for collection subscriptions the client still needs to know which item to remove. I did not want two different delete contracts depending on subscription type.

Internal changes

The old collection IRI derivation hack is gone. Collection subscription lookup is now based on resource metadata + getOperation(forceCollection: true) + IriConverter, which is much closer to how this should work in the framework.

private_fields is also now treated as a real GraphQL feature and not just an implementation side effect. The intended split is:

  • mercure.private controls private delivery on the Mercure side
  • private_fields controls API Platform-side partitioning of private subscription registrations using resource field values

This is meant for cases where a client is authenticated and allowed to see multiple scopes, but subscriptions still need to be isolated by something like organization / workspace / inbox / whatever the app defines. This is now validated explicitly, the extraction logic was centralized, and it now uses Symfony property access instead of raw getter assumptions.

Cache behavior changed quite a bit too.

  • The cache is treated as a subscription registry, not as a place where collection payloads are stored
  • Collection subscriptions now keep registration state only and are looked up through proper collection operation metadata
  • Item and collection subscriptions are partitioned consistently when private_fields are used, so private deliveries are separated by the configured scope and not mixed in one shared item cache bucket
  • Fan-out from item + collection subscriptions is deduplicated by subscription id
  • Delete handling now cleans up the correct partitioned item cache entries too

This makes the internal cache model much more predictable and fixes a few edge cases from the earlier implementation, especially around private subscriptions and collection notifications.

I also addressed the main review points around:

  • Proper collection subscription type
  • Removing the legacy internal collection flag
  • Removing the manual Mercure config merge in SubscriptionManager
  • Centralizing duplicated private field extraction logic
  • Removing array_merge usage in the hot path
  • Keeping BC on SubscriptionManagerInterface

Testing

Testing is much broader now too. The feature is covered in:

  • Manager tests
  • Listener tests
  • Schema / type / field tests
  • Processor tests
  • Serializer tests for nested Mercure relation normalization
  • A small functional GraphQL subscription contract test

@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch from f18a3ae to ffeb112 Compare October 6, 2026 18:54
Use the registering collection operation when looking up subscriptions for create, update and delete events. Avoid selecting a collection query or only the first collection subscription, and keep item lookups limited to item subscription operations.

Cover multiple collection subscriptions and HTTP registration followed by publication for all three event types.
@psihius
psihius marked this pull request as draft October 6, 2026 20:38
psihius added 13 commits October 6, 2026 23:39
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().

Signed-off-by: psihius <arvids.godjuks@gmail.com>
@psihius
psihius force-pushed the feature/graphql-subscription-for-all-operations branch from 2db0462 to cb1f7cf Compare October 7, 2026 15:46
@psihius

psihius commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Implementation walkthrough

This PR extends GraphQL Mercure subscriptions to cover item deletion and collection events, and reworks registration and publication to support multiple operations, private partitions, and concurrent workers.

Operation Events Scope
Subscription Update, delete One existing item
SubscriptionCollection Create, update, delete Individual changed items within the resource/partition

A collection subscription sends the changed item, not the entire collection. It does not store a growing collection snapshot.

The important distinction throughout the implementation is that metadata determines which operations may publish; cached registrations determine which subscriptions receive those publications. The registry does not replace operation metadata.

1. A client registers its selected fields

For example, a resource can expose both operation kinds:

#[ApiResource(
    mercure: true,
    graphQlOperations: [
        new Subscription(name: 'watch'),
        new SubscriptionCollection(name: 'watchCollection'),
    ],
)]

The client submits a GraphQL subscription with its field selection and receives the initial result and a mercureUrl. Collection registration also takes an existing item's IRI: that item passes through the subscription read/access pipeline and supplies any configured private partition. The resulting collection subscription is not limited to that item's IRI.

SubscriptionProcessor resolves the operation's Mercure options and calls SubscriptionManager::retrieveSubscriptionId(). The manager builds three distinct pieces of identity:

  • Bucket key: one SHA-256 hash of resource class, item/collection kind, item IRI for item subscriptions, and sorted private field values.
  • Operation key: shortName:name, distinguishing operations such as PublicMessage:watch and AdminMessage:watch on the same PHP class.
  • Subscription ID: incorporates the bucket scope, operation identity, and selected fields. Equivalent registrations reuse their ID.

SubscriptionStore::register() stores only IDs and selections in the registry. Conceptually:

bucket: Message class + item /messages/42 + tenant 7
    PublicMessage:watch
        [subscription-A, { id, text }]
        [subscription-B, { id, text, author { name } }]
    AdminMessage:watch
        [subscription-C, { id, text }]

bucket: Message class + collection + tenant 7
    PublicMessage:watchCollection
        [subscription-D, { id, text }]

Operation metadata, delivery settings, and complete payloads are not stored in these registry entries.

2. Doctrine changes are matched against resource metadata

During onFlush(), the listener collects created, updated, and deleted objects. For each object, storeObjectToPublish() loads its resource metadata and collects HTTP and GraphQL publications independently.

collectGraphQlPublications() still loops over the resource's declared GraphQL operations. It selects Subscription instances, excludes item subscriptions for create events, and evaluates each operation's Mercure configuration against the changed object. Disabled operations are excluded even if registrations remain cached.

The listener collects the applicable operations into one batch per changed object. For deletion, it captures the IRI, resource type, and private partition values before the entity disappears.

REST publication continues without GraphQL subscriptions. GraphQL publication can likewise remain enabled when REST publication is disabled. Each path uses its own operation's configuration, avoiding HTTP metadata overriding a subscription's privacy or delivery settings.

3. Operations share cache reads, while retaining their own behavior

After the flush, SubscriptionManager::getUpdates() groups the supplied operations by bucket key, reads each bucket once, and matches registrations to the eligible operations:

$subscriptions = $this->store->getSubscriptions($key, $collection);

foreach ($operations as $name => ['object' => $object, 'operation' => $operation]) {
    foreach ($subscriptions[$name] ?? [] as $subscription) {
        // Normalize the selected fields using this operation.
        // Prepare an update if publication is needed.
    }
}

For example, three item operations sharing an item and private partition need one registry read, rather than three. If only two have registrations, only those two perform normalization and prepare updates. A collection bucket requires a separate read; different private partitions also remain separate.

An eligible operation without registrations still undergoes metadata evaluation and grouping, but produces no payload. A cached operation removed from metadata, or disabled for this event, produces no publication.

The actual subscription operation is passed through normalization, preserving its normalization settings. Subscription behavior is identified by operation type rather than magic operation names. ItemNormalizer also retains subscription context when normalizing selected nested relationships.

4. Prepared updates are delivered, then acknowledged

The manager yields each prepared update together with the operation supplied by the listener. postFlush() processes deletes first, then creates, then updates. publishUpdates() uses each operation's resolved options to publish synchronously or dispatch through Messenger. Create/update notifications are acknowledged only after successful publication or dispatch; delete notifications are acknowledged after every delivery attempt, including a failed one.

GraphQL updates use the topic generated from the subscription ID. There is no need to configure topics for GraphQL subscriptions; configured resource topics remain part of the HTTP publication path. Each subscription operation retains its hub, private/public delivery, and asynchronous settings. For Messenger, a hub stamp and MercureHandlersLocator preserve the destination while retaining the Update message type and configured handlers.

Create/update payloads retain the selected GraphQL response structure. Deletes use a small envelope:

{
  "type": "delete",
  "payload": {
    "id": "/messages/42",
    "iri": "https://example.com/messages/42",
    "type": "Message"
  }
}

Deletion cleanup and delivery failures

Once Doctrine has successfully flushed a deletion, its item registrations must be retired even if Mercure delivery fails. The flow is:

  1. The store reads an item bucket and removes its registrations and fingerprints before yielding its delete notifications. This also removes registrations for disabled operations sharing that bucket. Collection registrations remain for subsequent events; buckets with no eligible operation are not visited.
  2. The listener attempts each notification. If publication or Messenger dispatch fails, it retains the first delivery error and continues iterating, allowing subsequent item buckets and private partitions to be retired and their notifications attempted.
  3. acknowledge() runs in finally, so a failed delete delivery cannot skip acknowledgement. For the default store, delete acknowledgement is a no-op because cleanup already happened; custom managers receive the same completion signal.
  4. After the delete pass, the listener rethrows the first delivery error and resets its buffers. Creates and updates run only if that pass succeeds, and their delivery errors propagate immediately. Cache, lock, or update-preparation failures still propagate; this continuation handles delivery failures.

For example, if two deleted items have active subscriptions and the first item's hub publication fails, the second item's buckets are still cleaned up and its delete notifications are attempted before the original error is rethrown.

Doctrine does not replay a flushed deletion after delivery failure, so retaining item registrations would not provide a retry mechanism. A delete Update already accepted by Messenger can still be retried using its queued topic and payload. Synchronous delivery failure, or failure before a message is queued, has no automatic replay.

Private partitions and Mercure permissions

This provides a GraphQL counterpart to the documented Dispatching Restrictive Updates (Security Mode). In the REST example, the publisher adds an owner-specific alternate topic, and the subscriber's JWT authorizes an appropriate topic selector. This scopes delivery to the resources that subscriber may receive.

GraphQL already publishes to generated subscription topics. Here, the configured private field values become part of the subscription identity itself, so different partitions produce different subscription IDs and therefore different Mercure topics—even when the operation and selected fields are identical. The scope is encoded into the generated topic rather than expressed through a manually configured alternate topic.

new SubscriptionCollection(
    name: 'watchMessages',
    mercure: [
        'private' => true,
        'private_fields' => ['tenant', 'chat'],
    ],
)

For the same resource, operation, and selection { id, text }, collection registrations behave like this (IDs are illustrative):

Enrollment item's private values Subscription ID / generated topic
tenant 7, chat 12 A
tenant 7, chat 13 B
tenant 8, chat 12 C
Another item in tenant 7, chat 12 A again

When a message in tenant 7/chat 12 changes, API Platform calculates that partition's bucket directly and publishes to its registered subscription topics. It does not scan other tenants/chats or publish that event to B or C. Different field selections within the same partition can still produce additional subscription IDs.

The two settings have complementary roles: private_fields separates registrations and their generated topics; private: true tells Mercure to enforce subscriber authorization for those topics. Separate cache buckets alone would be insufficient: Mercure sees topics, not cache keys, so reusing an ID across partitions would lose the separation at delivery time. The bucket and ID construction is in SubscriptionManager.

PropertyAccessorValueExtractor reads values through PropertyAccess and resolves related resources using their identifier metadata rather than assuming getId(). Invalid configuration or unreadable fields fail explicitly.

As with the documented restrictive-update approach, application authorization and Mercure subscriber permissions must match the intended access scope. The application still controls who may register and which generated topics their Mercure credentials authorize; partition hashing does not grant or revoke those permissions.

Payload suppression and concurrency

Previously, complete item payloads lived alongside registrations. The store now separates registry entries from per-subscription SHA-256 fingerprints. Collections retain neither payloads nor fingerprints.

prepareUpdate() suppresses unchanged item payloads. For example, changing an unselected field does not publish if the selected result remains identical. Initial item results seed the fingerprint; preparing subsequent updates does not advance it. The fingerprint advances only after successful hub acceptance or Messenger dispatch acceptance. A failed item publication therefore remains eligible if a later event or explicit replay prepares it again; this does not schedule an automatic retry. Dispatch acceptance does not imply client delivery; Messenger handles subsequent retries of accepted messages.

Registry registration/deletion uses a bucket lock. Fingerprint acknowledgement uses a separate per-subscription lock; normalization, hashing, and network publication happen outside those locks. Versioned comparison detects overlapping publications and invalidates conflicting suppression state so a later correction is not skipped. This is change suppression, not an exactly-once delivery or ordering guarantee.

The Symfony LockFactory is injected and configurable. Multi-server deployments need shared subscription caches and a shared lock backend. The configuration notes describe the pools and lock-factory alias.

Naming, migration, and verification

Unnamed collection subscriptions use collection. The legacy implicit item name remains available with a deprecation; setting defaults.extra_properties.legacy_graphql_subscription_names to false selects item. Explicit name: 'update' preserves the existing GraphQL field name. See OperationDefaultsTrait.

Custom managers must implement the updated SubscriptionManagerInterface: operation-aware registration, batched getUpdates() for one changed resource, and explicit acknowledge(). Yielded operations must retain the supplied instances and their resolved delivery settings. Delete attempts are acknowledged even after delivery failure.

The cache layout and generated IDs/topics change. Upgrading requires stopping old workers, clearing subscription state, and re-establishing subscriptions. Old and new workers must not share the registry.

The focused local checks passed 165 tests / 1,197 assertions, with deprecation notices and PHPUnit notices. Formatting and PHP syntax checks passed. The scoped PHPStan run reported two missing MongoDB ODM class errors. These local results do not represent a complete hosted CI run.

Coverage includes naming/schema behavior, identity and private partitions, normalization, independent REST/GraphQL delivery, hubs, failed publication, and concurrency. The shared-bucket regression tests verify read counts, distinct short-name/operation identities, partition isolation, and deletion cleanup; the functional publication test verifies the cache-read reduction through the Doctrine listener.

The delete delivery regression tests cover synchronous and Messenger dispatch failures, cleanup across subsequent items and private partitions, preservation of the first delivery error, and acknowledgement after failed deletes while failed creates/updates remain unacknowledged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants