Skip to content

fix(rate-limiter): namespace EWMA keys away from sliding-window keys - #945

Open
chappie-daemon wants to merge 1 commit into
mainfrom
fix/ewma-key-namespace
Open

chappie-daemon wants to merge 1 commit into
mainfrom
fix/ewma-key-namespace

Conversation

@chappie-daemon

Copy link
Copy Markdown
Collaborator

Takes @cameri's call in the issue and @Perlover's diagnosis: namespace the EWMA keys. Closes #930.

The bug, reproduced

Seeded a real Redis 6.2 with a sliding-window sorted set at 1.2.3.4:connection:120000 and ran the shipped EWMA script against it:

ERR Error running script ... @user_script:6: WRONGTYPE Operation against a key holding the wrong kind of value

Same error, same line as the report. Both strategies build their key the same way (<identity>:<bucket>:<period>) but store different types — a sorted set versus a hash — so any key left by sliding_window breaks every ewma request that touches it.

The fix

EWMARateLimiter now writes under an ewma: prefix:

export const EWMA_KEY_PREFIX = 'ewma:'
export const ewmaKey = (key: string): string => `${EWMA_KEY_PREFIX}${key}`

A prefix rather than the self-healing TYPE/DEL guard the issue also suggested. Deleting the key would destroy the sliding window's state, so switching strategy back would silently start from a fresh window — and that switch is a normal operation, not an upgrade path.

Affected call sites are unchanged; the prefix is applied once, inside the limiter, so nothing has to remember it.

Verification

Check Before After
hit() against a legacy zset WRONGTYPE @user_script:6 returns normally
key written …:connection:120000 (hash, collided) ewma:…:connection:120000 (hash)
legacy zset — untouched, still zset
TTL on the new key — 120s, as before

The before/after used the built EWMARateLimiter class against real Redis 6.2, not a stub — a mocked adapter cannot produce a WRONGTYPE, which is exactly why the existing unit test never caught this.

Tests. A unit test pins the key the script receives. An integration scenario seeds a real sliding-window zset under the shared name and still expects the client to be rate limited, so a regression fails on behaviour. The existing @rate-limiter step now seeds the namespaced key, since seeding the old name would leave the limiter on a fresh window and the original scenario would stop covering anything.

Full unit suite: 2521 passing, 5 failing — the same user-subscription-repository failures at base, untouched by this change. tsc --noEmit and biome clean.

Upgrade note

No manual Redis cleanup: old zsets stay where they are and simply stop being read. They expire on their own TTL, and the ewma: keys start empty, so the first request after the upgrade is unrestricted — the same fresh-start a restart already gives a sliding window.

@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e994d2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
nostream Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chappie-daemon
chappie-daemon deployed to website-preview October 23, 2026 21:49 — with GitHub Actions Active
EWMA and sliding_window store different Redis types under the same key name:
a sliding window keeps a sorted set, EWMA keeps a hash. Both write
`<identity>:<bucket>:<period>`, and 2.x shipped only the sliding window, so a
cache volume carried across the upgrade holds sorted sets where the EWMA script
then runs HGET - a WRONGTYPE error raised on every connection and every message
until the old key expires. Switching `limits.rateLimiter.strategy` back and
forth does the same thing to a live instance.

EWMA now writes under an `ewma:` prefix. A prefix rather than a
delete-if-wrong-type guard, so both strategies keep their own window and a
switch back to sliding_window resumes it instead of starting from zero.

Reproduced before the change on a real Redis 6.2 with a zset seeded at the
shared key name: `@user_script:6: WRONGTYPE`, the same error and line the issue
reports. After it, the same call returns normally, writes a hash under
`ewma:`, and leaves the legacy zset untouched.

The unit test pins the key the script receives. The integration scenario seeds
a real sliding-window zset under the shared name and asserts the client is still
rate limited, so a regression fails on the behaviour rather than on a string.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Site preview for this PR (includes all changes on this branch): https://nostream-web-pr-945.cameri.workers.dev/preview/945/

The https://ema.tnsor.network/preview/945/ form serves the same build once this PR merges — the production router that proxies /preview/* ships with it.

Updates with every push to this PR; deleted when the PR closes.

@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The PR affects rate limiting and the container release and deployment workflow.

Keep prereleases from replacing the production latest image before merging.

Findings

  1. P1 Prereleases replace the production image ▶
  2. P2 Pinned image tag is wrong ▶
  3. P2 Webhook migration steps are incomplete ▶

Summary

The PR separates EWMA Redis keys from sliding-window keys and moves production images to release-only publishing.

  • EWMA rate limits use their own Redis keys.
  • Container images publish only for official v* releases.

Acknowledged upgrade behavior: chappie-daemon explicitly stated that old EWMA state starts empty after the upgrade and described that fresh start as intentional.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  K[Caller key] --> E[EWMA limiter]
  K --> S[Sliding-window limiter]
  E --> H["Redis hash: ewma:key"]
  S --> Z["Redis sorted set: key"]
Loading

Reviews (1) · Last reviewed commit: "fix(rate-limiter): namespace EWMA keys a..." · Reviewed by Greptile

Comment thread .github/workflows/release.yml Outdated
# here is what makes `latest` something this repository promises.
tags: |
type=semver,pattern={{version}}
type=raw,value=latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Prereleases replace the production image

Publishing a prerelease such as v3.7.0-beta.1 now overwrites latest. The workflow accepts every published v* release, and the new type=raw,value=latest rule also applies to prereleases. Both production stacks now use latest, so their next update can install a test release. Only assign latest when github.event.release.prerelease is false.

Suggested change
type=raw,value=latest
type=raw,value=latest,enable=${{ !github.event.release.prerelease }}

Comment thread docs/CI-MIGRATION.md Outdated
None. Developers and deployment users receive the same `main` and per-commit
container image tags automatically after CI succeeds.
Deployments tracking `ghcr.io/cameri/nostream:main` should be updated to track released versions,
such as `ghcr.io/cameri/nostream:latest` or pinned release tags (e.g. `ghcr.io/cameri/nostream:v3.6.2`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Pinned image tag is wrong

The workflow's type=semver,pattern={{version}} produces 3.6.2 from v3.6.2, without the leading v. An operator following this example can get a missing-image error. Use ghcr.io/cameri/nostream:3.6.2 here and correct the same example in docs/DEPLOYMENT.md.

Comment thread docs/DEPLOY-RUNBOOK.md Outdated
Comment on lines +25 to +30
1. `NOSTREAM_TAG` in `/opt/nostream/webhook/.env` set to `latest`.
2. The GitHub webhook subscribed to **releases** as well as **push**, because
the receiver ignores anything that is not a push to the watched branch.

Both live in `containers/nostream-dev-webhook/` (the versioned source) with its
own README. Until both are done, deployments are manual: pull the tag by hand

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Webhook migration steps are incomplete

If the receiver ignores non-push events as stated here, subscribing the GitHub webhook to releases will not make it accept them. Neither listed step changes that filter, so operators can finish both steps without restoring automatic image pulls. Document the receiver change as a required step and link its actual source; containers/nostream-dev-webhook/ is absent from this repository.

@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 40029515750

Warning

No base build found for commit c40e570 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 75.467%

Details

  • Patch coverage: 2 of 2 lines across 1 file are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 11288
Covered Lines: 8908
Line Coverage: 78.92%
Relevant Branches: 5848
Covered Branches: 4024
Branch Coverage: 68.81%
Branches in Coverage %: Yes
Coverage Strength: 802.92 hits per line

💛 - Coveralls

This branch was successfully deployed

1 active (outdated) deployment
website-preview — 8ce15935 Deployed Oct 23, 2026 by chappie-daemon via preview #104
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] Upgrade from 2.x: EWMA rate limiter hits WRONGTYPE on sliding-window keys left in Redis

2 participants