Skip to content

fix(pubsub): reject subscription filter changes after creation - #112

Merged
hectorvent merged 2 commits into
floci-io:mainfrom
Raksus:fix/pubsub-filter-immutable
Aug 3, 2026
Merged

fix(pubsub): reject subscription filter changes after creation#112
hectorvent merged 2 commits into
floci-io:mainfrom
Raksus:fix/pubsub-filter-immutable

Conversation

@Raksus

@Raksus Raksus commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Closes #110

Follow-up to #103, as invited in its merge comment.

Problem

The filter is immutable in GCP, but subscriptions.patch applied changes to it, and a patch without an update mask cleared it. This was also the one case where publish-time evaluation could diverge from GCP's delivery-time semantics: they are equivalent only while the filter cannot change between enqueue and delivery. That point is @hectorvent's, from the #103 merge comment.

Behaviour, verified against the live Pub/Sub API

Rather than infer the semantics from the documentation, I checked each case against real Pub/Sub using a throwaway topic and subscription:

Request Real Pub/Sub This PR
A. mask filter, different value 400 INVALID_ARGUMENT, "the 'filter' field in the Subscription is not mutable." same
B. mask filter, unchanged value 400 INVALID_ARGUMENT, same message same
C. mask labels 200, filter preserved same
D. no update mask 400, "The update_mask … must be set, and must contain a non-empty paths list." see below
E. mask filter,labels 400, labels unchanged same
F. mask labels, body carries the same filter 200, filter ignored same
G. mask labels, body carries a different filter 200, filter ignored same
H. mask filter, empty value, subscription has no filter 400 INVALID_ARGUMENT same
I. mask filter, adding a filter to an unfiltered subscription 400 INVALID_ARGUMENT same

Three consequences worth calling out, because each is a decision that would otherwise have to be reverse-engineered from the diff:

  • Rejection keys off the presence of filter in the update mask, not a comparison against the stored value (A, B, H, I). Allowing a no-op restatement is the intuitive design, and it is wrong — GCP rejects that too, even when both the stored and the supplied filter are empty.
  • A filter in the request body outside the mask is ignored, not rejected (F, G). The update mask governs. This matters for clients that read-modify-write a whole subscription, such as the Terraform provider.
  • A patch with no update mask at all is handled on its own terms. GCP rejects those outright for the missing mask (D), so there is no GCP behaviour to mirror; floci-gcp accepting them is a pre-existing deviation I am not changing here. On that path a body carrying the current filter is accepted and the filter left alone, a body carrying a different one is rejected, and a body omitting it leaves the filter in place instead of clearing it — the last of those is a behaviour change, and preserving an immutable field seemed the conservative reading.

The error message is GCP's verbatim. The maskless branch reuses it even though no mask was supplied, since GCP cannot reach that state; a second message for an unreachable case seemed worse than the reuse.

Validating the filter on update is now unreachable — no update can change it — so that call is gone from both overloads. Creation still validates, and after this change PubSubService.java:219 is the only place in the codebase that writes a subscription filter:

$ grep -rn setFilter src/main/java/
  services/pubsub/PubSubService.java:219              # createSubscription, validates
  services/pubsub/model/StoredSubscription.java:50    # model setter
  services/pubsub/PubSubSubscriberController.java:397 # response echo
  core/common/GqlParser.java:56                       # unrelated (Datastore)

Tests

mvn test578 tests, 0 failures (Java 25 in a container). New cases cover, on both the FieldMask and REST overloads: rejection when changing, clearing, adding and restating a filter; a filter in the request body outside the mask being ignored; a maskless patch that omits, restates or changes the filter; and that a rejected multi-field update leaves the other fields untouched.

Compatibility suites run locally against a JVM build of this branch: sdk-test-java PubSubTest 17/17 and sdk-test-python test_pubsub 7/7, including a new SDK case asserting InvalidArgumentException for both a changed and an unchanged filter, and that a labels-only update preserves the filter.

Not run locally: sdk-test-node, sdk-test-go, sdk-test-gcloud, compat-terraform, compat-opentofu and the native GraalVM build.

Docs

docs/services/pubsub.md gains an immutability subsection under Subscription Filters, covering what is rejected, that the update mask governs, the maskless path, and GCP's documented snapshot-and-recreate path for changing a filter.

Not addressed here

An empty FieldMask on the gRPC UpdateSubscription is a silent no-op where GCP would reject for a missing mask. That is the same pre-existing deviation as the REST maskless path and predates this change.

The filter is an immutable property of a subscription in GCP: "after you
create a subscription, you cannot update the subscription to modify the
filter". floci-gcp accepted filter changes through subscriptions.patch, which
also left publish-time evaluation observably different from GCP's
delivery-time semantics — a filter changed between enqueue and delivery would
have been applied by GCP but not here.

Reject the modification where the filter was already being checked on update,
matching what real Pub/Sub returns: INVALID_ARGUMENT with "Invalid update_mask
provided in the UpdateSubscriptionRequest: the 'filter' field in the
Subscription is not mutable."

The rejection keys off the presence of filter in the update mask rather than a
comparison against the stored value, because that is what GCP does: restating
the current filter is rejected too. A patch that does not name filter leaves it
untouched and succeeds.

Validating the filter on update is now unreachable, since no update can change
it, so that call is gone; creation still validates.
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes Pub/Sub subscription filters immutable after creation while preserving existing filters during unrelated and maskless updates.

  • Rejects any update mask that names filter, including no-op restatements.
  • Allows maskless patches carrying the existing filter and preserves filters when they are omitted.
  • Adds service, REST integration, and Java compatibility coverage.
  • Documents update-mask and filter-immutability behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported maskless-patch rejection is corrected while the stored filter remains immutable and preserved.

Important Files Changed

Filename Overview
src/main/java/io/floci/gcp/services/pubsub/PubSubService.java Rejects masked filter updates and safely preserves immutable filters during maskless or unrelated updates.
src/test/java/io/floci/gcp/services/pubsub/PubSubServiceTest.java Covers changed, removed, added, unchanged, unmasked, and unrelated-mask filter-update cases.
src/test/java/io/floci/gcp/services/pubsub/PubSubRestIntegrationTest.java Verifies REST rejection and filter preservation across masked updates.
compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/PubSubTest.java Adds client-level compatibility coverage for immutable filters and labels-only updates.
docs/services/pubsub.md Documents filter immutability, update-mask semantics, and the snapshot-and-recreate workflow.

Reviews (2): Last reviewed commit: "fix(pubsub): keep maskless updates that ..." | Re-trigger Greptile

Comment thread src/main/java/io/floci/gcp/services/pubsub/PubSubService.java Outdated
…orking

The immutability guard treated any filter in the request body as a change, so a
maskless REST patch that echoed the subscription back — a read-modify-write
carrying its own unchanged filter — was rejected, where before it succeeded.

With an explicit mask the presence of "filter" is the trigger, matching GCP.
Without a mask GCP rejects the request outright for the missing mask, so there
is no GCP behaviour to mirror and only a value that actually differs is treated
as a change.
@hectorvent

Copy link
Copy Markdown
Contributor

Thank you, this is a model follow-up. The live API verification table made this review straightforward.

The change matches the documented behavior, filters cannot be updated after creation (Pub/Sub filtering docs), and rejecting on mask presence rather than value comparison matches what you measured. Structure and error handling follow the repo conventions, and the coverage on both overloads plus the SDK suite case is exactly what AGENTS.md asks for.

(follow-up, separate PR) Would you be open to filing a small tracking issue for the pre-existing maskless and empty FieldMask deviation you describe under "Not addressed here"? That observation deserves better than being buried in a merged PR body.

Nothing blocking from my side.

Merging.

@hectorvent
hectorvent merged commit 13d0b76 into floci-io:main Aug 3, 2026
12 checks passed
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.

[BUG] Pub/Sub subscription filter can be modified after creation

2 participants