Skip to content

Meter and limit GraphQL subscriptions - #391

Open
collins-uzu wants to merge 1 commit into
Divinity-Labs:mainfrom
collins-uzu:task-363-metered-subscriptions
Open

collins-uzu wants to merge 1 commit into
Divinity-Labs:mainfrom
collins-uzu:task-363-metered-subscriptions

Conversation

@collins-uzu

Copy link
Copy Markdown

Summary

  • Require a valid API key during the GraphQL WebSocket connection-init handshake.
  • Meter each subscription activation at the shared $0.10 GraphQL price and enforce usage quota.
  • Cap concurrent subscriptions per API key; configure the limit with GRAPHQL_MAX_SUBSCRIPTIONS_PER_KEY (default: 10).
  • Document the connection credentials, pricing, and limit.

Tests

  • npm test (62 files passed, 1 skipped; 643 tests passed, 1 skipped)
  • npx tsc --noEmit

Closes #363

@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@collins-uzu Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Collaborator

Needs a rebase — main moved six times this morning (#379, #380, #382, #392, #401, #310 all landed) and this now conflicts. Push a rebase and I will pick it up; nothing is queued ahead of it.

@Miracle656

Copy link
Copy Markdown
Collaborator

Needs a rebase — main has moved a lot today (#306, #309, #252, #256, #355, #381, #390, #402 all landed) and this now conflicts.

Worth knowing before you rebase: #402 replaced every console.* call with a pino logger, so if this PR adds logging, express it as logger.* on a component child rather than console.* — there is a test that fails the build on a stray console call in non-test source.

Push the rebase and I will pick it up; nothing is queued ahead of it.

@Miracle656

Copy link
Copy Markdown
Collaborator

Correction to my earlier review.

I described this branch as hundreds of commits behind with tens of thousands of lines of deletions. The deletion figure was wrong — it came from git diff origin/main <branch>, which compares the two tips, so everything main gained since you branched appeared as a deletion on your side. A merge works from the merge base and keeps those files.

Measured properly against current main:

conflicts: 3
files a merge would delete: 0

So the real cost is resolving 3 conflicts, not reconstructing the repository. I apologise for the framing.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Scanned clean, and merges with only three conflicts — far fewer than my earlier review implied, and I have corrected that separately. The feature is one I want: an unmetered, unlimited subscription is the cheapest way for one API key to pin a process, and active_subscriptions on main only counts them.

The blocker is that src/api/graphql.ts has been rewritten underneath you, and the two versions disagree about how a subscription is delivered. This is a port, not a merge, which is why I am handing it back rather than resolving it myself.

What changed on main

Your branch subscribes to one topic and filters per subscriber:

subscribe: withFilter<…>(
  async (_root, _args, { pubsub, apiKey }) => { … pubsub.subscribe(PRICE_TOPIC) … },
  (payload, { pair, network }) => payload.priceUpdated.pair === pair && …
)

main now routes by topic instead, so no per-subscriber filter callback runs at all:

subscribe: withScope('subscribe', (_root, { pair, network }, { pubsub }) => {
  const topics = network == null
    ? KNOWN_NETWORKS.map(n => priceTopic(pair, n))
    : [priceTopic(pair, network)]
  …
  queue.close.push(release)   // release rides Mercurius's close list
})

Two consequences for your code:

  • withFilter is gone, and with it the filter predicate. Routing happens by topic name now.
  • withScope wraps it — that is #623's per-field scope enforcement, which landed this morning. Subscriptions are gated on the subscribe scope, so whatever you add has to sit inside that wrapper rather than replacing it.

What to port, and where it goes

Your three pieces all still apply; they just attach to different places:

  1. The per-key concurrent limit. activeSubscriptionsByKey, the maxSubscriptionsPerKey() check and the increment belong at the top of the new resolver body, before pubsub.subscribe(topics). Note the new signature destructures { pubsub } only — you will need apiKey from the context too.
  2. The release path. Do not reimplement it. main already has a release() that rides queue.close, which is the part your version could not do — your finally in the async generator fires when the generator is drained, not when the client disconnects. Add your per-key decrement into that existing release, so the count follows the real subscription lifetime.
  3. The quota check. checkQuota / recordUsage / parseCents(GRAPHQL_REQUEST_PRICE) go before the subscribe call, with the release called on the failure path — the existing .catch already does that for you.

That last point is the one worth the most: main's release-on-close is strictly better than what your branch has, so porting onto it makes your limit more accurate rather than less.

Two smaller conflicts, both trivial: .env.example and README.md are additive on both sides and want a union.

Rebase, port those three pieces, and re-request — main is green (tsc clean, 1,874 tests), so anything that fails afterwards is yours. Happy to look quickly once it builds.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

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.

Subscriptions are free and unmetered

2 participants