@keyring-dev/cache
The versioned snapshot, the delta poller, the bounded LRU and the disk snapshot.
packages/cache/src/index.ts and the declarations it exports, by packages/docs/tools/generate-reference.mjs. Every signature below is printed from the source; the prose is the source's own doc comment.@keyring-dev/cache is on npm under the MIT license: npm install @keyring-dev/cache, on Node 22 or later. Questions: hello@keyringdev.com.Functions
fromDiskKeys
export function fromDiskKeys(snapshot: DiskSnapshot): KeyPolicy[];generateNodeId
Identifies one SDK process to the control plane's watermark table, so that
revoke?wait=true can answer "revoked on 4/4 servers".
Host and pid make it legible in that answer -- an operator looking at a stalled rollout wants to know which box has not caught up -- and the random suffix keeps two processes on one host, or two hosts that share a name in a container fleet, from colliding onto one watermark and each reporting the other's progress.
Shaped to match the id format the control plane's watermark table expects; anything the hostname contributes that does not fit is dropped rather than escaped.
export function generateNodeId(): string;toDiskKeys
export function toDiskKeys(policies: Iterable<KeyPolicy>): DiskSnapshot['keys'];toKeyPolicy
export function toKeyPolicy(wire: WirePolicyKey, workspaceId: string, projectId: string): KeyPolicy;Classes
DiskSnapshotStore
export class DiskSnapshotStore {
constructor(options: {
cacheDir?: string;
baseUrl: string;
projectId: string;
env: EnvFilter;
});
get path(): string;
read(): DiskSnapshot | null;
write(snapshot: DiskSnapshot): void;
async writeAsync(snapshot: DiskSnapshot): Promise<void>;
remove(): void;
}LruPolicyStore
A bounded, least-recently-used policy cache, built around two rules: cache
by tenant rather than by project (see applyUpsert), and bound the
resident set hard rather than let it grow unbounded.
A packed representation -- each record as a Uint8Array with an interned
scope table, estimated at 582 B -> ~140 B per key -- is deliberately not
implemented: that figure is an estimate rather than a measurement, and it
is worth doing only if the two rules above prove insufficient on their own.
The insertion order of a JS Map is the LRU list. A get that hits deletes
and re-inserts, which moves the entry to the end in O(1); eviction takes the
first key the iterator yields, which is the least recently used. No linked
list, no second index, nothing else per entry.
export class LruPolicyStore implements PolicyStore {
constructor(options: LruPolicyStoreOptions = {});
get(lookupHashKey: string): KeyPolicy | undefined;
getCached(lookupHashKey: string): CachedPolicy | undefined;
status(): PolicyStoreStatus;
stats(): PolicyStoreStats;
loadSnapshot(policies: Iterable<KeyPolicy>, meta: {
version: number;
fetchedAt: number;
source: PolicyStoreSource;
scopeComplete: boolean;
narrowed?: boolean;
}): void;
applyUpsert(policy: KeyPolicy, lookupHashKey: string): boolean;
applyTombstone(lookupHashKey: string): boolean;
markSynced(version: number, fetchedAt: number): void;
raiseMaxCachedKeys(next: number): boolean;
entries(): IterableIterator<KeyPolicy>;
get size(): number;
}PolicyAuthFailedError
Reported through onError when the policy poll is refused with 401 --
distinct from an ordinary failed poll, which already reaches onError as
the raw PolicyRequestError on every occurrence.
GitHub secret scanning revokes a leaked
krsk_live_ key at once, and that key is usually the vendor's own
KEYRING_SECRET_KEY -- so this is the shape a self-inflicted revoke takes
on every node in the fleet, all at once. A 401 is not "the control plane is
slow or unreachable", which is what stale-then-open is defensible against: it is "this credential no
longer authenticates", and that is not evidence that any other key is
fine to admit. So the node stops treating a miss as inconclusive while this
holds -- see authFailed on PolicyPoller and DecideOptions.authFailed
in @keyring-dev/sdk -- and keeps enforcing whatever it already knew: a hit
still verifies from the last snapshot, only a miss loses the benefit of the
doubt. Traffic is not refused wholesale, and this notice is what says why a
store that looks merely stale is instead denying every unknown key.
export class PolicyAuthFailedError extends Error {
override readonly name: "PolicyAuthFailedError";
readonly projectId: string;
readonly since: number;
readonly neverServed: boolean;
constructor(options: {
projectId: string;
since: number;
neverServed: boolean;
});
}PolicyBootRefusedError
Reported through onError when a cold node -- one that has never loaded
a snapshot, from disk or from the network -- gets a 400, 403 or 404 from
the policy poll, instead of an ordinary failed poll.
Pre-existing and easy to miss: a 400, 403 or 404 at boot is deterministic
-- assertEnvAllowed answers 403 for a krsk_test_ naming env: 'live',
a project outside the key's workspace answers 404, and a malformed request
answers 400 -- and none of those clears on its own the way an outage does,
so treating it as an ordinary failed poll left a cold node in "Never
loaded" admitting every well-formed key for as long as the condition
lasts, which for a misconfiguration is the life of the deploy.
The fix: any non-transient 4xx before the first successful poll enters the
same authFailed state a 401 does -- see authFailed on PolicyPoller --
deliberately scoped to the cold refusal only, not to a general non-401
policy. A warm node's
ordinary failed poll on one of these statuses is unchanged and does not
reach here: #refresh's catch only constructs this error while the store
has never loaded a snapshot, the same condition PolicyAuthFailedError's
own neverServed reads.
A 404 -- and, via #rollBackWidening, a 400 -- is not always the
misconfiguration above. An unowned tenant_id fails the whole snapshot
with a 404 (the control plane's own rule for a tenant widening it does
not recognise), and #rollBackWidening treats a 400 the same way; both
are the status a refused tenant widening produces, on the very poll this
class fires from. That cause is not a misconfiguration and does not need
one fixed: the rollback removes the offending id from the filter on this
same refusal, so the next poll re-seeds on a filter that works, with no
operator action. This class
cannot tell the two causes apart from the status code alone, so the
message names both rather than asserting the misconfiguration is the only
one -- measured: a cold node whose first widening names an unowned tenant
refuses all traffic for exactly one poll, then recovers on its own. A
statically configured tenantIds naming an unowned tenant is the
exception: there is no pending widening to roll back, so that node stays
refused for the life of the deploy -- a real fail-closed-outage trade for
that one case, deliberately left as-is rather than carved out of this
state.
export class PolicyBootRefusedError extends Error {
override readonly name: "PolicyBootRefusedError";
readonly projectId: string;
readonly since: number;
readonly status: 400 | 403 | 404;
constructor(options: {
projectId: string;
since: number;
status: 400 | 403 | 404;
});
}PolicyCacheAutoRaised
Reported through onError when the snapshot walk raised maxCachedKeys by
itself. It is a notice and not a failure -- the poll it came from succeeded
-- and it is on the error channel because that is the SDK's one operator
channel. An auto-raise that no one can see is the silent behaviour change
this whole decision exists to avoid; stats().policyCache.autoRaisedTo
carries the same fact for a scrape.
export class PolicyCacheAutoRaised extends Error {
override readonly name: "PolicyCacheAutoRaised";
readonly projectId: string;
readonly from: number;
readonly to: number;
constructor(options: {
projectId: string;
from: number;
to: number;
});
}PolicyClient
The only part of the SDK that touches the network, and it is deliberately not
reachable from verify(). Unkey's verifyKey is a network call to Unkey,
and no amount of caching in front of that changes the shape. Keyring ships
the policy here instead, on a timer, out of band.
export class PolicyClient {
constructor(options: PolicyClientOptions);
get tenantIds(): readonly string[];
addTenants(tenantIds: readonly string[]): readonly string[];
removeTenants(tenantIds: readonly string[]): void;
async snapshot(options: {
cursor?: string;
maxKeys: number;
}): Promise<WireSnapshot>;
async delta(since: number, options: {
maxKeys: number;
}): Promise<WireDeltaAnswer | null>;
}PolicyPoller
Keeps an LruPolicyStore fed from the versioned snapshot and the delta feed,
and keeps a copy on disk so a restart of the same process/filesystem is not
a cold start.
This is the wedge, in one class. Unkey's verifyKey is a
network call to Unkey, and their co-founder's summary of years of latency
work is "zero network requests are always faster than one network request."
The answer is not a better cache in front of that call -- it is to ship the
policy here, on this timer, so the call does not exist.
export class PolicyPoller {
readonly nodeId: string;
constructor(options: PolicyPollerOptions);
get store(): LruPolicyStore;
get consecutiveFailures(): number;
get authFailed(): boolean;
get authFailedSince(): number | null;
get authFailedRefusingAll(): boolean;
get tenantIdsRejected(): number;
get tenantIds(): readonly string[];
get tenantsConvicted(): number;
get suspectsTracked(): number;
get tenantsHeldOut(): number;
loadFromDisk(): DiskSnapshot | null;
async refreshOnce(): Promise<PollOutcome>;
start(): void;
stop(): void;
admitTenants(requested: readonly string[]): boolean;
get snapshotPages(): number;
get snapshotTruncated(): boolean;
get autoRaisedMaxCachedKeys(): number | null;
get maxAutoCachedKeys(): number;
get scopeTooLarge(): PolicyScopeTooLargeError | null;
get diskSnapshotScopeMismatch(): PolicySnapshotScopeMismatch | null;
async flushPersist(): Promise<void>;
}PolicyRequestError
export class PolicyRequestError extends Error {
readonly status: number;
constructor(status: number, message: string);
}PolicyScopeTooLargeError
The project has more live keys than this node will hold, and no tenantIds
narrows it.
Two different events, told apart by refusedToStart, because "refuse to
start" and "kill a process that is answering requests" are different actions
with different blast radii, and only the first was in scope for this
decision.
- Cold (
refusedToStart: true): the node has never served. Thrown out of the snapshot walk, so the store is never loaded and the poller stops;@keyring-dev/sdkrethrows it out of a microtask and the deploy dies. That is the deliberate outcome for this case. - Warm (
refusedToStart: false): the node is already serving. Recorded and reported on every poll, and nothing is stopped. Throwing here made one ordinaryPATCH /v1/projects/:id-- which raisespolicy_change_floor, so every node re-seeds on its next poll -- take a whole fleet down inside 31 ms, taking the customer's own API with it. Andstop()ping such a node is worse than not stopping it: it freezes the store, so no revocation ever reaches that node again.
The message names what an operator needs and a generic error would not: how
many keys were walked, what the ceiling is, what it costs to raise it, and --
tenantIds being the answer that reads like a fix and is not -- what
narrowing would cost, in the same sentence that offers it.
export class PolicyScopeTooLargeError extends Error {
override readonly name: "PolicyScopeTooLargeError";
readonly projectId: string;
readonly keysWalked: number;
readonly ceiling: number;
readonly refusedToStart: boolean;
constructor(options: {
projectId: string;
keysWalked: number;
ceiling: number;
refusedToStart: boolean;
});
}PolicySnapshotScopeMismatch
Reported through onError when a disk snapshot was refused because the
process that wrote it was running under a different scope than this one.
A notice, like PolicyCacheAutoRaised: the node is not broken, it is cold,
and the cold path is one it already has. But an operator staring at a node
that keeps cold-starting has no other way to see why -- the file is there,
it is valid, it is this project and this environment, and it is still not
being read. So the message names both scopes and the file.
export class PolicySnapshotScopeMismatch extends Error {
override readonly name: "PolicySnapshotScopeMismatch";
readonly path: string;
readonly writer: SnapshotScope | undefined;
readonly reader: SnapshotScope;
constructor(options: {
path: string;
writer: SnapshotScope | undefined;
reader: SnapshotScope;
});
}Interfaces
CachedPolicy
export interface CachedPolicy {
readonly policy: KeyPolicy;
/**
* Epoch ms. Derived rather than stored per record: every successful poll
* confirms the whole resident set, so a record's freshness is the later of
* when it was admitted and when the store last synced. Storing an absolute
* value per record and rewriting all of them on each poll would be O(n) every
* five seconds for no additional truth.
*/
readonly cachedUntil: number;
}DiskSnapshot
export interface DiskSnapshot {
readonly format: typeof SNAPSHOT_FORMAT;
readonly workspaceId: string;
readonly projectId: string;
readonly env: EnvFilter;
readonly version: number;
readonly fetchedAt: number;
readonly scopeComplete: boolean;
/**
* Absent in a file written by an SDK older than this field. That is an
* *unknown* scope, not a matching one: the file it comes from is exactly the
* file a mixed-version rollout leaves behind, which is the deployment this
* check exists for. A tenant-narrowed reader refuses it and comes up cold --
* one poll's worth of cost, against a poisoned store that never repairs
* itself. A project-scoped reader loads it but does **not** believe its
* `scopeComplete`, because such a file may have been written by a node that
* was `complete: true` while holding one tenant's keys.
*
* The field is additive rather than a `SNAPSHOT_FORMAT` bump, deliberately:
* a bump cold-starts every node in every fleet once, which is the cost this
* decision exists to avoid. The price of that is a one-directional
* mixed-version window -- an older SDK ignores this field and keeps
* poisoning itself until it is replaced -- and `packages/cache/README.md`
* says so out loud rather than leaving a customer to find it.
*/
readonly scope?: SnapshotScope;
readonly keys: ReadonlyArray<{
keyId: string;
tenantId: string;
env: Environment;
lookupHash: string;
displayPrefix: string;
scopes: string[];
expiresAt: number | null;
/** Absent in a file written by an SDK from before this field existed. */
rateLimits?: WireRateLimit[];
}>;
}LruPolicyStoreOptions
export interface LruPolicyStoreOptions {
/** `= 640 bytes x maxCachedKeys`, so 10,000 ~ 6.1 MiB. */
readonly maxCachedKeys?: number;
readonly cachedTtlMs?: number;
readonly now?: () => number;
}PolicyClientOptions
export interface PolicyClientOptions {
readonly baseUrl: string;
/** A `krsk_` vendor secret key. Its environment decides what the feed shows. */
readonly secretKey: string;
readonly projectId: string;
readonly env?: EnvFilter;
/** Narrows the snapshot to these tenants, done server-side. */
readonly tenantIds?: readonly string[];
readonly nodeId?: string;
readonly requestTimeoutMs?: number;
readonly fetch?: FetchLike;
}PolicyPollerOptions
export interface PolicyPollerOptions extends LruPolicyStoreOptions {
readonly baseUrl: string;
readonly secretKey: string;
readonly projectId: string;
readonly env?: EnvFilter;
readonly tenantIds?: readonly string[];
readonly store?: LruPolicyStore;
/**
* How far the snapshot walk may raise `maxCachedKeys` on its own.
* `DEFAULT_MAX_AUTO_CACHED_KEYS` unless set, and never below `maxCachedKeys`
* -- an operator who picked a larger bound already chose it.
*
* Setting it *equal* to `maxCachedKeys` turns the automatic step off, which
* leaves the two honest answers to a project that does not fit and not the
* silent third one: a node that has never served refuses to start, and one
* that is already serving alarms on every poll and keeps serving. Raising
* the bound is the fix either way; `tenantIds` lets the node run without
* closing the hole, because a narrowed store is never `complete` either.
*/
readonly maxAutoCachedKeys?: number;
readonly policyRefreshMs?: number;
readonly pollJitter?: number;
readonly requestTimeoutMs?: number;
readonly nodeId?: string;
/** A day-0 requirement. Set false to opt out entirely. */
readonly persist?: boolean;
readonly cacheDir?: string;
readonly fetch?: FetchLike;
readonly now?: () => number;
readonly onError?: (error: unknown) => void;
readonly onPoll?: (outcome: PollOutcome) => void;
}PolicyStoreStats
export interface PolicyStoreStats {
readonly size: number;
/** The bound in force now, which `raiseMaxCachedKeys` may have widened. */
readonly maxCachedKeys: number;
/** The bound this store was constructed with, whatever it is now. */
readonly configuredMaxCachedKeys: number;
readonly hits: number;
readonly misses: number;
/**
* The rule is "bound it hard and say so": an SDK that silently grows
* unbounded inside a customer's process is how you get uninstalled -- and
* one that silently *evicts* is how a fail-open default stops being safe,
* because the argument for it is that the cache is complete for the project.
*/
readonly evictions: number;
/** Evictions per second since the store was created. */
readonly evictionRate: number;
/** Upserts dropped because the key was not resident. See `applyUpsert`. */
readonly skippedUpserts: number;
}PollOutcome
export interface PollOutcome {
readonly kind: PollKind;
readonly version: number | null;
/** How many cached records the poll added, replaced or dropped. */
readonly applied: number;
readonly error?: unknown;
}WireDelta
export interface WireDelta {
readonly object: 'policy_delta';
readonly workspace_id: string;
readonly project_id: string;
readonly env: EnvFilter;
readonly since: number;
readonly version: number;
readonly project_deleted: boolean;
readonly generated_at: string;
readonly changes: WireChange[];
}WirePolicyKey
One key as GET /v1/policy/snapshot sends it.
export interface WirePolicyKey {
readonly key_id: string;
readonly tenant_id: string;
readonly env: Environment;
/** base64 SHA-256 of the whole key: the cache index, and not a secret. */
readonly lookup_hash: string;
readonly display_prefix: string;
readonly scopes: string[];
readonly expires_at: string | null;
/**
* The key's effective limits, resolved against the project default by the
* control plane. Absent on a snapshot from a control plane that predates
* this field, which reads as "no limits" -- the same answer that plane
* would have given.
*/
readonly rate_limits?: WireRateLimit[];
}WireSnapshot
export interface WireSnapshot {
readonly object: 'policy_snapshot';
readonly workspace_id: string;
readonly project_id: string;
readonly env: EnvFilter;
readonly version: number;
readonly project_deleted: boolean;
readonly generated_at: string;
readonly keys: WirePolicyKey[];
/**
* The token that fetches the next page of this snapshot, or `null`/absent for
* the last one.
*
* Optional on this side and not on the control plane's, because a snapshot
* also arrives from disk and from a control plane older than the paging
* protocol. Absent is read as "there is no next page", which is safe in both
* directions: an old control plane never truncates -- it refuses or it sends
* everything -- and a truncation is only ever something this client asked for
* by sending `max_keys`.
*/
readonly next_cursor?: string | null;
}Types
EnvFilter
null is "both environments", which is what a live credential gets.
export type EnvFilter = Environment | null;FetchLike
export type FetchLike = (
input: string,
init?: {
method?: string;
headers?: Record<string, string>;
signal?: AbortSignal;
},
) => Promise<{
status: number;
headers: { get(name: string): string | null };
text(): Promise<string>;
}>;PollKind
export type PollKind = 'snapshot' | 'delta' | 'unchanged' | 'error';SnapshotScope
The scope of the process that wrote the file: the whole project, or a specific tenant filter.
Nothing recorded it before, so PolicyPoller.loadFromDisk built half the
loaded state from the file (scopeComplete) and half from this node's
options (narrowed). Two differently-scoped processes sharing one
cacheDir -- several workers in one container is the ordinary shape of that
-- then reached complete: true together with narrowed: true, which no
network walk can produce: applyUpsert skips every non-resident delta
upsert while status() still says the store can prove a miss, so a key
minted for a tenant that node serves is answered 401 for the life of the
process. Recording the writer's scope is what lets the loader refuse.
The filter is normalised -- deduplicated and sorted -- so the comparison is
about the set and not about the order an integrator listed it in, and the
empty filter is spelled project rather than tenants: [] so there is
exactly one representation of "the whole project".
The rejected alternative is putting tenantIds into the filename
fingerprint, so differently-scoped processes never share a file at all. It
makes the mismatch invisible rather than diagnosable, it multiplies files in
a shared directory, and a node whose filter widens at runtime would write
under a name its own restart no longer looks for.
export type SnapshotScope =
| { readonly kind: 'project' }
| { readonly kind: 'tenants'; readonly tenantIds: readonly string[] };WireChange
export type WireChange =
| ({ readonly change: 'upsert' } & WirePolicyKey)
| {
readonly change: 'tombstone';
readonly key_id: string;
readonly env: Environment;
readonly lookup_hash: string;
};WireDeltaAnswer
A delta request can come back as a snapshot. That is not an error: it is the
control plane saying the caller's since is not one it can answer completely
(pruned, ahead of the current version, or so far behind that the delta would
cost more than a re-seed). A partial delta would be a silently wrong cache,
so the protocol has no way to express one.
export type WireDeltaAnswer = WireDelta | WireSnapshot;Constants
AUTH_FAILED_REPORT_INTERVAL_MS
How often the loud auth-failed notice repeats while the state continues, once it has already fired on entry.
Every failed poll already reaches onError with the raw PolicyRequestError
(unchanged); this is a second, distinct notice on top of that, so it must
not repeat on the same 5 s cadence or a sustained 401 turns into a
duplicate line every poll. It is not the recovery bound -- there is none, by
design: only a poll that authenticates clears the state.
const AUTH_FAILED_REPORT_INTERVAL_MS: 60000;DEFAULT_CACHE_DIR
os.tmpdir() is the default and it is world-writable on every Unix.
That means the directory is the exposure, not the file: another local user
can pre-create /tmp/keyring and own it, and then every 0600 file we write
lands somewhere they control.
So the directory is created 0700 and, if it already exists, checked before anything is written to it: a symlink, a non-directory, or permissions that let group or other write to it are all refused. Refusing means the SDK runs without a disk cache and says so -- degraded, and loudly, rather than silently writing the customer's policy where someone else can read or replace it.
A customer sharing a host with untrusted local users should set cacheDir to
a private path they own. The README says so.
const DEFAULT_CACHE_DIR: string;DEFAULT_CACHED_TTL_MS
Belt-and-braces bound: every cached record also carries
cachedUntil = now + 60 s. It is not the invalidation mechanism -- the delta
feed is, at 5 s -- it is the bound on how long a node that has stopped being
able to reach us keeps serving before the configured fail-open/fail-closed
policy takes over.
const DEFAULT_CACHED_TTL_MS: 60000;DEFAULT_MAX_AUTO_CACHED_KEYS
How far one node will raise its own maxCachedKeys to finish a snapshot
walk, before it refuses to start (cold) or alarms and keeps serving (warm).
For a project too large to cache, the policy is: raise the bound
automatically to here, and only then insist the operator raise it further
and pay for it. What was rejected matters as much as what was chosen.
Leaving the walk silently truncated makes the largest customers carry a
permanent complete: false, which under stale-then-open admits any
well-formed key, unverified, with no tenant, no scopes and no scope check,
for the life of the process; the key's checksum is CRC-32, so forging a
well-formed key costs an attacker nothing. Defaulting a truncated store to
stale-then-closed instead would close that hole by rejecting
legitimate keys for ever: a key outside the cached slice can never
arrive, because there is no per-key remote lookup and the async refresh
re-walks into the same wall.
The bound here is a memory cost, not a key count, and this number is
the key count that delivers it. "50,000 keys at 31.2 MiB" was the first
estimate, built from wire records as JS object literals: all 50,000 of
those keys shared one 'read:orders' string object and one set of
rate_limits ids, and JSON.parse -- the path PolicyClient actually
takes -- allocates a string per occurrence rather than sharing one.
Re-measured on the wire path, a key costs 870 B at three scopes and
the two-rule set the control plane's rate-limit configuration allows, so
50,000 of them is 41.5 MiB and not 31.2. 38,000 x 870 B is 31.5 MiB,
which is the number chosen.
What that buys and what it does not, measured on the same run:
| shape | B/key | steady at 38,000 |
|---|---|---|
| 1 scope, no limits | 534 | 19.4 MiB |
| 3 scopes, 2 rules | 870 | 31.5 MiB |
| 8 scopes, 5 rules | 1,286 | 46.6 MiB |
A re-seed costs another ~1.4x of that while the page in flight is still
uncollected, and one PATCH /v1/projects/:id makes a whole fleet re-seed at
once -- so eight Node workers on the 2 GB container DEFAULT_MAX_CACHED_KEYS
is written against spend 248 MiB steady at this ceiling and ~340 MiB through
a simultaneous re-seed. At 50,000 the same fleet spends 322 MiB and ~450 MiB.
Bounding on measured bytes rather than on a key count would be the better
invariant and is deliberately not done here: it would change the configuration
surface, which the bound above leaves alone.
const DEFAULT_MAX_AUTO_CACHED_KEYS: 38000;DEFAULT_MAX_CACHED_KEYS
The default the memory formula is written against. The measurement is what matters, not the key count on its own: 582-679 bytes per cached key in the V8 heap means 1 M keys is 555 MiB inside someone else's process, and a customer running eight Node workers on a 2 GB container cannot afford that.
const DEFAULT_MAX_CACHED_KEYS: 10000;DEFAULT_POLICY_REFRESH_MS
5 s: fast enough to meet the "<= 5 seconds (p100)" propagation commitment quoted to customers, without paying for it in feed load -- polling every 1 s instead would be five times the requests against the same feed, spread across every node in a fleet.
const DEFAULT_POLICY_REFRESH_MS: 5000;DEFAULT_POLL_JITTER
10,000 nodes on an unjittered 5 s poll is 10,000 requests arriving in the same millisecond every five seconds, because they all started when the same deploy rolled out. The feed is sized for 2,000 req/s spread evenly; in lockstep it is a 10,000-request spike against our own delta feed twelve times a minute.
The jitter is subtracted, never added: the delay is drawn from
[interval * (1 - jitter), interval]. Adding it would spread the herd just as
well and would push worst-case propagation past the interval, and "<= 5
seconds (p100)" is a number we quote to customers.
const DEFAULT_POLL_JITTER: 0.2;MAX_SNAPSHOT_PAGES
How many pages of one snapshot this node will walk before it stops and says the store is incomplete.
The real bound is maxCachedKeys: everything past it would be evicted on
load anyway, so paging beyond it is bandwidth spent to throw the result away.
This is the second bound, and it is about a control plane that misbehaves
rather than one that is large -- a page that answers with a cursor and no
keys would otherwise be an infinite loop inside the customer's process, which
is not a failure mode an SDK gets to have.
const MAX_SNAPSHOT_PAGES: 512;SNAPSHOT_FORMAT
The cold start is the genuinely dangerous case: a restarted process with no
cache to be stale from. Without a snapshot on
disk, a customer restarting the same process/filesystem during a Keyring
outage goes down -- the worst possible first impression -- so this is a
day-0 requirement rather than a nice-to-have. It does not cover a fresh
container in an immutable-container rollout, which has no file to hydrate
regardless; see packages/cache/README.md's "Immutable containers and
rollouts".
What lands on disk, and why that is acceptable:
lookup_hashis the unpeppered SHA-256 of the key. That is not a concession: it has to be computable inside the customer's process, which is what makes verification local, and the authoritative peppered hash never leaves the control plane. A key carries 192 bits of entropy, so a hash is not invertible and there is no dictionary to attack -- the same argument that letsapi_key.lookup_hashbe unpeppered in our own database. (The pepper cannot substitute for that entropy -- it adds server-side defense in depth, it does not extend the key's 192 bits.)- key ids, tenant ids, display prefixes, scopes and expiries. Metadata, and already in the customer's own heap and their own request logs.
- never a plaintext key, never the pepper, never the peppered hash. None of the three exists in this process at all.
So the file adds no class of secret the process was not already holding; what it adds is a persistence window, and that is what the permissions below are for.
const SNAPSHOT_FORMAT: "keyring.policy-snapshot.v1";