Skip to content

Commit 6ada85e

Browse files
committed
⚡️(yhub) cache authz verdicts to cut backend calls on connections
A connection to the collaboration server — websocket or http fallback — can chain up to three calls to the backend: users/me, documents/{id} and accesses/me. On the fallback poll path this repeats every few seconds per client, and the calls bypass the backend throttling (X-Y-Provider-Key), so a busy instance hammers the backend unthrottled. Add an opt-in authz cache in yhub, three layers with different invalidation physics: - identity: users/me -> userid, in-process per replica, keyed on the SHA-256 of the Cookie header. Nothing can invalidate it and nothing needs to: a dead session fails closed at the abilities fetch. Kills the users/me call on every poll and REST request. - abilities: the documents/{id} verdict, in the shared redis per (document, cookie hash). The one payload a stale entry would over-grant, so reset-connections deletes every entry of the document BEFORE triggering the recheck — the recheck then re-runs authorize against post-invalidation state and the revocation stays immediate. The TTL is only the backstop for a lost kick. Anonymous callers share one entry per document: what they may do is the link configuration, and nothing about them distinguishes one from another. - access date: the accesses/me created_at bounding the history a user may read. Append-mostly by design, invalidated by user-scoped kicks. All three TTLs default to 0: caching off, byte-identical behavior. Backend refusals (403/404) are cached like any verdict, so a client that lost access costs one call per TTL instead of one per poll. An unanswered backend is never cached — an error is not a permission decision. Keys carry only sha256 hashes, never session ids. Warm-state connections and polls make zero backend calls. Watch yhub_authz_cache_lookups_total{layer,result} against yhub_backend_request_duration_seconds{route} before raising the TTLs.
1 parent 040b9f1 commit 6ada85e

12 files changed

Lines changed: 1011 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
- ✨(backend) add a setting for the http fallback polling interval #2827
1212
- ✨(backend) add a setting to turn off awareness on the http fallback #2827
13+
- ⚡️(yhub) cache authz verdicts to cut backend calls on connections #2832
1314

1415
### Changed
1516

‎documentation/env.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ documents what each of them changes.
202202
| YHUB_TASK_DEBOUNCE_MS | How long an update waits on the redis stream before a worker persists it | 10000 |
203203
| YHUB_MIN_MESSAGE_LIFETIME_MS | How long persisted updates stay replayable from redis rather than read back from postgres | 60000 |
204204
| YHUB_BACKEND_REQUEST_TIMEOUT_MS | How long a call to the backend (`users/me`, `documents/{id}`, `accesses/me`) may take before it is given up on and the caller told to retry, in ms | 5000 |
205+
| YHUB_IDENTITY_CACHE_TTL_MS | How long a resolved identity is reused for the same session cookie, in ms. 0 disables this layer (default) | 0 |
206+
| YHUB_ABILITIES_CACHE_TTL_MS | How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick. 0 disables it | 0 |
207+
| YHUB_ACCESS_CACHE_TTL_MS | How long an access date (the history bound) is served from cache, in ms. 0 disables it (default) | 0 |
205208
| SENTRY_DSN | Sentry DSN, unset disables it: the SDK is then not even loaded. Can be read from a file with SENTRY_DSN_FILE | |
206209
| SENTRY_ENVIRONMENT | Environment the events are reported under | production |
207210
| SENTRY_RELEASE | Release the events are reported under. Detected by the SDK when unset | |

‎src/helm/impress/values.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,9 @@ yhub:
11891189
## @extra yhub.envVars.YHUB_TASK_DEBOUNCE_MS How long an update waits on the redis stream before a worker persists it, in ms (default: 10000)
11901190
## @extra yhub.envVars.YHUB_MIN_MESSAGE_LIFETIME_MS How long persisted updates stay replayable from redis, in ms (default: 60000)
11911191
## @extra yhub.envVars.YHUB_BACKEND_REQUEST_TIMEOUT_MS How long a call to the backend may take before yhub gives up on it and tells the caller to retry, in ms (default: 5000)
1192+
## @extra yhub.envVars.YHUB_IDENTITY_CACHE_TTL_MS How long a resolved identity is reused for the same session cookie, in ms; 0 disables this layer (default)
1193+
## @extra yhub.envVars.YHUB_ABILITIES_CACHE_TTL_MS How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick; 0 disables it
1194+
## @extra yhub.envVars.YHUB_ACCESS_CACHE_TTL_MS How long an access date (the history bound) is served from cache, in ms; 0 disables it (default)
11921195
## @extra yhub.envVars.SOFT_MIGRATION Set to "true" to seed rooms from the legacy Django/S3 document store on first access
11931196
## @extra yhub.envVars.LEGACY_S3_ENDPOINT_URL Required by SOFT_MIGRATION, endpoint of the legacy Django media bucket, without a path (e.g. https://s3.example.com)
11941197
## @extra yhub.envVars.LEGACY_S3_ACCESS_KEY_ID Required by SOFT_MIGRATION, read access to the legacy bucket (or LEGACY_S3_ACCESS_KEY_ID_FILE)

‎src/yhub-server/README.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,58 @@ were configurable:
274274
| `YHUB_TASK_DEBOUNCE_MS` | `10000` | How long an update waits on the stream before a worker persists it |
275275
| `YHUB_MIN_MESSAGE_LIFETIME_MS` | `60000` | How long persisted updates stay replayable from redis |
276276
| `YHUB_BACKEND_REQUEST_TIMEOUT_MS` | `5000` | How long a call to the Docs backend may take before it is given up on |
277+
| `YHUB_IDENTITY_CACHE_TTL_MS` | `0` | How long a resolved `users/me` identity is reused for the same Cookie header (authz cache, off by default) |
278+
| `YHUB_ABILITIES_CACHE_TTL_MS` | `0` | How long a `documents/{id}` verdict is served from cache before it is re-read (deleted by `reset-connections`, see the authz-cache section) |
279+
| `YHUB_ACCESS_CACHE_TTL_MS` | `0` | How long an `accesses/me` date is served from cache before it is re-read |
280+
281+
### The authz cache
282+
283+
Off by default, the three TTLs above turn on a cache that keeps the
284+
connection path from calling the backend on every request. The three payloads
285+
it holds go stale differently, so each has its own layer:
286+
287+
- **identity** (`users/me` → userid, keyed on the SHA-256 of the whole
288+
`Cookie` header): kept in-process, per replica, no invalidation. Nothing on
289+
the backend side can name the key, and nothing needs to — a logged-out
290+
caller is at most one TTL stale, and the stale userid then fails closed at
291+
the abilities fetch, which answers 401 and denies. One nuance of that
292+
staleness: a caller editing a *public* document keeps their userid for up
293+
to one TTL after a server-side logout, so those edits stay attributed to
294+
the logged-out user instead of `anonymous` — access is unaffected.
295+
- **abilities** (`documents/{id}` → the verdict): the one payload a stale
296+
entry would over-grant, so it lives in yhub's own redis — the one already
297+
carrying the document streams, shared between the replicas and never
298+
touched by the backend — under
299+
`{prefix}:authz:abilities:{org}:{docid}:{sha256(cookie)}`, and the
300+
shortest TTL bounds what any lost invalidation can leave behind. Anonymous
301+
callers share one entry per document (`…:anon`): what they may do is the
302+
document's link configuration, and nothing about them distinguishes one
303+
from another.
304+
- **access** (`accesses/me` → the stored `created_at` bounding history
305+
reads): append-mostly, keyed per user, invalidated by user-scoped kicks.
306+
307+
**Revocation stays immediate.** The backend's `reset-connections` kick — a
308+
plain HTTP POST, it reads nothing of yhub's stores — reaches one replica,
309+
which — before triggering the recheck — deletes every `authz` entry of that
310+
document from yhub's redis (see `invalidateDocumentAuthz`); the recheck
311+
itself is distributed to all replicas through the same redis stream, and
312+
every recheck re-runs `authorize`, which then reads post-invalidation
313+
state. The abilities TTL is therefore only the backstop for a lost kick,
314+
not the mechanism. One bounded window remains: an
315+
`authorize` in flight on another replica when the DEL runs can SET its
316+
pre-revocation verdict back after the deletion, admitting a connection opened
317+
in exactly that instant for at most one abilities TTL — the kick's recheck
318+
then re-checks it, and a fetch the backend answered *after* the revocation
319+
commits caches the fresh denial, not the grant.
320+
321+
A backend refusal (403/404) is cached like any other verdict: a client that
322+
lost access costs one call per TTL, not one per poll. A backend that did not
323+
answer is never cached — an error is not a permission decision.
324+
325+
Watch `yhub_authz_cache_lookups_total{layer,result}` alongside
326+
`yhub_backend_request_duration_seconds{route}`: the hit rate tells you what
327+
the cache is saving, and the remaining `users_me` / `document` /
328+
`accesses_me` rates tell you what is left.
277329

278330
**Concurrency** multiplies with the number of processes running a worker, since
279331
redis hands each task to exactly one of them: the two are interchangeable up to

0 commit comments

Comments
 (0)