Files
puter/src/backend/services/events/EventsService.ts
T
Daniel Salazar d8ebfc8023 feat: delivery re-check cache, revocation and anchor settle (PUT-1677) (#3681)
* feat: delivery re-check cache, revocation and anchor settle (PUT-1677)

* fix: authorize re-anchors, settle each row once, purge revoked backlog (PUT-1677)

- A path-form row whose anchor is deleted only climbs to an ancestor its
  holder may still watch under the mode it subscribed with; otherwise it ends
  with `anchor_deleted`. It used to land on any surviving ancestor (a guest's
  row on the owner's home), where the re-check denied every delivery but the
  row still held an anchor slot and a filter evaluation there.
- After a climb the new anchor is re-verified and the climb repeated if a
  recursive delete took that level too, instead of leaving the row on a dead uid.
- suspend() is one conditional write per row and reports which rows it was the
  one to suspend; concurrent settles of the same grant (an unshare revokes
  several strings) no longer each purge, forget and notify the same rows.
- One "subscriptions ended" notification per holder and app, carrying the count
  and subjects, instead of one per row.
- A revoke that removed nothing no longer announces; the sweeper purges (not
  defers) the backlog of a permission_revoked row; the reap purges pending
  entries with the row.
- The delivery auth cache indexes entries by subscription so forget() is not a
  scan of the whole cache.
2026-09-02 16:00:14 -07:00

2080 lines
76 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { randomUUID } from 'node:crypto';
import type { EventKey } from '../../clients/event/types.js';
import {
EVENTS_ACK_LIMIT,
EVENTS_BROADCAST_DELIVERY_LIMIT,
EVENTS_COALESCE_WINDOW_MS,
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SUBSCRIBE_LIMIT,
SUSPENDED_ROW_TTL_DAYS,
} from '../../controllers/events/limits.js';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import type { ReanchorInput } from '../../stores/events/DurableSubscriptionStore.js';
import {
SESSION_SUBSCRIPTION_TTL_SECONDS,
type DispatchSubscription,
type DurableSubscription,
type GenerationBump,
type SessionSubscription,
} from '../../stores/events/EventSubscriptionStore.js';
import type {
ClaimedDelivery,
PendingShed,
} from '../../stores/events/PendingDeliveryStore.js';
import {
DEFAULT_DURABLE_TARGETS,
SESSION_TARGETS,
isSubscriptionTarget,
targetsAllowedForDelivery,
type SubscriptionTarget,
} from '../../stores/events/types.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { PageResult } from '../../util/pagination.js';
import type { AclMode, ResourceDescriptor } from '../acl/ACLService.js';
import { resolveNode } from '../fs/resolveNode.js';
import {
appSocketRoom,
type SocketSpecifier,
} from '../socket/SocketService.js';
import { PuterService } from '../types.js';
import { resolveFsAnchor, type FsAnchorDeps } from './anchors.js';
import {
assertSubscribeAuthorized,
checkDeliveryAuthorized,
deliveryGenerationTag,
nodeDescriptor,
resolveGrantActor,
rowInActorScope,
type EventAclDeps,
} from './authorization.js';
import { DeliveryCoalescer } from './coalescer.js';
import { DeliveryAuthCache } from './deliveryAuthCache.js';
import {
FILTER_EVALUATIONS_PER_EVENT,
compileMatch,
evaluateWithCap,
relativeTo,
type CompiledMatch,
} from './matcher.js';
import {
lookupPublicSubject,
type DeliverableEvent,
type DeliveryClass,
type EventContext,
type GapMarker,
type GapReason,
type ProjectedEvent,
type PublicSubject,
} from './registry.js';
import { SubscriptionCache } from './subscriptionCache.js';
import { fsAnchorToken, parseSubject, type FsOp } from './subjects.js';
import {
RecordingWorkerInvoker,
type WorkerInvocation,
type WorkerInvokerSeam,
} from './workerSeam.js';
/**
* Subscribe, unsubscribe, and the dispatch hot path.
*
* The write path is the constraint everything here is shaped by. Almost every
* write in the product belongs to a user with no subscriptions at all, and that
* write must not pay for the ones that do — so dispatch is three gates in
* increasing order of cost:
*
* 1. The feature switch: a boolean.
* 2. "Does this user have anything at all": in-process, invalidated by a broadcast
* rather than a timer, so it costs nothing once warm.
* 3. One Redis command against the user's watched tokens.
*
* Only past all three does anything read a subscription or walk the tree, and
* only a row that survived op and filter matching pays for the ACL re-check
* that decides whether its holder may still be told. Everything is keyed by the
* owner of the resource, because that is all a write knows about itself.
*
* Nothing here reaches the caller: an event that could not be dispatched is an
* event nobody hears, and the write that produced it still succeeded.
*/
// -- Wire shapes ------------------------------------------------------
export interface SubscribeRequest {
subject?: unknown;
targets?: unknown;
}
export interface UnsubscribeRequest {
subId?: unknown;
}
/** Body of the `events.ack` verb: which subscription, and which delivery. */
export interface AckRequest {
subId?: unknown;
id?: unknown;
}
/** Body of `POST /events/subscribe`. */
export interface DurableSubscribeRequest extends SubscribeRequest {
delivery?: unknown;
targets?: unknown;
handlerName?: unknown;
context?: unknown;
expiresAt?: unknown;
}
export interface DurableListRequest {
limit?: number;
cursor?: string;
includeTotal?: boolean;
}
/** What a client gets back for a subscription it just made. */
export interface SubscriptionView {
subId: string;
subject: string;
anchor: { uid: string; path: string };
match: string | null;
op: FsOp | null;
targets: SubscriptionTarget[];
}
/**
* A durable row as its holder sees it. `context` is deliberately absent: it is
* read on the delivery path and nowhere else, and a listing is the one surface
* an app can call repeatedly.
*/
export interface DurableSubscriptionView extends SubscriptionView {
delivery: DeliveryClass;
handlerName: string | null;
appUid: string | null;
createdAt: number;
expiresAt: number | null;
suspendedAt: number | null;
suspendedReason: string | null;
}
export type VerbAck<T extends object> =
| ({ ok: true } & T)
| { ok: false; error: { code: string; message: string } };
export type { GapMarker, GapReason } from './registry.js';
/** One delivery, as the client receives it. */
export interface DeliveryEnvelope {
subId: string;
event: DeliverableEvent;
/** Set on a `single`: nothing else takes this until `events.ack` settles it. */
ackRequired?: true;
/** The handle `events.ack` names — the delivery, not the event. */
ackId?: string;
}
/**
* The envelope plus where it goes. The address is not part of the wire, and
* neither is the handler a durable row may want run alongside the socket copy.
*/
interface AddressedDelivery {
target: SocketSpecifier;
envelope: DeliveryEnvelope;
/** False for a row that asked for its handler and no socket copy. */
socket: boolean;
worker?: WorkerInvocation;
}
/**
* Why a subscription ended without its holder unsubscribing. Both are terminal:
* the grant is gone, or the node is, and neither comes back by itself.
*/
export type SubscriptionEndReason = 'permission_revoked' | 'anchor_deleted';
/** Who a stored row acts as, and what its cached decisions hang on. */
interface GrantIdentity {
actor: Actor;
generation: string;
}
/** A withdrawn grant, as the settle pass reads it off the bus. */
export interface RevokedGrant {
holderUserId: number;
/** The app the grant was to, or `null` for a user-to-user grant. */
appUid: string | null;
/** The grant string, or `null` when every grant to the app went. */
permission: string | null;
}
/** What a dispatch call site can supply that the event itself does not carry. */
export interface FsDispatchOptions {
/**
* Resolved lazily and only past the second gate — walking the tree for a
* user nobody subscribed on behalf of is the cost this exists to avoid.
* Paths as well as uids: the same walk answers the token lookup and the
* per-delivery ACL re-check.
*/
ancestors?: () => Promise<ReadonlyArray<{ uid: string; path: string }>>;
/** Who performed the write, for the `self` flag. */
actingUserId?: number;
}
// -- Socket wire names ------------------------------------------------
export const EVENTS_SUBSCRIBE_VERB = 'events.subscribe';
export const EVENTS_UNSUBSCRIBE_VERB = 'events.unsubscribe';
export const EVENTS_ACK_VERB = 'events.ack';
export const EVENTS_DELIVERY_CHANNEL = 'events.delivery';
// -- Expiry sweep -----------------------------------------------------
/** An expiry is a date, not a deadline, so hourly is close enough. */
const EXPIRY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
// The first pass lands within this long of boot, so a fleet that redeploys more
// often than the interval still sweeps.
const EXPIRY_SWEEP_INITIAL_DELAY_MS = 5 * 60 * 1000;
/** Rows one delete takes. Small enough not to hold a lock anyone waits on. */
const EXPIRY_BATCH_SIZE = 500;
/** Batches one sweep takes, so a large backlog drains over several passes. */
const EXPIRY_MAX_BATCHES = 50;
/**
* Subjects one "subscriptions ended" notification names before it stops
* listing.
*/
const ENDED_SUBJECTS_LISTED = 20;
// -- Owed deliveries --------------------------------------------------
/**
* Socket attempts one `single` delivery gets before its handler takes it. Two,
* because the second is what a client that reconnected — or a second connection
* of the same account — is worth trying; a third is just a slower hand-off.
*/
const SINGLE_SOCKET_ATTEMPTS = 2;
/** How often expired leases are reclaimed and owed deliveries retried. */
const PENDING_SWEEP_INTERVAL_MS = 10_000;
/** Subscriptions one sweep pass looks at, taken from the oldest end. */
const PENDING_SWEEP_SUBSCRIPTIONS = 100;
/**
* Deliveries one subscription may be handed in a row. A consumer that settles
* inline would otherwise drain a whole backlog inside one ack.
*/
const PENDING_DRAIN_BATCH = 25;
/** The part of a socket this service uses, so tests need not build one. */
export interface EventSocket {
id: string;
on(event: string, listener: (...args: never[]) => void): unknown;
once(event: string, listener: (...args: never[]) => void): unknown;
}
// -- Errors -----------------------------------------------------------
const disabled = (): HttpError =>
new HttpError(503, 'Events are not enabled on this server', {
legacyCode: 'events_disabled',
});
const unknownSubscription = (): HttpError =>
new HttpError(404, 'No such subscription', {
legacyCode: 'subscription_does_not_exist',
});
const tooManyCalls = (): HttpError =>
new HttpError(429, 'Too many subscription changes', {
legacyCode: 'too_many_requests',
});
const handlerRequired = (): HttpError =>
new HttpError(400, 'A `single` subscription needs a handlerName', {
legacyCode: 'events_handler_required',
});
const badRequest = (message: string, code: string): HttpError =>
new HttpError(400, message, { legacyCode: code });
const errorAck = (err: unknown): VerbAck<never> => {
if (err instanceof HttpError)
return {
ok: false,
error: {
code: String(err.legacyCode ?? err.code ?? 'events_failed'),
message: err.message,
},
};
return {
ok: false,
error: { code: 'events_failed', message: 'Subscription failed' },
};
};
const toView = (sub: DispatchSubscription): SubscriptionView => ({
subId: sub.subId,
subject: sub.subject,
anchor: { uid: sub.anchorUid, path: sub.anchorPath },
match: sub.match,
op: sub.op,
targets: sub.targets ?? SESSION_TARGETS,
});
const toDurableView = (sub: DurableSubscription): DurableSubscriptionView => ({
...toView(sub),
delivery: sub.delivery,
handlerName: sub.handlerName,
appUid: sub.appUid,
createdAt: sub.createdAt,
expiresAt: sub.expiresAt,
suspendedAt: sub.suspendedAt,
suspendedReason: sub.suspendedReason,
});
/** Coalescing is per (subscription, subject), which is what the key says. */
const coalesceKey = (subId: string, subject: string): string =>
`${subId}|${subject}`;
/**
* Where one row's deliveries go. A session row is addressed at the connection
* that made it. A durable row has no connection to name, so it is addressed at
* a room: the app's own room for a row an app created, and the holder's user
* room for one their session created — which is every desktop tab they have
* open, and the only handle that reaches an account rather than a connection.
*/
const deliveryTarget = (row: DispatchSubscription): SocketSpecifier => {
if (row.socketId !== undefined) return { socket: row.socketId };
return {
room: row.appUid
? appSocketRoom(row.holderUserId, row.appUid)
: String(row.holderUserId),
};
};
/** Transports a row is asking for, whichever store it came from. */
const targetsOf = (row: DispatchSubscription): SubscriptionTarget[] =>
row.durable === true
? (row.targets ?? DEFAULT_DURABLE_TARGETS)
: SESSION_TARGETS;
/**
* Whether this pass has anywhere to put the row. A `single` is queued whether
* or not anything is listening right now — that is what it is for — while a
* `broadcast` whose only transport is one this build cannot carry has no
* target, and an event with no target is dropped rather than held.
*/
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
/** Past `expiresAt` a durable row is finished, sweep or no sweep. */
const unexpired = (row: DispatchSubscription): boolean => {
if (row.durable !== true) return true;
const { expiresAt } = row as DurableSubscription;
return expiresAt === null || expiresAt > nowSeconds();
};
const deliverable = (row: DispatchSubscription): boolean => {
if (row.durable !== true) return true;
if (!unexpired(row)) return false;
if (row.delivery === 'single') return true;
const targets = targetsOf(row);
return targets.includes('socket') || targets.includes('worker');
};
const isSingle = (row: DispatchSubscription): boolean =>
row.durable === true && row.delivery === 'single';
// -- Durable request parsing ------------------------------------------
/** Longest a `handlerName` may be, matching the column that holds it. */
const HANDLER_NAME_MAX_LENGTH = 128;
const parseDelivery = (value: unknown): DeliveryClass => {
if (value === undefined || value === null || value === 'broadcast')
return 'broadcast';
if (value === 'single') return 'single';
throw badRequest(`Unknown delivery class: ${String(value)}`, 'bad_request');
};
const parseTargets = (
value: unknown,
delivery: DeliveryClass,
): SubscriptionTarget[] => {
if (value === undefined || value === null) return DEFAULT_DURABLE_TARGETS;
if (!Array.isArray(value) || value.length === 0)
throw badRequest(
'targets must be a non-empty array',
'invalid_targets',
);
if (!value.every(isSubscriptionTarget))
throw badRequest('Unknown delivery target', 'invalid_targets');
const targets = [...new Set(value)];
if (!targetsAllowedForDelivery(delivery, targets))
throw badRequest(
'A `single` subscription needs a `worker` target and may not target `push`',
'invalid_targets',
);
return targets;
};
/**
* A session row is one connection, so the socket is the only transport it can
* have: a handler runs long after the connection is gone, and a device
* notification is not addressed to a connection at all.
*/
const parseSessionTargets = (value: unknown): SubscriptionTarget[] => {
if (value === undefined || value === null) return SESSION_TARGETS;
if (!Array.isArray(value) || value.length === 0)
throw badRequest(
'targets must be a non-empty array',
'invalid_targets',
);
if (!value.every((target) => target === 'socket'))
throw badRequest(
'A session subscription may only target `socket`',
'invalid_targets',
);
return SESSION_TARGETS;
};
const parseHandlerName = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
if (typeof value !== 'string' || value.length === 0)
throw badRequest('handlerName must be a string', 'bad_request');
if (value.length > HANDLER_NAME_MAX_LENGTH)
throw badRequest(
`handlerName may not exceed ${HANDLER_NAME_MAX_LENGTH} characters`,
'bad_request',
);
return value;
};
/** Stored as JSON text; the byte cap is the store's to enforce. */
const parseContext = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
try {
return JSON.stringify(value);
} catch {
throw badRequest('context must be JSON-serializable', 'bad_request');
}
};
/** Unix seconds or an ISO-8601 string, and it has to be in the future. */
const parseExpiresAt = (value: unknown): number | null => {
if (value === undefined || value === null) return null;
const seconds =
typeof value === 'number'
? Math.floor(value)
: Math.floor(Date.parse(String(value)) / 1000);
if (!Number.isFinite(seconds) || seconds <= Math.floor(Date.now() / 1000))
throw badRequest(
'expiresAt must be a future time',
'invalid_expires_at',
);
return seconds;
};
export class EventsService extends PuterService {
readonly #cache = new SubscriptionCache();
readonly #deliveryAuth = new DeliveryAuthCache();
readonly #compiled = new Map<string, CompiledMatch>();
readonly #lookups = new Map<string, Promise<boolean>>();
readonly #refreshTimers = new Map<string, ReturnType<typeof setInterval>>();
#coalescer: DeliveryCoalescer<AddressedDelivery> | null = null;
#expirySweep: ReturnType<typeof setInterval> | null = null;
#expiryKick: ReturnType<typeof setTimeout> | null = null;
#pendingSweep: ReturnType<typeof setInterval> | null = null;
/**
* What runs an app's handler. The default records the intent and settles
* nothing, so a delivery handed to it stays owed until there is a real
* invoker to take it.
*/
worker: WorkerInvokerSeam = new RecordingWorkerInvoker();
// -- Lifecycle ---------------------------------------------------
override onServerStart(): void {
this.clients.event.on(
'outer.events.generationBumped',
(_key, data, meta) => {
// Our own emit reaches local listeners too, and that half has
// already been applied.
if (!(meta as { from_outside?: boolean })?.from_outside) return;
const { userId, durable } = (data ?? {}) as {
userId?: number;
durable?: boolean;
};
if (typeof userId !== 'number') return;
this.invalidateUser(userId, { rebuild: durable === true });
},
);
// The delivery re-check would already deny these, but a row nobody can
// deliver to still holds an anchor slot, still bills, and still costs a
// filter evaluation on every event under it.
this.clients.event.on('permission.revoked', (_key, data) => {
void this.settleRevokedGrant(data as RevokedGrant).catch((err) => {
console.warn('[events] revocation settle failed', err);
});
});
this.#armExpirySweep();
this.#armPendingSweep();
}
override onServerPrepareShutdown(): void {
if (this.#expiryKick) clearTimeout(this.#expiryKick);
this.#expiryKick = null;
if (this.#expirySweep) clearInterval(this.#expirySweep);
this.#expirySweep = null;
if (this.#pendingSweep) clearInterval(this.#pendingSweep);
this.#pendingSweep = null;
}
override onServerShutdown(): void {
for (const timer of this.#refreshTimers.values()) clearInterval(timer);
this.#refreshTimers.clear();
}
/** The master switch. Read on every write, so it stays a field lookup. */
get enabled(): boolean {
return this.config.events?.enabled === true;
}
// -- Socket surface ----------------------------------------------
/**
* Install the subscription verbs on one connection. Session subscriptions
* are per-socket by construction: they are addressed to this socket's id,
* and they leave when it does.
*/
attachSocket(socket: EventSocket, actor: Actor): void {
const userId = actor.user?.id;
if (userId === undefined) return;
socket.on(EVENTS_SUBSCRIBE_VERB, ((
payload: SubscribeRequest,
ack: unknown,
) => {
void this.#answer(ack, () =>
this.subscribe(actor, socket.id, payload),
);
}) as (...args: never[]) => void);
socket.on(EVENTS_UNSUBSCRIBE_VERB, ((
payload: UnsubscribeRequest,
ack: unknown,
) => {
void this.#answer(ack, async () => {
await this.unsubscribe(actor, socket.id, payload);
return {};
});
}) as (...args: never[]) => void);
socket.on(EVENTS_ACK_VERB, ((payload: AckRequest, ack: unknown) => {
void this.#answer(ack, async () => {
await this.ackDelivery(actor, payload);
return {};
});
}) as (...args: never[]) => void);
socket.once('disconnect', (() => {
void this.reapSocket(userId, socket.id);
}) as (...args: never[]) => void);
}
async #answer<T extends object>(
ack: unknown,
run: () => Promise<T>,
): Promise<void> {
const respond =
typeof ack === 'function'
? (ack as (response: unknown) => void)
: undefined;
try {
const result = await run();
respond?.({ ok: true, ...result });
} catch (err) {
if (!respond) {
console.warn('[events] subscription verb failed', err);
return;
}
respond(errorAck(err));
}
}
// -- Subscribe / unsubscribe -------------------------------------
async subscribe(
actor: Actor,
socketId: string,
request: SubscribeRequest,
): Promise<{ sub: SubscriptionView }> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
await this.#spendCallBudget(holderUserId);
const targets = parseSessionTargets(request?.targets);
const rawSubject = String(request?.subject ?? '');
const anchor = await this.#resolveSubscribeAnchor(actor, rawSubject);
const sub: SessionSubscription = {
subId: randomUUID(),
socketId,
holderUserId,
ownerUserId: anchor.ownerUserId,
subject: rawSubject,
token: anchor.token,
anchorUid: anchor.uid,
anchorPath: anchor.path,
match: anchor.match,
op: anchor.op,
appUid: actor.effectiveApp?.uid ?? null,
permission: anchor.permission,
targets,
};
const bump = await this.stores.eventSubscription.add(sub);
this.#publishGeneration(bump, false);
this.#startRefresh(holderUserId, socketId);
return { sub: toView(sub) };
}
async unsubscribe(
actor: Actor,
socketId: string,
request: UnsubscribeRequest,
): Promise<void> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
await this.#spendCallBudget(holderUserId);
const subId = String(request?.subId ?? '');
if (!subId) throw unknownSubscription();
const sub = await this.stores.eventSubscription.getForSocket(
holderUserId,
socketId,
subId,
);
// An id this socket never held — or one another app created — reads as
// absent rather than refused: a 403 here is an oracle for subIds.
if (!sub || !rowInActorScope(actor, sub)) throw unknownSubscription();
const bump = await this.stores.eventSubscription.remove(sub);
this.#forget(subId);
this.#publishGeneration(bump, false);
}
/** What this actor holds on one connection, scoped to what it may see. */
async listSubscriptions(
actor: Actor,
socketId: string,
): Promise<SubscriptionView[]> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
const held = await this.stores.eventSubscription.listForSocket(
holderUserId,
socketId,
);
return held.filter((sub) => rowInActorScope(actor, sub)).map(toView);
}
// -- Durable subscriptions ---------------------------------------
/**
* Register a subscription that outlives the connection that made it. Same
* subject resolution and same ACL check as the session verb — what differs
* is where the row lands and who it is later addressed as.
*/
async subscribeDurable(
actor: Actor,
request: DurableSubscribeRequest,
): Promise<{ sub: DurableSubscriptionView }> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
await this.#spendCallBudget(holderUserId);
const delivery = parseDelivery(request?.delivery);
const targets = parseTargets(request?.targets, delivery);
const handlerName = parseHandlerName(request?.handlerName);
const context = parseContext(request?.context);
const expiresAt = parseExpiresAt(request?.expiresAt);
// A `single` is owed to exactly one consumer, and the handler is the
// only one that is always there to take it. Whether the handler exists
// is the publish surface's question, not this one's.
if (delivery === 'single' && !handlerName) throw handlerRequired();
const rawSubject = String(request?.subject ?? '');
const anchor = await this.#resolveSubscribeAnchor(actor, rawSubject);
const { row, bump } = await this.stores.durableSubscription.create({
holderUserId,
ownerUserId: anchor.ownerUserId,
appUid: actor.effectiveApp?.uid ?? null,
subject: rawSubject,
token: anchor.token,
anchorUid: anchor.uid,
anchorPath: anchor.path,
match: anchor.match,
op: anchor.op,
delivery,
targets,
handlerName,
context,
permission: anchor.permission,
expiresAt,
});
this.#publishGeneration(bump, true);
return { sub: toDurableView(row) };
}
/**
* What this actor holds durably. An app-context actor is confined to its
* own rows by the index the query runs on; an account-context one sees
* across apps, which is what makes the account the revoke surface for a row
* whose app is long gone.
*/
async listDurable(
actor: Actor,
request: DurableListRequest = {},
): Promise<PageResult<DurableSubscriptionView>> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
const app = actor.effectiveApp;
// Unresolved is not "no app" — reading it that way is what would hand
// an app the account-wide view.
if (app === undefined) return { items: [] };
const page = await this.stores.durableSubscription.listForHolder(
holderUserId,
{
appUid: app?.uid ?? null,
limit: request.limit,
cursor: request.cursor,
includeTotal: request.includeTotal,
},
);
return {
items: page.items.map(toDurableView),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
};
}
async unsubscribeDurable(
actor: Actor,
request: UnsubscribeRequest,
): Promise<void> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
await this.#spendCallBudget(holderUserId);
const subId = String(request?.subId ?? '');
if (!subId) throw unknownSubscription();
const row = await this.stores.durableSubscription.getBySubId(subId);
// Someone else's id — or one another app created — reads as absent
// rather than refused: a 403 here is an oracle for subIds.
if (
!row ||
row.holderUserId !== holderUserId ||
!rowInActorScope(actor, row)
)
throw unknownSubscription();
const bump = await this.stores.durableSubscription.remove(row);
// Whatever it was still owed goes with it: a backlog held for a
// subscription nobody can consume is memory, and the paths it names are
// ones its holder just gave up asking about.
await this.stores.pendingDelivery.purge(subId);
this.#forget(subId);
this.#publishGeneration(bump, true);
}
/**
* Settle a `single` delivery the client took. An id this subscription is
* not holding — one already settled, or one reclaimed and handed on — is
* not an error: at-least-once makes a duplicate ack routine.
*/
async ackDelivery(actor: Actor, request: AckRequest): Promise<void> {
if (!this.enabled) throw disabled();
const holderUserId = actor.user?.id;
if (holderUserId === undefined) throw disabled();
const subId = String(request?.subId ?? '');
const entryId = String(request?.id ?? '');
if (!subId || !entryId) throw unknownSubscription();
const ok = await checkRateLimit(
`${EVENTS_ACK_LIMIT.scope}:${holderUserId}`,
EVENTS_ACK_LIMIT.limit,
EVENTS_ACK_LIMIT.window,
);
if (!ok) throw tooManyCalls();
const row = await this.stores.durableSubscription.getBySubId(subId);
if (
!row ||
row.holderUserId !== holderUserId ||
!rowInActorScope(actor, row)
)
throw unknownSubscription();
await this.stores.pendingDelivery.settle(subId, entryId);
await this.#drain(row);
}
/**
* Drop rows past their expiry, in batches, and report how many went. Every
* node sweeps; the delete is idempotent, so two overlapping costs a few
* empty batches rather than correctness.
*/
async sweepExpired(): Promise<number> {
if (!this.enabled) return 0;
return this.#sweepInBatches((batchSize) =>
this.stores.durableSubscription.sweepExpired(batchSize),
);
}
/**
* Drop rows that have been suspended longer than they are worth keeping. A
* revoked subscription never resumes, so the row survives only as the
* answer its holder gets from `list` when they ask why it stopped.
*/
async sweepSuspended(): Promise<number> {
if (!this.enabled) return 0;
const cutoff =
Math.floor(Date.now() / 1000) -
SUSPENDED_ROW_TTL_DAYS * 24 * 60 * 60;
return this.#sweepInBatches((batchSize) =>
this.stores.durableSubscription.sweepSuspended(cutoff, batchSize),
);
}
async #sweepInBatches(
pass: (batchSize: number) => Promise<number>,
): Promise<number> {
let removed = 0;
for (let i = 0; i < EXPIRY_MAX_BATCHES; i++) {
const batch = await pass(EXPIRY_BATCH_SIZE);
removed += batch;
if (batch < EXPIRY_BATCH_SIZE) break;
}
return removed;
}
/**
* Retry what nobody took. Reads the pending index from its oldest end — the
* subscriptions that have waited longest — so finding the work costs one
* ordered read rather than a walk of the keyspace, which is exactly the
* thing that gets expensive when there is a backlog to find.
*
* A lease that lapsed is what makes a delivery claimable again, so this
* needs no notion of failure: it retries whatever is not currently held.
*/
async sweepPending(): Promise<number> {
if (!this.enabled) return 0;
// A partial write can move a pending set without its share of the
// region counter going with it, in either direction. This is what
// keeps that drift from being permanent.
await this.stores.pendingDelivery
.reconcileRegionDepth()
.catch((err) => {
console.warn('[events] pending counter reconcile failed', err);
});
let attempted = 0;
for (const { subId } of await this.stores.pendingDelivery.head(
PENDING_SWEEP_SUBSCRIPTIONS,
)) {
try {
const row =
await this.stores.durableSubscription.getBySubId(subId);
// Nothing left to deliver to, so nothing left to hold.
if (!row || this.#isOver(row)) {
await this.stores.pendingDelivery.purge(subId);
continue;
}
// A suspended row keeps what it is owed — what happens to that
// backlog is the suspension's decision, not the sweeper's. It
// goes to the back of the line so it cannot hold the head.
if (row.suspendedAt !== null) {
// Except a revoked row's, which names paths its holder may
// no longer see: anything a dispatch in flight queued after
// the settle's own purge goes now, not at the reap.
if (row.suspendedReason === 'permission_revoked')
await this.stores.pendingDelivery.purge(subId);
else await this.stores.pendingDelivery.defer(subId);
continue;
}
attempted += await this.#drain(row);
} catch (err) {
console.warn('[events] pending sweep failed', subId, err);
}
}
return attempted;
}
/** Past its expiry, so the daily reaper is only a matter of time. */
#isOver(row: DurableSubscription): boolean {
return row.expiresAt !== null && row.expiresAt <= Date.now() / 1000;
}
/**
* Resolve, authorize and compile one subscribe request. Shared so a durable
* row cannot be created under a weaker check than a session one.
*/
async #resolveSubscribeAnchor(
actor: Actor,
rawSubject: string,
): Promise<{
token: string;
uid: string;
path: string;
match: string | null;
op: FsOp | null;
ownerUserId: number;
permission: AclMode;
}> {
const parsed = parseSubject(rawSubject);
if (parsed.family !== 'fs')
throw new HttpError(
400,
`Subject family not subscribable yet: ${parsed.family}`,
{ legacyCode: 'invalid_subject' },
);
const anchor = await resolveFsAnchor(parsed, this.#anchorDeps(), {
username: actor.user?.username,
});
// The resolver answers where a subscription keys, not whose it is, so
// the owner comes from the anchor node itself — and that is the
// keyspace the row is indexed in, because dispatch only ever knows
// whose resource changed.
const entry = await resolveNode(this.stores.fsEntry, {
uid: anchor.uid,
});
if (!entry)
throw new HttpError(404, `No such entry: ${anchor.path}`, {
legacyCode: 'subject_does_not_exist',
});
const permission = await assertSubscribeAuthorized(
actor,
{ uid: anchor.uid, path: anchor.path },
rawSubject,
this.#aclDeps(),
);
// Compile now so an unusable pattern fails this call rather than every
// event under the anchor.
if (anchor.match) compileMatch(anchor.match);
return { ...anchor, ownerUserId: entry.userId, permission };
}
/** Disconnect handler; also covers a socket the server dropped. */
async reapSocket(holderUserId: number, socketId: string): Promise<void> {
// Nothing could have been registered, so nothing has to be looked up.
// A switch flipped off under live subscriptions leaves them to the TTL.
if (!this.enabled) return;
this.#stopRefresh(holderUserId, socketId);
try {
const held = await this.stores.eventSubscription.listForSocket(
holderUserId,
socketId,
);
for (const sub of held) this.#forget(sub.subId);
const bumps = await this.stores.eventSubscription.reapSocket(
holderUserId,
socketId,
);
for (const bump of bumps) this.#publishGeneration(bump, false);
} catch (err) {
// The TTL backstop exists for exactly this.
console.warn('[events] failed to reap socket subscriptions', err);
}
}
// -- Dispatch ----------------------------------------------------
/**
* Publish one committed filesystem change. Never throws, and no write ever
* waits on it.
*/
async dispatchFs(
key: EventKey,
entry: FSEntry,
options: FsDispatchOptions = {},
): Promise<void> {
if (!this.enabled) return;
const subject = lookupPublicSubject(key);
if (!subject) return;
const ownerUserId = entry?.userId;
if (typeof ownerUserId !== 'number') return;
if (!(await this.#userHasAny(ownerUserId))) return;
const ancestors = options.ancestors ? await options.ancestors() : [];
const context: EventContext = {
key,
entry,
ancestors,
id: randomUUID(),
ts: Date.now(),
};
const watched = await this.stores.eventSubscription.watchedTokens(
ownerUserId,
subject.tokens(context),
);
if (watched.length === 0) return;
const rows = await this.stores.eventSubscription.getForTokens(
ownerUserId,
watched,
);
if (rows.length === 0) return;
await this.#route(subject, context, rows, options.actingUserId);
// Only a removal can invalidate an anchor, so nothing else pays for
// this — and this pass is already holding the rows that key on the uid
// now gone.
if (key === 'fs.remove.node')
await this.#settleDeletedAnchor(context, rows);
}
async #route(
subject: PublicSubject,
context: EventContext,
candidates: DispatchSubscription[],
actingUserId: number | undefined,
): Promise<void> {
const rows = candidates.filter(deliverable);
if (rows.length === 0) return;
// One throwaway projection reads the op off the registry entry rather
// than re-deriving it from the subject string.
const { op } = subject.project({ ...context, self: false, seq: 0 });
const matchOn = subject.matchOn(context);
const evaluated = evaluateWithCap(
rows,
(row) => this.#passes(row, op, matchOn),
FILTER_EVALUATIONS_PER_EVENT,
);
const matched = await this.#stillAuthorized(
evaluated.matched.slice(0, EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT),
context,
);
let seq = 0;
for (const row of matched) {
const event = subject.project({
...context,
self:
actingUserId === undefined ||
actingUserId === row.holderUserId,
seq: seq++,
});
// A `single` is owed rather than sent: it is queued, and never
// coalesced or broadcast — collapsing two of them would drop one
// the subscription was promised.
if (isSingle(row)) {
await this.#owe(row, event);
continue;
}
const targets = targetsOf(row);
this.#coalesce().push(coalesceKey(row.subId, event.subject), {
target: deliveryTarget(row),
envelope: { subId: row.subId, event },
socket: targets.includes('socket'),
worker: this.#workerInvocation(row, event),
});
}
// The marker goes to whoever lost the event, not to whoever received
// it: rows a cap cut before they were delivered, and rows the
// evaluation cap never reached — those may or may not have matched, and
// over-reporting is the only safe direction when we cannot know. It is
// itself capped, or a fan-out ceiling would be a fan-out of markers.
const missed = [
...evaluated.matched.slice(EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT),
...rows.slice(evaluated.evaluated),
].slice(0, EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT);
if (missed.length === 0) return;
this.#gap(
missed,
subject,
context,
evaluated.stoppedEarly
? 'filter_evaluation_limit'
: 'matched_subscription_limit',
);
}
/** Op filter first — a comparison, where the glob is not. */
#passes(row: DispatchSubscription, op: FsOp, matchOn: string): boolean {
if (row.op !== null && row.op !== op) return false;
if (!row.match) return true;
const relative = relativeTo(row.anchorPath, matchOn);
if (relative === null) return false;
return this.#matcherFor(row).test(relative);
}
/**
* Re-run each surviving row's access against the node the event is about,
* not against its anchor: a filter that reaches into something the holder
* cannot list must not deliver from it, and a share revoked after the
* subscription was made must stop delivering at once rather than when the
* row is next touched.
*
* Last of the filters, because it is the only one that can cost a lookup.
* Two layers keep that lookup rare: the cross-event cache, keyed by the
* permission cache's own generation, answers a subscription being written
* to repeatedly without asking anything; and within one event, rows sharing
* an identity and a grant share one decision — which is what a fan-out over
* one folder is.
*/
async #stillAuthorized(
rows: DispatchSubscription[],
context: EventContext,
): Promise<DispatchSubscription[]> {
if (rows.length === 0) return rows;
const node = this.#eventDescriptor(context);
const identities = new Map<string, Promise<GrantIdentity | null>>();
const decisions = new Map<string, Promise<boolean>>();
const allowed = await Promise.all(
rows.map((row) =>
this.#recheck(row, node, context.entry.uid, {
identities,
decisions,
}),
),
);
return rows.filter((_row, i) => allowed[i]);
}
async #recheck(
row: DispatchSubscription,
node: ResourceDescriptor,
nodeUid: string,
memo: {
identities: Map<string, Promise<GrantIdentity | null>>;
decisions: Map<string, Promise<boolean>>;
},
): Promise<boolean> {
const identityKey = `${row.holderUserId}|${row.appUid ?? ''}`;
let identity = memo.identities.get(identityKey);
if (!identity) {
identity = this.#grantIdentity(row);
memo.identities.set(identityKey, identity);
}
// An identity that cannot be resolved — a deleted app, a deleted user —
// is one nothing may be delivered to.
const resolved = await identity;
if (!resolved) return false;
const key = {
subId: row.subId,
generation: resolved.generation,
nodeUid,
};
const cached = this.#deliveryAuth.read(key);
if (cached !== null) return cached;
const decisionKey = `${identityKey}|${row.permission}`;
let decision = memo.decisions.get(decisionKey);
if (!decision) {
decision = checkDeliveryAuthorized(
resolved.actor,
row.permission,
node,
this.#aclDeps(),
);
memo.decisions.set(decisionKey, decision);
}
const allowed = await decision;
this.#deliveryAuth.write(key, allowed);
return allowed;
}
/** Who a row acts as, and the counter its answers are keyed by. */
async #grantIdentity(
row: DispatchSubscription,
): Promise<GrantIdentity | null> {
try {
const deps = this.#aclDeps();
const actor = await resolveGrantActor(row, deps);
if (!actor) return null;
return {
actor,
generation: await deliveryGenerationTag(actor, deps),
};
} catch {
return null;
}
}
/** The event's node as ACL wants it, reusing the walk dispatch already did. */
#eventDescriptor(context: EventContext): ResourceDescriptor {
return nodeDescriptor(
{ uid: context.entry.uid, path: context.entry.path },
{ getAncestorChain: async () => context.ancestors },
);
}
#matcherFor(row: DispatchSubscription): CompiledMatch {
const cached = this.#compiled.get(row.subId);
if (cached && cached.pattern === row.match) return cached;
const compiled = compileMatch(row.match as string);
this.#compiled.set(row.subId, compiled);
return compiled;
}
#gap(
rows: DispatchSubscription[],
subject: PublicSubject,
context: EventContext,
reason: GapReason,
): void {
for (const row of rows) {
const marker: GapMarker = {
id: context.id,
subject: subject.subject,
op: 'gap',
reason,
ts: context.ts,
};
// A marker is a delivery, so it takes the same route its
// subscription's events would: queued for a `single`, sent for the
// rest.
if (isSingle(row)) {
void this.#owe(row, marker);
continue;
}
// A marker rides the socket; a row with none has nowhere to hear
// it, and sending nothing must not count as a delivery.
if (!targetsOf(row).includes('socket')) continue;
this.#send({
target: deliveryTarget(row),
socket: true,
envelope: { subId: row.subId, event: marker },
});
}
}
// -- Settling ----------------------------------------------------
/**
* Take out of service every durable subscription a withdrawn grant was
* holding up. One holder-index read per revocation, not one per row.
*
* The delivery re-check already refuses these, so this is not what makes a
* revocation safe — it is what keeps a revoked subscription from going on
* costing its holder an anchor slot, a daily line and a filter evaluation
* per event forever. Re-granting does not bring one back: the consent to
* watch is re-established by subscribing again.
*
* Session rows are left to the re-check: finding them means knowing which
* connections a holder has, which is a keyspace scan, and one that ends
* with the connection anyway.
*/
async settleRevokedGrant(revocation: RevokedGrant): Promise<number> {
if (!this.enabled) return 0;
const held = await this.stores.durableSubscription.listActiveForHolder(
revocation.holderUserId,
revocation.appUid,
);
// Withdrawing an app's access wholesale is the user saying the app is
// done, so it takes everything the app holds — no per-row question,
// and none of the standing exemptions an app enjoys over its own data
// keep a background subscription alive past the consent that made it.
const settling =
revocation.permission === null
? held
: await this.#leftUnauthorized(held, revocation.permission);
if (settling.length === 0) return 0;
// Only the rows this pass was the one to suspend are its to purge and
// announce: an unshare withdraws several grant strings in a row, and
// every one of them runs this settle.
const { suspended, bumps } =
await this.stores.durableSubscription.suspend(
settling,
'permission_revoked',
);
for (const row of suspended) {
// Unlike every other suspension, this backlog goes at once: it
// holds the paths of a resource its holder has just lost the right
// to see, and keeping it for a resume that by design never comes
// turns a revocation into a delayed disclosure.
await this.stores.pendingDelivery.purge(row.subId).catch(() => {});
// Takes the coalesced deliveries with it: one still in flight names
// exactly what its holder has stopped being allowed to see.
this.#forget(row.subId);
}
for (const bump of bumps) this.#publishGeneration(bump, true);
await this.#notifyEnded(suspended, 'permission_revoked');
return suspended.length;
}
/**
* Of a holder's rows, the ones a named withdrawn grant has actually left
* without access.
*
* Two steps, and both are needed. The first is implication-aware rather
* than string equality: a row's anchor and stored mode compose the
* permission its subscribe check ran against, and `list` is answered by
* `read`, `write` and `manage`, so withdrawing any of them puts the row in
* question. That only narrows, though — the second step asks the access
* question for real, because a share whose mode merely _changed_ is
* recorded as a grant followed by a revoke, and settling on the revoke
* alone would end a subscription whose reach just got wider.
*
* A grant withdrawn on an _ancestor_ of an anchor is not narrowed to here —
* the row names its own node, not the chain above it. Those are left to the
* delivery re-check, which stops them immediately and permanently.
*/
async #leftUnauthorized(
rows: readonly DurableSubscription[],
permission: string,
): Promise<DurableSubscription[]> {
const settling: DurableSubscription[] = [];
for (const row of rows) {
const covered = this.services.acl
.permissionsFor(row.anchorUid, row.permission)
.includes(permission);
if (covered && !(await this.#anchorStillReachable(row)))
settling.push(row);
}
return settling;
}
/** Whether a row's holder can still reach its anchor, asked fresh. */
#anchorStillReachable(row: DurableSubscription): Promise<boolean> {
return this.#reachable(row, row);
}
/**
* Whether a row's holder may watch from `at`, under the mode it subscribed
* with.
*/
async #reachable(
row: DispatchSubscription,
at: { anchorUid: string; anchorPath: string },
): Promise<boolean> {
const deps = this.#aclDeps();
const actor = await resolveGrantActor(row, deps);
if (!actor) return false;
return checkDeliveryAuthorized(
actor,
row.permission,
nodeDescriptor({ uid: at.anchorUid, path: at.anchorPath }, deps),
deps,
);
}
/**
* Move a path-form row up to the nearest surviving ancestor its holder may
* still watch, or end it. Asked again after each move: a recursive delete
* works from the leaves up, so the level just moved to may be gone by the
* time the row is written there — and its own removal pass ran before the
* row was visible on it.
*/
async #carryForward(
row: DispatchSubscription,
ancestors: ReadonlyArray<{ uid: string; path: string }>,
): Promise<void> {
let current = row;
for (let hop = 0; hop <= ancestors.length; hop++) {
const next = await this.#nextAnchor(current, ancestors);
// Climbing must not land a row where its holder could never have
// subscribed: the re-check would deny every delivery, but the row
// would still hold an anchor slot and a filter evaluation there.
if (!next || !(await this.#reachable(current, next))) {
await this.#endSubscription(current, 'anchor_deleted');
return;
}
await this.#reanchor(current, next);
if (await resolveNode(this.stores.fsEntry, { uid: next.anchorUid }))
return;
current = {
...current,
token: next.token,
anchorUid: next.anchorUid,
anchorPath: next.anchorPath,
match: next.match,
ownerUserId: next.ownerUserId,
};
}
}
/**
* What the removal of an anchor node does to the rows keyed on it. Runs in
* the pass that delivered the `remove`, because nothing will ever come
* looking for them again: the uid they key on is gone.
*
* A row carrying a match asked about a _path_, so it follows that path up
* to whatever still exists, its match rewritten to lead with the segments
* that went. A row with no match asked about that node, whose uid is never
* coming back, so it ends.
*/
async #settleDeletedAnchor(
context: EventContext,
candidates: readonly DispatchSubscription[],
): Promise<void> {
const onAnchor = candidates.filter(
(row) => row.anchorUid === context.entry.uid,
);
if (onAnchor.length === 0) return;
for (const row of onAnchor) {
try {
if (row.match) await this.#carryForward(row, context.ancestors);
else await this.#endSubscription(row, 'anchor_deleted');
} catch (err) {
console.warn(
'[events] could not settle a subscription whose anchor was removed',
row.subId,
err,
);
}
}
}
/**
* Where a path-form row moves to, or `null` when there is nowhere to move
* it: nothing left above it, or a rewritten pattern past what may be
* compiled.
*
* The chain holds existing ancestors only, deepest first, so its head is
* already the nearest survivor however many levels a recursive delete took
* at once. It is still walked rather than indexed, because a delete works
* from the leaves up and the level above may have gone since the walk. Root
* is where climbing stops on its own — its uid never changes.
*/
async #nextAnchor(
row: DispatchSubscription,
ancestors: ReadonlyArray<{ uid: string; path: string }>,
): Promise<ReanchorInput | null> {
for (const survivor of ancestors) {
const climbed = relativeTo(survivor.path, row.anchorPath);
if (climbed === null) continue;
const match = climbed
? `${climbed}/${row.match}`
: String(row.match);
try {
compileMatch(match);
} catch {
return null;
}
// The keyspace a row is indexed in is the anchor owner's, so a
// climb that crosses an ownership boundary moves with it.
const entry = await resolveNode(this.stores.fsEntry, {
uid: survivor.uid,
});
if (!entry) continue;
return {
token: fsAnchorToken(survivor.uid),
anchorUid: survivor.uid,
anchorPath: survivor.path,
match,
ownerUserId: entry.userId,
};
}
return null;
}
async #reanchor(
row: DispatchSubscription,
next: ReanchorInput,
): Promise<void> {
const bumps =
row.durable === true
? (
await this.stores.durableSubscription.reanchor(
row as DurableSubscription,
next,
)
).bumps
: await this.stores.eventSubscription.reanchorSession(
row as SessionSubscription,
{ ...(row as SessionSubscription), ...next },
);
// The matcher cache keys on the pattern it compiled, so it corrects
// itself; the access decisions were about a node this row no longer
// watches.
this.#deliveryAuth.forget(row.subId);
for (const bump of bumps)
this.#publishGeneration(bump, row.durable === true);
}
/**
* End one subscription that cannot be carried forward. Anything already
* coalesced for it is deliberately left alone — the final `remove` is the
* last thing it is owed, and cancelling it here would be the delivery this
* whole pass exists to make.
*/
async #endSubscription(
row: DispatchSubscription,
reason: SubscriptionEndReason,
): Promise<void> {
this.#compiled.delete(row.subId);
this.#deliveryAuth.forget(row.subId);
if (row.durable !== true) {
const bump = await this.stores.eventSubscription.remove(
row as SessionSubscription,
);
this.#publishGeneration(bump, false);
return;
}
const durable = row as DurableSubscription;
const bump = await this.stores.durableSubscription.remove(durable);
// Nothing can drain a stream whose row is gone, so what is still owed
// goes with it — the same trade an explicit unsubscribe makes.
await this.stores.pendingDelivery.purge(durable.subId).catch(() => {});
this.#publishGeneration(bump, true);
await this.#notifyEnded([durable], reason);
}
/**
* Tell durable holders their subscriptions are over. They did not ask for
* this, and silence would read as "still watching" — so it goes to the
* holder, not the app's developer. One notification per holder and app:
* withdrawing an app's access ends everything it held at once, and that is
* one piece of news, not one per row.
*/
async #notifyEnded(
rows: readonly DurableSubscription[],
reason: SubscriptionEndReason,
): Promise<void> {
const groups = new Map<string, DurableSubscription[]>();
for (const row of rows) {
const key = `${row.holderUserId}|${row.appUid ?? ''}`;
groups.set(key, [...(groups.get(key) ?? []), row]);
}
for (const group of groups.values()) {
const [first] = group;
try {
await this.services.notification.notify(
[first.holderUserId],
{
title:
group.length === 1
? 'A subscription ended'
: `${group.length} subscriptions ended`,
subject: first.subject,
subjects: group
.slice(0, ENDED_SUBJECTS_LISTED)
.map((row) => row.subject),
count: group.length,
reason,
},
{ type: 'app.events.ended', appUid: first.appUid },
);
} catch (err) {
console.warn(
'[events] could not report a subscription ending',
err,
);
}
}
}
// -- Owed deliveries ---------------------------------------------
/**
* Queue a `single` and try to hand it straight over. The queue comes first:
* an attempt that fails after it is recorded is a retry, where one that
* fails before it is a lost event.
*/
async #owe(
row: DispatchSubscription,
event: DeliverableEvent,
): Promise<void> {
try {
const { shed } = await this.stores.pendingDelivery.enqueue(
row.subId,
event,
);
this.#reportShed(shed);
await this.#drain(row);
} catch (err) {
this.#enqueueFailed(row, err);
}
}
/**
* Hand this subscription what it is owed, oldest first, until something
* takes a lease and holds it. Stops after a batch so one consumer that
* settles inline cannot drain a whole backlog inside one call.
*/
async #drain(row: DispatchSubscription): Promise<number> {
let handed = 0;
for (let pass = 0; pass < PENDING_DRAIN_BATCH; pass++) {
const claimed = await this.stores.pendingDelivery.claim(row.subId);
if (!claimed) return handed;
handed++;
// Anything still holding the lease is the next consumer's answer to
// give, so this pass is over.
if (!(await this.#handOut(row, claimed))) return handed;
}
return handed;
}
/**
* One attempt at one owed delivery. Sockets first and one at a time, the
* handler once they are spent — a client that is there answers faster than
* anything else, and one that is not must not stall the delivery forever.
*
* Returns whether the delivery settled, which is what says the next one may
* go out now rather than when this lease lapses.
*/
async #handOut(
row: DispatchSubscription,
claimed: ClaimedDelivery,
): Promise<boolean> {
const targets = targetsOf(row);
const target = deliveryTarget(row);
// Only this region's own connections are visible here; the ones other
// regions hold arrive with presence.
const overSocket =
targets.includes('socket') &&
claimed.socketAttempts < SINGLE_SOCKET_ATTEMPTS &&
this.services.socket.has(target);
if (overSocket) {
await this.stores.pendingDelivery.recordSocketAttempt(
row.subId,
claimed.entryId,
);
this.#send({
target,
socket: true,
envelope: {
subId: row.subId,
event: claimed.event,
ackRequired: true,
ackId: claimed.entryId,
},
});
return false;
}
// Nowhere to put it yet: the lease is what paces the next attempt.
if (!targets.includes('worker')) return false;
const invocation = this.#workerInvocation(row, claimed.event);
if (!invocation) return false;
const outcome = await this.worker.invoke(invocation);
this.onDelivered({ subId: row.subId, event: claimed.event });
if (outcome !== 'settled') return false;
await this.stores.pendingDelivery.settle(row.subId, claimed.entryId);
return true;
}
/** What the handler seam is handed, or null for a row that wants none. */
#workerInvocation(
row: DispatchSubscription,
event: DeliverableEvent,
): WorkerInvocation | null {
if (row.durable !== true) return null;
if (!targetsOf(row).includes('worker')) return null;
return {
subId: row.subId,
holderUserId: row.holderUserId,
appUid: row.appUid,
handlerName: row.handlerName ?? null,
event,
context: row.context ?? null,
};
}
/**
* Say that deliveries were dropped to stay inside a cap. The subscriptions
* that lost them are told by the gap marker the store queued in their
* place; this is the half nobody else would see.
*/
#reportShed(shed: readonly PendingShed[]): void {
for (const dropped of shed) {
if (dropped.scope === 'subscription') {
console.warn(
`[events] dropped ${dropped.dropped} undelivered event(s) of ${dropped.subId}: backlog full`,
);
continue;
}
console.error(
`[events] dropped ${dropped.dropped} undelivered event(s) of ${dropped.subId}: too many held in this region`,
);
this.clients.alarm.create(
'events_pending_ceiling',
'Too many undelivered events are being held here — the oldest were dropped',
{ subId: dropped.subId, dropped: dropped.dropped },
'warning',
{ dedup: true },
);
}
}
/**
* A `single` that could not be queued is an event its subscription was
* promised and will never see, and nothing downstream will notice on its
* own. The write that produced it still succeeded.
*/
#enqueueFailed(row: DispatchSubscription, err: unknown): void {
console.error(
`[events] could not queue an event for ${row.subId}`,
err,
);
this.clients.alarm.create(
'events_pending_enqueue_failed',
'An event owed to a subscription could not be queued and is lost',
{
subId: row.subId,
holderUserId: row.holderUserId,
error: err instanceof Error ? err : new Error(String(err)),
},
'warning',
{ dedup: true },
);
}
// -- Delivery ----------------------------------------------------
#coalesce(): DeliveryCoalescer<AddressedDelivery> {
this.#coalescer ??= new DeliveryCoalescer<AddressedDelivery>(
EVENTS_COALESCE_WINDOW_MS,
(_key, delivery) => void this.#flush(delivery),
);
return this.#coalescer;
}
async #flush(delivery: AddressedDelivery): Promise<void> {
try {
const allowed = await checkRateLimit(
`${EVENTS_BROADCAST_DELIVERY_LIMIT.scope}:${delivery.envelope.subId}`,
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
EVENTS_BROADCAST_DELIVERY_LIMIT.window,
);
if (allowed) {
this.#send(delivery);
return;
}
const event = delivery.envelope.event as ProjectedEvent;
this.#send({
target: delivery.target,
socket: delivery.socket,
envelope: {
subId: delivery.envelope.subId,
event: {
id: event.id,
subject: event.subject,
op: 'gap',
reason: 'delivery_rate_limit',
ts: event.ts,
},
},
});
} catch (err) {
console.warn('[events] delivery failed', err);
}
}
/**
* Addressed at a socket id — which socket.io joins every socket to — or at
* a room, so either way the adapter carries it to whichever node terminates
* the connection. A durable row may also want its handler run, which
* happens alongside the socket copy and at most once per delivery.
*/
#send(delivery: AddressedDelivery): void {
if (delivery.socket) {
try {
void this.services.socket
.send(
delivery.target,
EVENTS_DELIVERY_CHANNEL,
delivery.envelope,
)
.catch((err: unknown) => {
console.warn('[events] socket send failed', err);
});
} catch (err) {
console.warn('[events] socket send failed', err);
}
}
if (delivery.worker) {
const invocation = delivery.worker;
// At-most-once by construction: a `broadcast` invocation is never
// retried, which is why the docs ask handlers to be idempotent
// rather than promising them each event exactly once.
try {
void this.worker.invoke(invocation).catch((err: unknown) => {
console.warn('[events] handler invocation failed', err);
});
} catch (err) {
console.warn('[events] handler invocation failed', err);
}
}
this.onDelivered(delivery.envelope);
}
/**
* Called once per event that actually reached a subscriber, gap markers
* included. This is the seam metering hangs off — one delivered event is
* one line, which is why nothing filtered out, coalesced away or rate
* limited can arrive here.
*/
onDelivered(_envelope: DeliveryEnvelope): void {
return;
}
// -- Hot-path cache ----------------------------------------------
/**
* Whether this user has anything subscribed at all. Warm, a `Map` read;
* cold, one `EXISTS` — behind, at most once per warm window, the table read
* that teaches this region about durable rows it has never seen. Without
* that, an empty watched set is ambiguous: it means "nobody is listening"
* in a region that has looked, and nothing at all in one that has not.
*
* The epoch is captured before the read and is part of the in-flight key,
* so a bump landing mid-flight — a subscribe discovering the very folder a
* fire-and-forget dispatch is still warming up against, say — starts its
* own fresh lookup rather than being handed the answer an older,
* now-superseded one is about to compute.
*/
async #userHasAny(userId: number): Promise<boolean> {
const cached = this.#cache.read(userId);
if (cached !== null) return cached;
const epoch = this.#cache.generationOf(userId);
const key = `${userId}|${epoch}`;
// Concurrent writes at the same epoch miss together, and each miss
// can cost a table read. One lookup answers all of them.
const inFlight = this.#lookups.get(key);
if (inFlight) return inFlight;
const lookup = this.#lookUpHasAny(userId, epoch).finally(() => {
this.#lookups.delete(key);
});
this.#lookups.set(key, lookup);
return lookup;
}
async #lookUpHasAny(userId: number, epoch: number): Promise<boolean> {
try {
await this.stores.durableSubscription.warmRegion(userId);
const hasAny =
await this.stores.eventSubscription.userHasAny(userId);
this.#cache.write(userId, epoch, hasAny);
return hasAny;
} catch {
// Not being able to tell is the same outcome as no subscribers,
// and this must not become the writer's problem.
return false;
}
}
/**
* Forget what this process, and this region, believe about one user's
* subscriptions. Where a generation bump from anywhere else lands: the
* process drops its answer, and the region rebuilds its durable rows from
* the table on the next dispatch.
*
* Bumps unconditionally rather than passing a remote generation through to
* the cache's own number check: `ev:g` is a region-local counter, so a
* peer's bump can carry a number this process's own counter has already
* passed for entirely unrelated reasons, and comparing them would let a
* real invalidation be silently ignored as "already applied". There is
* nothing to order a cross-region signal against, so every one just
* forgets, and `SubscriptionCache`'s read-side TTL is what bounds a process
* that never received one at all.
*/
invalidateUser(
userId: number,
{ rebuild = true }: { rebuild?: boolean } = {},
): void {
this.#cache.bump(userId);
if (!rebuild) return;
void this.stores.eventSubscription
.markRegionCold(userId)
.catch(() => {});
}
/**
* `durable` says whether the table changed. Session rows live in this
* region's Redis alone, so a peer hearing about one has nothing to rebuild
* — only a durable bump is worth a primary read over there.
*/
#publishGeneration(
{ userId, generation }: GenerationBump,
durable: boolean,
): void {
this.#cache.bump(userId, generation);
try {
this.clients.event.emit(
'outer.events.generationBumped',
{ userId, generation, durable },
{},
);
} catch {
// A peer that misses the bump rebuilds on its own next miss.
}
}
// -- Plumbing ----------------------------------------------------
#forget(subId: string): void {
this.#compiled.delete(subId);
this.#deliveryAuth.forget(subId);
this.#coalesce().cancel((key) => key.startsWith(`${subId}|`));
}
#anchorDeps(): FsAnchorDeps {
return {
resolveNode: (ref) => resolveNode(this.stores.fsEntry, ref),
getAncestorChain: (path) => this.services.fs.getAncestorChain(path),
};
}
#aclDeps(): EventAclDeps {
return {
acl: this.services.acl,
getAncestorChain: (path) => this.services.fs.getAncestorChain(path),
getUser: (userId) => this.stores.user.getById(userId),
getApp: (uid) => this.stores.app.getByUid(uid),
getCacheGeneration: (uid) =>
this.stores.permission.getCacheGeneration(uid),
};
}
async #spendCallBudget(userId: number): Promise<void> {
const ok = await checkRateLimit(
`${EVENTS_SUBSCRIBE_LIMIT.scope}:${userId}`,
EVENTS_SUBSCRIBE_LIMIT.limit,
EVENTS_SUBSCRIBE_LIMIT.window,
);
if (!ok) throw tooManyCalls();
}
/**
* Hold a live socket's keys open. Connections routinely outlive the TTL,
* and renewing is what separates one of those from a node that died without
* reaping.
*/
#startRefresh(holderUserId: number, socketId: string): void {
const key = `${holderUserId}|${socketId}`;
if (this.#refreshTimers.has(key)) return;
const timer = setInterval(
() => {
void this.stores.eventSubscription
.refresh(holderUserId, socketId)
.catch(() => {});
},
Math.floor((SESSION_SUBSCRIPTION_TTL_SECONDS * 1000) / 3),
);
timer.unref?.();
this.#refreshTimers.set(key, timer);
}
#armExpirySweep(): void {
if (!this.enabled) return;
const run = () => {
void this.sweepExpired()
.then(() => this.sweepSuspended())
.catch((err) => {
console.warn('[events] expiry sweep failed', err);
});
};
// Jittered so a deploy does not have every node sweep at once.
const kick = setTimeout(
run,
EXPIRY_SWEEP_INITIAL_DELAY_MS * (0.5 + Math.random()),
);
kick.unref?.();
this.#expiryKick = kick;
const sweep = setInterval(run, EXPIRY_SWEEP_INTERVAL_MS);
sweep.unref?.();
this.#expirySweep = sweep;
}
#armPendingSweep(): void {
if (!this.enabled) return;
const sweep = setInterval(() => {
void this.sweepPending().catch((err) => {
console.warn('[events] pending sweep failed', err);
});
}, PENDING_SWEEP_INTERVAL_MS);
sweep.unref?.();
this.#pendingSweep = sweep;
}
#stopRefresh(holderUserId: number, socketId: string): void {
const key = `${holderUserId}|${socketId}`;
const timer = this.#refreshTimers.get(key);
if (!timer) return;
clearInterval(timer);
this.#refreshTimers.delete(key);
}
}