feat: session event subscriptions and dispatch hot path (PUT-1666) (#3675)

This commit is contained in:
Daniel Salazar
2026-09-02 09:01:22 -07:00
committed by GitHub
parent 92e1b40361
commit b07d2e109f
18 changed files with 3103 additions and 3 deletions
+13
View File
@@ -435,6 +435,19 @@ export type EventMap = {
'outer.permission.flatInvalidated': {
entries: Array<{ holderUserId: number; permission: string }>;
};
/**
* A user's subscription set changed, so every process must drop its cached
* "does this user have any subscriptions" answer. Carries the counter the
* bump produced so a listener can order two bumps it sees out of order.
*
* The dispatch hot path never reads the counter — it is the broadcast that
* invalidates, which is what keeps an unsubscribed write at zero Redis
* commands.
*/
'outer.events.generationBumped': {
userId: number;
generation: number;
};
'outer.fs.write-hash': { hash: string; uuid: string };
/**
* Cache keys the KV read cache must stop serving, because the entries
+97
View File
@@ -0,0 +1,97 @@
/*
* 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 type { RouteRateLimit } from '../../core/http/types';
// -- Shared event limits ---------------------------------------------
//
// One cheap write can fan out into (matched subscriptions × connected
// sockets), and every number here caps a term of that product. Storage quota
// and the FS write limits bound how many events can be *produced*; nothing
// else bounds how many deliveries one of them turns into.
//
// The verbs arrive over the socket rather than over HTTP, so these are not
// mounted as route gates — they are spent imperatively at the point each one
// protects, with the `scope` naming the counter the same way a route gate
// would. Every number below is published in `rate-limits-and-quotas.md`.
/** Per-user sliding window, spent imperatively via `checkRateLimit`. */
const userWindow = (
scope: string,
limit: number,
window = 60_000,
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
// -- Subscription surface --------------------------------------------
/**
* Live subscriptions one socket may hold.
*
* Session subscriptions are Redis set members keyed to a socket that can go
* away without saying so, so the cap is really on how much a disconnect can
* leave behind. A client watching more than a few dozen distinct anchors wants
* one subscription on their common parent instead.
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
/**
* Subscribe + unsubscribe calls per minute, per user.
*
* Both resolve a path and take a write, so a loop over them is a write loop.
* Sized like the sharing verbs, which are the closest existing analogue: a
* client sets its subscriptions up once and then leaves them alone.
*/
export const EVENTS_SUBSCRIBE_LIMIT = userWindow('events:subscribe', 60);
// -- Dispatch fan-out ------------------------------------------------
/**
* Subscriptions one event may deliver to before dispatch stops and reports a
* gap. The amplification ceiling: without it, one write costs as many
* deliveries as an account cared to register.
*/
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
/**
* Broadcast deliveries per minute, per subscription.
*
* Ten a second sustained is far past what a UI can render and well past what a
* human is watching for; a subscription over it is looping, and the gap marker
* tells it so rather than letting it silently miss events.
*/
export const EVENTS_BROADCAST_DELIVERY_LIMIT = userWindow(
'events:delivery',
600,
);
/**
* Filter evaluations one event may spend. Lives with the matcher because the
* primitive that enforces it does; re-exported here so every published number
* is readable in one place.
*/
export { FILTER_EVALUATIONS_PER_EVENT } from '../../services/events/matcher.js';
// -- Coalescing ------------------------------------------------------
/**
* Debounce window per (subscription, subject). A multipart upload or a
* recursive delete is one intent that lands as many writes; this is what makes
* it arrive as one event.
*/
export const EVENTS_COALESCE_WINDOW_MS = 250;
@@ -0,0 +1,970 @@
/*
* 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 MockRedis from 'ioredis-mock';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
EVENTS_BROADCAST_DELIVERY_LIMIT,
EVENTS_COALESCE_WINDOW_MS,
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SUBSCRIBE_LIMIT,
} from '../../controllers/events/limits.js';
import type { Actor } from '../../core/actor.js';
import { isHttpError } from '../../core/http/HttpError.js';
import { EventSubscriptionStore } from '../../stores/events/EventSubscriptionStore.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { IConfig } from '../../types.js';
import {
EventsService,
EVENTS_DELIVERY_CHANNEL,
EVENTS_SUBSCRIBE_VERB,
EVENTS_UNSUBSCRIBE_VERB,
type DeliveryEnvelope,
type EventSocket,
} from './EventsService.js';
import { FILTER_EVALUATIONS_PER_EVENT } from './matcher.js';
/**
* The hot path is a cost claim before it is a behaviour claim, so the Redis
* client is counted rather than replaced: these tests assert how many commands
* a dispatch spends, which a stubbed store could not tell us.
*/
// ioredis-mock keeps one keyspace per process, and the imperative rate-limit
// counters are process-global too — so identity is what isolates tests here.
let seq = 0;
let userId = 0;
let socketId = '';
let redis: InstanceType<typeof MockRedis.Cluster>;
let commands: string[];
let store: EventSubscriptionStore;
let service: EventsService;
let sent: Array<{ socket?: string; envelope: DeliveryEnvelope }>;
let delivered: DeliveryEnvelope[];
let entries: Map<string, FSEntry>;
let eventBus: { on: ReturnType<typeof vi.fn>; emit: ReturnType<typeof vi.fn> };
/** The listener `onServerStart` registered for remote generation bumps. */
type GenerationBumpHandler = (
key: string,
data: unknown,
meta: unknown,
) => void;
const remoteGenerationBumpHandler = (): GenerationBumpHandler | undefined =>
eventBus.on.mock.calls.find(
([key]: [string]) => key === 'outer.events.generationBumped',
)?.[1] as GenerationBumpHandler | undefined;
const COUNTED = new Set([
'exists',
'smismember',
'scard',
'smembers',
'sadd',
'srem',
'hset',
'hdel',
'hlen',
'hvals',
'incr',
'expire',
'get',
'del',
'pipeline',
]);
/** Count what actually crosses the client boundary, pipelines included. */
const countingRedis = (
inner: InstanceType<typeof MockRedis.Cluster>,
): InstanceType<typeof MockRedis.Cluster> =>
new Proxy(inner, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof property !== 'string' || typeof value !== 'function')
return value;
if (!COUNTED.has(property)) return value;
if (property === 'pipeline')
return (...args: unknown[]) => {
const pipeline = (
value as (...a: unknown[]) => Record<string, unknown>
).apply(target, args);
const exec = pipeline.exec as () => Promise<unknown>;
pipeline.exec = () => {
commands.push('pipeline');
return exec.call(pipeline);
};
return pipeline;
};
return (...args: unknown[]) => {
commands.push(property);
return (value as (...a: unknown[]) => unknown).apply(
target,
args,
);
};
},
}) as InstanceType<typeof MockRedis.Cluster>;
const entry = (over: Partial<FSEntry> = {}): FSEntry =>
({
uid: 'file-uid',
uuid: 'file-uid',
path: `/u${userId}/Documents/notes.txt`,
userId,
isDir: false,
...over,
}) as FSEntry;
const actorFor = (id = userId): Actor =>
({
user: { id, uuid: `user-${id}`, username: `u${id}` },
effectiveApp: null,
}) as unknown as Actor;
/**
* Each service gets its own outbox. A delivery still in flight when a test
* ends must land in that test's record, not in the next one's.
*/
const buildService = (
config: IConfig,
): {
service: EventsService;
sent: Array<{ socket?: string; envelope: DeliveryEnvelope }>;
delivered: DeliveryEnvelope[];
eventBus: { on: ReturnType<typeof vi.fn>; emit: ReturnType<typeof vi.fn> };
} => {
const outbox: Array<{ socket?: string; envelope: DeliveryEnvelope }> = [];
const counted: DeliveryEnvelope[] = [];
const bus = { on: vi.fn(), emit: vi.fn() };
const built = new EventsService(
config,
{
redis,
event: bus,
} as never,
{ eventSubscription: store, fsEntry: fsEntryStore } as never,
{
socket: {
send: vi.fn(async (spec: { socket?: string }, _key, data) => {
outbox.push({
socket: spec.socket,
envelope: data as DeliveryEnvelope,
});
}),
},
fs: {
getAncestorChain: vi.fn(async (path: string) =>
ancestorChain(path),
),
},
} as never,
);
built.onDelivered = (envelope) => counted.push(envelope);
built.onServerStart();
return { service: built, sent: outbox, delivered: counted, eventBus: bus };
};
const fsEntryStore = {
getEntryByUuid: async (uid: string) => entries.get(`uid:${uid}`) ?? null,
getEntryByPath: async (path: string) => entries.get(`path:${path}`) ?? null,
getEntryById: async () => null,
};
const register = (node: FSEntry): FSEntry => {
entries.set(`uid:${node.uid}`, node);
entries.set(`path:${node.path}`, node);
return node;
};
/** Existing ancestors of a path, deepest first — what `FSService` returns. */
const ancestorChain = (path: string): Array<{ uid: string; path: string }> => {
const chain: Array<{ uid: string; path: string }> = [];
let cursor = path;
while (cursor.lastIndexOf('/') > 0) {
cursor = cursor.slice(0, cursor.lastIndexOf('/'));
const found = entries.get(`path:${cursor}`);
if (found) chain.push({ uid: found.uid, path: found.path });
}
return chain;
};
/** Anchor the tests subscribe against, and the tree above the written file. */
const seedTree = (): { home: FSEntry; documents: FSEntry; file: FSEntry } => {
const home = register(
entry({ uid: `home-${seq}`, path: `/u${userId}`, isDir: true }),
);
const documents = register(
entry({
uid: `docs-${seq}`,
path: `/u${userId}/Documents`,
isDir: true,
}),
);
const file = register(entry({ uid: `file-${seq}` }));
return { home, documents, file };
};
const subscribe = async (subject: string, socket = socketId) =>
(await service.subscribe(actorFor(), socket, { subject })).sub;
/**
* Register rows straight into the store. The fan-out and filter caps need
* hundreds of subscriptions, which is far past the per-minute budget on the
* verb — and the verb is not what those tests are about.
*/
const seedSubscriptions = async (
count: number,
row: { token: string; anchorUid: string; anchorPath: string; match: string | null },
): Promise<void> => {
for (let i = 0; i < count; i++)
await store.add({
subId: `seed-${seq}-${i}`,
socketId: `socket-${seq}-${i}`,
userId,
subject: 'fs:seeded',
op: null,
appUid: null,
...row,
});
};
/** Dispatch as the FS write path does, with the ancestor walk as a thunk. */
const dispatch = async (node: FSEntry, key = 'fs.write.file' as const) =>
service.dispatchFs(key, node, {
actingUserId: userId,
ancestorUids: async () => ancestorChain(node.path).map((a) => a.uid),
});
beforeEach(() => {
seq++;
userId = 1000 + seq;
socketId = `socket-${seq}`;
commands = [];
entries = new Map();
redis = countingRedis(new MockRedis.Cluster(['redis://localhost:7001']));
store = new EventSubscriptionStore(
{} as IConfig,
{ redis } as never,
{} as never,
);
({ service, sent, delivered, eventBus } = buildService({
events: { enabled: true },
} as IConfig));
});
afterEach(() => {
vi.useRealTimers();
});
// -- The switch ------------------------------------------------------
describe('the feature switch', () => {
it('is off when the config says nothing', () => {
expect(buildService({} as IConfig).service.enabled).toBe(false);
});
it('refuses to subscribe with a stable code when off', async () => {
const { service: off } = buildService({
events: { enabled: false },
} as IConfig);
await expect(
off.subscribe(actorFor(), socketId, { subject: 'fs:/u/x' }),
).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) && err.legacyCode === 'events_disabled',
);
});
it('spends nothing at all on a dispatch when off', async () => {
const { service: off } = buildService({
events: { enabled: false },
} as IConfig);
const { file } = seedTree();
await off.dispatchFs('fs.write.file', file, {
ancestorUids: async () => {
throw new Error('the tree must not be walked');
},
});
expect(commands).toEqual([]);
});
});
// -- Subscribing -----------------------------------------------------
describe('subscribing', () => {
it('anchors on the node and reports where it landed', async () => {
const { documents } = seedTree();
const sub = await subscribe(`fs:/u${userId}/Documents`);
expect(sub).toMatchObject({
anchor: { uid: documents.uid, path: documents.path },
match: null,
op: null,
});
});
it('rejects a subject that resolves to nothing', async () => {
await expect(subscribe('fs:/nowhere/at/all')).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) &&
err.statusCode === 404 &&
err.legacyCode === 'subject_does_not_exist',
);
});
it('answers a node someone else owns as absent, not as refused', async () => {
const { documents } = seedTree();
register({ ...documents, userId: userId + 500 } as FSEntry);
await expect(
subscribe(`fs:${documents.uid}`),
).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) &&
err.statusCode === 404 &&
err.legacyCode === 'subject_does_not_exist',
);
});
it('files the missing remainder as the filter', async () => {
const { documents } = seedTree();
const sub = await subscribe(`fs:/u${userId}/Documents/reports/*.csv`);
expect(sub).toMatchObject({
anchor: { uid: documents.uid },
match: 'reports/*.csv',
});
});
});
describe('unsubscribing', () => {
it('stops delivery', async () => {
const { documents, file } = seedTree();
const sub = await subscribe(`fs:${documents.uid}`);
await service.unsubscribe(actorFor(), socketId, { subId: sub.subId });
await dispatch(file);
expect(sent).toEqual([]);
});
it('reports an id this socket never held as absent', async () => {
seedTree();
await expect(
service.unsubscribe(actorFor(), socketId, { subId: 'not-mine' }),
).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) &&
err.statusCode === 404 &&
err.legacyCode === 'subscription_does_not_exist',
);
});
});
// -- What a dispatch costs -------------------------------------------
describe('what a dispatch costs', () => {
it('spends no redis command for a user with nothing subscribed', async () => {
const { file } = seedTree();
// First dispatch warms the answer; it is the ones after that the
// product actually pays for.
await dispatch(file);
commands = [];
for (let i = 0; i < 5; i++) await dispatch(file);
expect(commands).toEqual([]);
});
it('spends one command, and no store read, on an unwatched token', async () => {
const { documents } = seedTree();
await subscribe(`fs:${documents.uid}`);
const elsewhere = register(
entry({ uid: `other-${seq}`, path: `/u${userId}/Other/file.txt` }),
);
await dispatch(elsewhere);
commands = [];
await dispatch(elsewhere);
expect(commands).toEqual(['smismember']);
expect(sent).toEqual([]);
});
it('reads subscriptions only for the tokens that are watched', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
await dispatch(file);
commands = [];
await dispatch(file);
// The membership test, then one pipelined read of the one hit.
expect(commands).toEqual(['smismember', 'pipeline']);
});
it('walks the tree only for a user who has subscriptions', async () => {
const { file } = seedTree();
const walk = vi.fn(async () => ['docs']);
await service.dispatchFs('fs.write.file', file, {
ancestorUids: walk,
});
await service.dispatchFs('fs.write.file', file, {
ancestorUids: walk,
});
expect(walk).not.toHaveBeenCalled();
});
it('notices a new subscription without being told to look again', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await dispatch(file);
await subscribe(`fs:${documents.uid}`);
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent).toHaveLength(1);
});
});
describe('cross-process invalidation', () => {
it('flips a cached "nothing subscribed" answer on a remote bump, no timer involved', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await dispatch(file); // warms this process's cache to "nothing subscribed"
// A subscription made on another process: the row lands straight in
// the shared store, never through this process's `subscribe()` —
// which is the only other thing that updates the local cache.
await store.add({
subId: 'remote-sub',
socketId: 'remote-socket',
userId,
subject: `fs:${documents.uid}`,
token: `f#${documents.uid}`,
anchorUid: documents.uid,
anchorPath: documents.path,
match: null,
op: null,
appUid: null,
});
const generation = await store.getGeneration(userId);
const handler = remoteGenerationBumpHandler();
expect(handler).toBeDefined();
handler?.(
'outer.events.generationBumped',
{ userId, generation },
{ from_outside: true },
);
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent).toHaveLength(1);
});
it('ignores its own emit rather than re-checking what it already applied', async () => {
const { file } = seedTree();
await dispatch(file); // warms the cache to "nothing subscribed"
commands = [];
// No `from_outside`: this is what the local half of our own emit
// looks like, and it must not force a redundant re-check.
remoteGenerationBumpHandler()?.(
'outer.events.generationBumped',
{ userId, generation: 1 },
{},
);
await dispatch(file);
expect(commands).toEqual([]);
});
});
// -- Matching --------------------------------------------------------
describe('matching', () => {
beforeEach(() => {
vi.useFakeTimers();
});
const flush = () =>
vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
it('delivers a deep write to a subscription on the folder', async () => {
const { documents, file } = seedTree();
const sub = await subscribe(`fs:${documents.uid}`);
await dispatch(file);
await flush();
expect(sent).toHaveLength(1);
expect(sent[0].socket).toBe(socketId);
expect(sent[0].envelope.subId).toBe(sub.subId);
expect(sent[0].envelope.event).toMatchObject({
op: 'write',
uid: file.uid,
path: file.path,
self: true,
});
});
it('drops an event whose op the subscription did not ask for', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}:remove`);
await dispatch(file);
await flush();
expect(sent).toEqual([]);
});
it('drops an event the match filter excludes', async () => {
const { documents } = seedTree();
await subscribe(`fs:/u${userId}/Documents/reports/*.csv`);
const wrong = register(
entry({
uid: `wrong-${seq}`,
path: `${documents.path}/reports/summary.txt`,
}),
);
await dispatch(wrong);
await flush();
expect(sent).toEqual([]);
});
it('delivers what the match filter includes', async () => {
const { documents } = seedTree();
await subscribe(`fs:/u${userId}/Documents/reports/*.csv`);
const right = register(
entry({
uid: `right-${seq}`,
path: `${documents.path}/reports/summary.csv`,
}),
);
await dispatch(right);
await flush();
expect(sent).toHaveLength(1);
});
it('marks a write by someone else as not the holder`s own', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
await service.dispatchFs('fs.write.file', file, {
actingUserId: userId + 900,
ancestorUids: async () => [documents.uid],
});
await flush();
expect(sent[0].envelope.event).toMatchObject({ self: false });
});
it('will not deliver a row whose holder does not own the node', async () => {
const { documents, file } = seedTree();
const sub = await subscribe(`fs:${documents.uid}`);
// Holder and owner cannot disagree through the API yet, so the stored
// row is where they are made to — which is what the delivery-side
// check is for once a shared anchor can produce that pair.
const rowKey = `ev:t:{${userId}}:f#${documents.uid}`;
const stored = JSON.parse(
(await redis.hget(rowKey, sub.subId)) as string,
) as Record<string, unknown>;
await redis.hset(
rowKey,
sub.subId,
JSON.stringify({ ...stored, userId: userId + 700 }),
);
await dispatch(file);
await flush();
expect(sent).toEqual([]);
});
it('publishes nothing for an event with no registry entry', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
commands = [];
await service.dispatchFs('fs.copy.node', file, {
ancestorUids: async () => [documents.uid],
});
await flush();
expect(commands).toEqual([]);
expect(sent).toEqual([]);
});
});
// -- Coalescing ------------------------------------------------------
describe('coalescing', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('turns a burst of writes to one file into one delivery', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
for (let i = 0; i < 12; i++) await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent).toHaveLength(1);
});
it('keeps two files apart', async () => {
const { documents, file } = seedTree();
const second = register(
entry({
uid: `second-${seq}`,
path: `${documents.path}/other.txt`,
}),
);
await subscribe(`fs:${documents.uid}`);
for (let i = 0; i < 6; i++) {
await dispatch(file);
await dispatch(second);
}
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent).toHaveLength(2);
expect(sent.map((s) => (s.envelope.event as { uid: string }).uid).sort())
.toEqual([file.uid, second.uid].sort());
});
it('counts exactly what it delivered', async () => {
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
for (let i = 0; i < 9; i++) await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(delivered).toHaveLength(sent.length);
expect(delivered).toHaveLength(1);
});
});
// -- Limits ----------------------------------------------------------
describe('limits', () => {
it('refuses subscription changes past the per-minute budget', async () => {
const { documents } = seedTree();
for (let i = 0; i < EVENTS_SUBSCRIBE_LIMIT.limit; i++)
await service
.unsubscribe(actorFor(), socketId, { subId: 'nope' })
.catch(() => {});
await expect(subscribe(`fs:${documents.uid}`)).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) &&
err.statusCode === 429 &&
err.legacyCode === 'too_many_requests',
);
});
it('delivers to the fan-out cap and then says there was more', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await seedSubscriptions(EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT + 5, {
token: `f#${documents.uid}`,
anchorUid: documents.uid,
anchorPath: documents.path,
match: null,
});
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
// Fifty were delivered; the five that were cut are the five told so.
const ops = sent.map((s) => s.envelope.event.op);
expect(ops.filter((op) => op === 'write')).toHaveLength(
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
);
expect(ops.filter((op) => op === 'gap')).toHaveLength(5);
expect(
sent.find((s) => s.envelope.event.op === 'gap')?.envelope.event,
).toMatchObject({ reason: 'matched_subscription_limit' });
const gapped = new Set(
sent
.filter((s) => s.envelope.event.op === 'gap')
.map((s) => s.envelope.subId),
);
const written = new Set(
sent
.filter((s) => s.envelope.event.op === 'write')
.map((s) => s.envelope.subId),
);
expect([...gapped].some((id) => written.has(id))).toBe(false);
});
it('stops evaluating filters at the cap and says there was more', async () => {
vi.useFakeTimers();
const { documents } = seedTree();
// Every one of these matches, so the pass would run to the end if the
// cap did not stop it — and the marker names the cap that did.
await seedSubscriptions(FILTER_EVALUATIONS_PER_EVENT + 20, {
token: `f#${documents.uid}`,
anchorUid: documents.uid,
anchorPath: documents.path,
match: 'reports/*.csv',
});
const file = register(
entry({
uid: `deep-${seq}`,
path: `${documents.path}/reports/summary.csv`,
}),
);
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(
sent.filter((s) => s.envelope.event.op === 'write'),
).toHaveLength(EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT);
// 200 evaluated, 20 never reached: everything past the delivery cap is
// told it missed something, itself capped.
expect(
sent.filter((s) => s.envelope.event.op === 'gap'),
).toHaveLength(EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT);
expect(
sent.find((s) => s.envelope.event.op === 'gap')?.envelope.event,
).toMatchObject({ reason: 'filter_evaluation_limit' });
});
it('drops a subscription past its delivery budget with a gap', async () => {
vi.useFakeTimers();
const { documents } = seedTree();
const sub = await subscribe(`fs:${documents.uid}`);
const over = EVENTS_BROADCAST_DELIVERY_LIMIT.limit + 1;
// One window, one file each: coalescing folds repeats on a subject, so
// spending the budget means distinct subjects rather than distinct
// minutes — the budget is per subscription, not per subject.
for (let i = 0; i < over; i++)
await dispatch(
register(
entry({
uid: `burst-${seq}-${i}`,
path: `${documents.path}/burst-${i}.txt`,
}),
),
);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent).toHaveLength(over);
expect(sent.every((s) => s.envelope.subId === sub.subId)).toBe(true);
expect(
sent.filter((s) => s.envelope.event.op === 'write'),
).toHaveLength(EVENTS_BROADCAST_DELIVERY_LIMIT.limit);
expect(
sent.find((s) => s.envelope.event.op === 'gap')?.envelope.event,
).toMatchObject({ reason: 'delivery_rate_limit' });
});
});
// -- The socket surface ----------------------------------------------
describe('the socket surface', () => {
const fakeSocket = (): EventSocket & {
fire: (event: string, ...args: unknown[]) => void;
} => {
const handlers = new Map<string, (...args: never[]) => void>();
return {
id: socketId,
on: (event, listener) => handlers.set(event, listener),
once: (event, listener) => handlers.set(event, listener),
fire: (event, ...args) =>
(
handlers.get(event) as
| ((...a: unknown[]) => void)
| undefined
)?.(...args),
};
};
it('acks a subscribe with the subscription it made', async () => {
const { documents } = seedTree();
const socket = fakeSocket();
service.attachSocket(socket, actorFor());
const ack = vi.fn();
socket.fire(
EVENTS_SUBSCRIBE_VERB,
{ subject: `fs:${documents.uid}` },
ack,
);
await vi.waitFor(() => expect(ack).toHaveBeenCalled());
expect(ack.mock.calls[0][0]).toMatchObject({
ok: true,
sub: { subject: `fs:${documents.uid}` },
});
});
it('acks a failure with a code rather than throwing at the socket', async () => {
const socket = fakeSocket();
service.attachSocket(socket, actorFor());
const ack = vi.fn();
socket.fire(EVENTS_SUBSCRIBE_VERB, { subject: 'not-a-subject' }, ack);
await vi.waitFor(() => expect(ack).toHaveBeenCalled());
expect(ack.mock.calls[0][0]).toEqual({
ok: false,
error: expect.objectContaining({ code: 'invalid_subject' }),
});
});
it('acks an unsubscribe', async () => {
const { documents } = seedTree();
const socket = fakeSocket();
service.attachSocket(socket, actorFor());
const sub = await subscribe(`fs:${documents.uid}`);
const ack = vi.fn();
socket.fire(EVENTS_UNSUBSCRIBE_VERB, { subId: sub.subId }, ack);
await vi.waitFor(() => expect(ack).toHaveBeenCalled());
expect(ack.mock.calls[0][0]).toEqual({ ok: true });
});
it('addresses deliveries at the socket that asked for them', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`, 'socket-one');
await subscribe(`fs:${documents.uid}`, 'socket-two');
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(sent.map((s) => s.socket).sort()).toEqual([
'socket-one',
'socket-two',
]);
});
it('reaps what a socket held when it disconnects', async () => {
const { documents, file } = seedTree();
const socket = fakeSocket();
service.attachSocket(socket, actorFor());
await subscribe(`fs:${documents.uid}`, socket.id);
socket.fire('disconnect');
await vi.waitFor(async () =>
expect(await store.userHasAny(userId)).toBe(false),
);
await dispatch(file);
expect(sent).toEqual([]);
});
it('clears its refresh timer on disconnect rather than leaking one per socket', async () => {
vi.useFakeTimers();
const { documents } = seedTree();
const socket = fakeSocket();
service.attachSocket(socket, actorFor());
const before = vi.getTimerCount();
await subscribe(`fs:${documents.uid}`, socket.id);
expect(vi.getTimerCount()).toBe(before + 1);
socket.fire('disconnect');
await vi.waitFor(async () =>
expect(await store.userHasAny(userId)).toBe(false),
);
expect(vi.getTimerCount()).toBe(before);
});
});
// -- The write path is never the subscriber's problem ----------------
describe('failure containment', () => {
it('swallows a store that cannot answer', async () => {
const { file } = seedTree();
vi.spyOn(store, 'userHasAny').mockRejectedValue(new Error('down'));
await expect(dispatch(file)).resolves.toBeUndefined();
expect(sent).toEqual([]);
});
it('swallows a socket that cannot be reached', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
(
service as unknown as {
services: { socket: { send: ReturnType<typeof vi.fn> } };
}
).services.socket.send.mockRejectedValue(new Error('no socket'));
await expect(dispatch(file)).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(delivered).toHaveLength(1);
});
});
it('sends only the projected shape, never an internal row', async () => {
vi.useFakeTimers();
const { documents, file } = seedTree();
await subscribe(`fs:${documents.uid}`);
await dispatch(file);
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
expect(Object.keys(sent[0].envelope).sort()).toEqual(['event', 'subId']);
expect(Object.keys(sent[0].envelope.event).sort()).toEqual([
'id',
'op',
'path',
'self',
'seq',
'subject',
'ts',
'uid',
]);
expect(sent[0].envelope.event).not.toHaveProperty('userId');
});
it('names the delivery channel the clients listen on', () => {
expect(EVENTS_DELIVERY_CHANNEL).toBe('events.delivery');
});
@@ -0,0 +1,720 @@
/*
* 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_BROADCAST_DELIVERY_LIMIT,
EVENTS_COALESCE_WINDOW_MS,
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SUBSCRIBE_LIMIT,
} 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 {
SESSION_SUBSCRIPTION_TTL_SECONDS,
type SessionSubscription,
} from '../../stores/events/EventSubscriptionStore.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { resolveNode } from '../fs/resolveNode.js';
import { PuterService } from '../types.js';
import { resolveFsAnchor, type FsAnchorDeps } from './anchors.js';
import {
assertSubscribeAuthorized,
checkSubscribeAuthorization,
} from './authorization.js';
import { DeliveryCoalescer } from './coalescer.js';
import {
FILTER_EVALUATIONS_PER_EVENT,
compileMatch,
evaluateWithCap,
relativeTo,
type CompiledMatch,
} from './matcher.js';
import {
lookupPublicSubject,
type EventContext,
type ProjectedEvent,
type PublicSubject,
} from './registry.js';
import { SubscriptionCache } from './subscriptionCache.js';
import { parseSubject, type FsOp } from './subjects.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.
* 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;
}
export interface UnsubscribeRequest {
subId?: unknown;
}
/** 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;
}
export type VerbAck<T extends object> =
| ({ ok: true } & T)
| { ok: false; error: { code: string; message: string } };
export type GapReason =
| 'matched_subscription_limit'
| 'filter_evaluation_limit'
| 'delivery_rate_limit';
/**
* `gap` says an event existed and was not delivered. It rides the delivery
* channel because a subscriber that never saw one would read the silence as
* "nothing happened", and carries no `uid`/`path` — what was dropped is exactly
* what it cannot name.
*/
export interface GapMarker {
id: string;
subject: string;
op: 'gap';
reason: GapReason;
ts: number;
}
/** One delivery, as the client receives it. */
export interface DeliveryEnvelope {
subId: string;
event: ProjectedEvent | GapMarker;
}
/** The envelope plus where it goes. The socket id is not part of the wire. */
interface AddressedDelivery {
socketId: string;
envelope: DeliveryEnvelope;
}
/** 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.
*/
ancestorUids?: () => Promise<readonly 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_DELIVERY_CHANNEL = 'events.delivery';
/** 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 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: SessionSubscription): SubscriptionView => ({
subId: sub.subId,
subject: sub.subject,
anchor: { uid: sub.anchorUid, path: sub.anchorPath },
match: sub.match,
op: sub.op,
});
/** Coalescing is per (subscription, subject), which is what the key says. */
const coalesceKey = (subId: string, subject: string): string =>
`${subId}|${subject}`;
export class EventsService extends PuterService {
readonly #cache = new SubscriptionCache();
readonly #compiled = new Map<string, CompiledMatch>();
readonly #refreshTimers = new Map<string, ReturnType<typeof setInterval>>();
#coalescer: DeliveryCoalescer<AddressedDelivery> | null = null;
// -- 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, generation } = (data ?? {}) as {
userId?: number;
generation?: number;
};
if (typeof userId !== 'number') return;
this.#cache.bump(userId, generation);
},
);
}
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.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 userId = actor.user?.id;
if (userId === undefined) throw disabled();
await this.#spendCallBudget(userId);
const rawSubject = String(request?.subject ?? '');
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.
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',
});
assertSubscribeAuthorized(
{ userId },
{ ownerUserId: entry.userId },
rawSubject,
);
// Compile now so an unusable pattern fails this call rather than every
// event under the anchor.
if (anchor.match) compileMatch(anchor.match);
const sub: SessionSubscription = {
subId: randomUUID(),
socketId,
userId,
subject: rawSubject,
token: anchor.token,
anchorUid: anchor.uid,
anchorPath: anchor.path,
match: anchor.match,
op: anchor.op,
appUid: actor.effectiveApp?.uid ?? null,
};
const generation = await this.stores.eventSubscription.add(sub);
this.#publishGeneration(userId, generation);
this.#startRefresh(userId, socketId);
return { sub: toView(sub) };
}
async unsubscribe(
actor: Actor,
socketId: string,
request: UnsubscribeRequest,
): Promise<void> {
if (!this.enabled) throw disabled();
const userId = actor.user?.id;
if (userId === undefined) throw disabled();
await this.#spendCallBudget(userId);
const subId = String(request?.subId ?? '');
if (!subId) throw unknownSubscription();
const generation = await this.stores.eventSubscription.remove(
userId,
socketId,
subId,
);
// An id this socket never held reads as absent rather than refused —
// otherwise unsubscribe reports whether someone else's id exists.
if (generation === null) throw unknownSubscription();
this.#forget(subId);
this.#publishGeneration(userId, generation);
}
/** Disconnect handler; also covers a socket the server dropped. */
async reapSocket(userId: 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(userId, socketId);
try {
const held = await this.stores.eventSubscription.listForSocket(
userId,
socketId,
);
for (const sub of held) this.#forget(sub.subId);
const generation = await this.stores.eventSubscription.reapSocket(
userId,
socketId,
);
if (generation !== null)
this.#publishGeneration(userId, generation);
} 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 userId = entry?.userId;
if (typeof userId !== 'number') return;
if (!(await this.#userHasAny(userId))) return;
const ancestorUids = options.ancestorUids
? await options.ancestorUids()
: [];
const context: EventContext = {
key,
entry,
ancestorUids,
id: randomUUID(),
ts: Date.now(),
};
const watched = await this.stores.eventSubscription.watchedTokens(
userId,
subject.tokens(context),
);
if (watched.length === 0) return;
const rows = await this.stores.eventSubscription.getForTokens(
userId,
watched,
);
if (rows.length === 0) return;
this.#route(subject, context, rows, options.actingUserId);
}
#route(
subject: PublicSubject,
context: EventContext,
rows: SessionSubscription[],
actingUserId: number | undefined,
): void {
// 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, context),
FILTER_EVALUATIONS_PER_EVENT,
);
const matched = evaluated.matched.slice(
0,
EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT,
);
let seq = 0;
for (const row of matched) {
const event = subject.project({
...context,
self: actingUserId === undefined || actingUserId === row.userId,
seq: seq++,
});
this.#coalesce().push(coalesceKey(row.subId, event.subject), {
socketId: row.socketId,
envelope: { subId: row.subId, 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: SessionSubscription,
op: FsOp,
matchOn: string,
context: EventContext,
): boolean {
if (row.op !== null && row.op !== op) return false;
if (
!checkSubscribeAuthorization(
{ userId: row.userId },
{ ownerUserId: context.entry.userId },
)
)
return false;
if (!row.match) return true;
const relative = relativeTo(row.anchorPath, matchOn);
if (relative === null) return false;
return this.#matcherFor(row).test(relative);
}
#matcherFor(row: SessionSubscription): 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: SessionSubscription[],
subject: PublicSubject,
context: EventContext,
reason: GapReason,
): void {
for (const row of rows)
this.#send({
socketId: row.socketId,
envelope: {
subId: row.subId,
event: {
id: context.id,
subject: subject.subject,
op: 'gap',
reason,
ts: context.ts,
},
},
});
}
// -- 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({
socketId: delivery.socketId,
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 the socket's own id, which socket.io joins every socket to —
* so the adapter carries it to whichever node terminates the connection.
*/
#send(delivery: AddressedDelivery): void {
try {
void this.services.socket
.send(
{ socket: delivery.socketId },
EVENTS_DELIVERY_CHANNEL,
delivery.envelope,
)
.catch((err: unknown) => {
console.warn('[events] socket send failed', err);
});
} catch (err) {
console.warn('[events] socket send 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`. The generation is captured before the read, so a
* subscribe that lands mid-flight is not cached over.
*/
async #userHasAny(userId: number): Promise<boolean> {
const cached = this.#cache.read(userId);
if (cached !== null) return cached;
const generation = this.#cache.generationOf(userId);
try {
const hasAny =
await this.stores.eventSubscription.userHasAny(userId);
this.#cache.write(userId, generation, 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;
}
}
#publishGeneration(userId: number, generation: number): void {
this.#cache.bump(userId, generation);
try {
this.clients.event.emit(
'outer.events.generationBumped',
{ userId, generation },
{},
);
} catch {
// A peer that misses the bump rebuilds on its own next miss.
}
}
// -- Plumbing ----------------------------------------------------
#forget(subId: string): void {
this.#compiled.delete(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),
};
}
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(userId: number, socketId: string): void {
const key = `${userId}|${socketId}`;
if (this.#refreshTimers.has(key)) return;
const timer = setInterval(
() => {
void this.stores.eventSubscription
.refresh(userId, socketId)
.catch(() => {});
},
Math.floor((SESSION_SUBSCRIPTION_TTL_SECONDS * 1000) / 3),
);
timer.unref?.();
this.#refreshTimers.set(key, timer);
}
#stopRefresh(userId: number, socketId: string): void {
const key = `${userId}|${socketId}`;
const timer = this.#refreshTimers.get(key);
if (!timer) return;
clearInterval(timer);
this.#refreshTimers.delete(key);
}
}
@@ -0,0 +1,61 @@
/*
* 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 { HttpError } from '../../core/http/HttpError.js';
/**
* Who may subscribe to an anchor, and who may still be delivered from it.
*
* Owner-only today. The real rule is an ACL check for `list` on the anchor,
* with the grant it succeeded under stored on the subscription and re-checked
* at delivery; both call sites below are shaped for that and neither has to
* move when it lands. What must not change is the failure: a node the caller
* cannot reach is answered as absent, because a distinguishable "forbidden"
* turns subscribe into a way to ask whether a path exists.
*/
export interface AnchorOwnership {
/** The user the anchor node belongs to. */
ownerUserId: number;
}
export interface SubscribingActor {
userId: number;
}
const subjectDoesNotExist = (subject: string): HttpError =>
new HttpError(404, `No such entry: ${subject}`, {
legacyCode: 'subject_does_not_exist',
});
/** Whether this actor may hold a subscription on this anchor. */
export const checkSubscribeAuthorization = (
actor: SubscribingActor,
anchor: AnchorOwnership,
): boolean => actor.userId === anchor.ownerUserId;
/** The same decision, as the subscribe path needs it: pass, or 404. */
export const assertSubscribeAuthorized = (
actor: SubscribingActor,
anchor: AnchorOwnership,
subject: string,
): void => {
if (!checkSubscribeAuthorization(actor, anchor))
throw subjectDoesNotExist(subject);
};
@@ -0,0 +1,110 @@
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
import { DeliveryCoalescer } from './coalescer.js';
let released: Array<[string, string]>;
let coalescer: DeliveryCoalescer<string>;
beforeEach(() => {
vi.useFakeTimers();
released = [];
coalescer = new DeliveryCoalescer<string>(
EVENTS_COALESCE_WINDOW_MS,
(key, payload) => released.push([key, payload]),
);
});
afterEach(() => {
vi.useRealTimers();
});
it('turns a burst on one subject into one release', () => {
for (let i = 0; i < 20; i++) coalescer.push('sub|fs:a:write', `v${i}`);
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
expect(released).toEqual([['sub|fs:a:write', 'v19']]);
});
it('keeps distinct subjects apart', () => {
coalescer.push('sub|fs:a:write', 'a');
coalescer.push('sub|fs:b:write', 'b');
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
expect(released).toEqual([
['sub|fs:a:write', 'a'],
['sub|fs:b:write', 'b'],
]);
});
it('keeps two subscriptions on one subject apart', () => {
coalescer.push('one|fs:a:write', 'x');
coalescer.push('two|fs:a:write', 'x');
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
expect(released).toHaveLength(2);
});
it('holds nothing back before the window is up', () => {
coalescer.push('sub|fs:a:write', 'a');
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS - 1);
expect(released).toEqual([]);
vi.advanceTimersByTime(1);
expect(released).toHaveLength(1);
});
it('does not let a sustained writer hold the window open forever', () => {
for (let tick = 0; tick < 10; tick++) {
coalescer.push('sub|fs:a:write', `v${tick}`);
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS / 2);
}
expect(released.length).toBeGreaterThanOrEqual(4);
});
it('opens a fresh window after one closes', () => {
coalescer.push('sub|fs:a:write', 'first');
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
coalescer.push('sub|fs:a:write', 'second');
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
expect(released.map(([, payload]) => payload)).toEqual([
'first',
'second',
]);
});
it('drops what a cancelled subscription had queued', () => {
coalescer.push('gone|fs:a:write', 'a');
coalescer.push('stays|fs:a:write', 'b');
coalescer.cancel((key) => key.startsWith('gone|'));
vi.advanceTimersByTime(EVENTS_COALESCE_WINDOW_MS);
expect(released).toEqual([['stays|fs:a:write', 'b']]);
expect(coalescer.pendingCount).toBe(0);
});
+84
View File
@@ -0,0 +1,84 @@
/*
* 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/>.
*/
/**
* One delivery per (subscription, subject) per window.
*
* A multipart upload, a save loop and a recursive delete are each one thing a
* user did that reaches the write path many times; a subscriber wants to hear
* about it once. The window starts at the first event rather than restarting on
* each one — a sustained writer would otherwise hold the timer open
* indefinitely and the subscriber would hear nothing at all. Later events in
* the window replace the payload, so what arrives is the newest state.
*
* Filtering runs before this, so a filtered-out event never opens a window;
* whatever comes out the far side is a delivery, which is what makes this the
* right place to count one.
*/
type Timer = ReturnType<typeof setTimeout>;
interface Pending<T> {
payload: T;
timer: Timer;
}
export class DeliveryCoalescer<T> {
readonly #pending = new Map<string, Pending<T>>();
readonly #windowMs: number;
readonly #flush: (key: string, payload: T) => void;
constructor(windowMs: number, flush: (key: string, payload: T) => void) {
this.#windowMs = windowMs;
this.#flush = flush;
}
get pendingCount(): number {
return this.#pending.size;
}
/** Queue an event, opening a window if this key does not already have one. */
push(key: string, payload: T): void {
const existing = this.#pending.get(key);
if (existing) {
existing.payload = payload;
return;
}
const timer = setTimeout(() => this.#release(key), this.#windowMs);
timer.unref?.();
this.#pending.set(key, { payload, timer });
}
/** Drop everything queued, without delivering — used when a socket goes. */
cancel(predicate: (key: string) => boolean): void {
for (const [key, pending] of this.#pending) {
if (!predicate(key)) continue;
clearTimeout(pending.timer);
this.#pending.delete(key);
}
}
#release(key: string): void {
const pending = this.#pending.get(key);
if (!pending) return;
this.#pending.delete(key);
this.#flush(key, pending.payload);
}
}
@@ -0,0 +1,216 @@
/*
* 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/>.
*/
/**
* The hook against the real write path. The unit tests drive `dispatchFs`
* directly; this pins the two things that only the wiring can get wrong —
* whether the emit sites reach it at all, and whether a dispatcher that blows
* up can take a user's write down with it.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import type { DeliveryEnvelope } from './EventsService.js';
import { EVENTS_COALESCE_WINDOW_MS } from '../../controllers/events/limits.js';
const BOOT_TIMEOUT_MS = 120_000;
const SOCKET_ID = 'integration-socket';
let env: PuterTestEnv;
let actor: Actor;
let userId: number;
let username: string;
let delivered: DeliveryEnvelope[];
const events = () => env.server.services.events;
const fs = () => env.server.services.fs;
const settle = () =>
vi.waitFor(() => expect(delivered.length).toBeGreaterThan(0), {
timeout: EVENTS_COALESCE_WINDOW_MS * 8,
interval: 25,
});
beforeAll(async () => {
env = await setupPuterTestEnv({
events: { enabled: true },
} as IConfig);
username = env.users.user.username;
const user = await env.server.stores.user.getByUsername(username);
userId = user!.id;
actor = makeActor({ user: user as never });
delivered = [];
events().onDelivered = (envelope) => delivered.push(envelope);
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
await env?.shutdown();
});
const subscribeTo = async (subject: string) => {
const { sub } = await events().subscribe(actor, SOCKET_ID, { subject });
return sub;
};
describe('the write path reaches subscribers', () => {
it('delivers a create under a watched folder', async () => {
const folder = `/${username}/watch-create`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
const sub = await subscribeTo(`fs:${folder}`);
delivered.length = 0;
await fs().touch(userId, { path: `${folder}/made.txt` });
await settle();
expect(delivered).toHaveLength(1);
expect(delivered[0].subId).toBe(sub.subId);
expect(delivered[0].event).toMatchObject({
op: 'add',
path: `${folder}/made.txt`,
});
});
it('delivers a rename and a remove on the same subscription', async () => {
const folder = `/${username}/watch-lifecycle`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
await subscribeTo(`fs:${folder}`);
const file = await fs().touch(userId, { path: `${folder}/before.txt` });
await settle();
delivered.length = 0;
const renamed = await fs().rename(userId, file, 'after.txt');
await settle();
expect(delivered.map((d) => d.event.op)).toContain('move');
delivered.length = 0;
await fs().remove(userId, { entry: renamed });
await settle();
expect(delivered.map((d) => d.event.op)).toContain('remove');
});
it('addresses the delivery at the socket that subscribed', async () => {
const folder = `/${username}/watch-socket`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
await subscribeTo(`fs:${folder}`);
const send = vi.spyOn(env.server.services.socket, 'send');
delivered.length = 0;
await fs().touch(userId, { path: `${folder}/addressed.txt` });
await settle();
expect(send).toHaveBeenCalledWith(
{ socket: SOCKET_ID },
'events.delivery',
expect.objectContaining({ subId: expect.any(String) }),
);
send.mockRestore();
});
it('leaves an unwatched folder alone', async () => {
const folder = `/${username}/watch-nothing`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
delivered.length = 0;
await fs().touch(userId, { path: `${folder}/ignored.txt` });
await new Promise((resolve) =>
setTimeout(resolve, EVENTS_COALESCE_WINDOW_MS * 2),
);
expect(delivered).toEqual([]);
});
});
describe('the writer never pays for the subscriber', () => {
it('completes the write when the dispatcher throws', async () => {
const folder = `/${username}/watch-throws`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
const dispatch = vi
.spyOn(events(), 'dispatchFs')
.mockImplementation(() => {
throw new Error('dispatcher is down');
});
try {
const created = await fs().touch(userId, {
path: `${folder}/still-written.txt`,
});
expect(created.path).toBe(`${folder}/still-written.txt`);
await expect(
env.server.stores.fsEntry.getEntryByPath(created.path),
).resolves.toMatchObject({ uuid: created.uuid });
} finally {
dispatch.mockRestore();
}
});
it('completes the write when the dispatcher rejects', async () => {
const folder = `/${username}/watch-rejects`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
const dispatch = vi
.spyOn(events(), 'dispatchFs')
.mockRejectedValue(new Error('dispatcher is down'));
try {
await expect(
fs().touch(userId, { path: `${folder}/also-written.txt` }),
).resolves.toMatchObject({
path: `${folder}/also-written.txt`,
});
} finally {
dispatch.mockRestore();
}
});
});
describe('unsubscribing and disconnecting', () => {
it('stops delivering once the subscription is gone', async () => {
const folder = `/${username}/watch-unsubscribe`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
const sub = await subscribeTo(`fs:${folder}`);
await events().unsubscribe(actor, SOCKET_ID, { subId: sub.subId });
delivered.length = 0;
await fs().touch(userId, { path: `${folder}/after.txt` });
await new Promise((resolve) =>
setTimeout(resolve, EVENTS_COALESCE_WINDOW_MS * 2),
);
expect(delivered).toEqual([]);
});
it('leaves no watched token behind when the socket goes', async () => {
const folder = `/${username}/watch-disconnect`;
await fs().mkdir(userId, { path: folder, createMissingParents: true });
const anchor = await env.server.stores.fsEntry.getEntryByPath(folder);
await events().subscribe(actor, 'doomed-socket', {
subject: `fs:${folder}`,
});
await events().reapSocket(userId, 'doomed-socket');
await expect(
env.server.stores.eventSubscription.watchedTokens(userId, [
`f#${anchor!.uid}`,
]),
).resolves.toEqual([]);
});
});
@@ -0,0 +1,106 @@
/*
* 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 { describe, expect, it } from 'vitest';
import { SubscriptionCache } from './subscriptionCache.js';
describe('answers', () => {
it('says nothing until it has been told something', () => {
expect(new SubscriptionCache().read(1)).toBeNull();
});
it('holds an answer written under the current generation', () => {
const cache = new SubscriptionCache();
cache.write(1, cache.generationOf(1), false);
expect(cache.read(1)).toBe(false);
});
});
describe('invalidation', () => {
it('drops the answer on a bump', () => {
const cache = new SubscriptionCache();
cache.write(1, 0, false);
cache.bump(1, 1);
expect(cache.read(1)).toBeNull();
});
it('leaves other users alone', () => {
const cache = new SubscriptionCache();
cache.write(1, 0, true);
cache.write(2, 0, false);
cache.bump(1, 1);
expect(cache.read(2)).toBe(false);
});
it('discards a read that a bump overtook while it was in flight', () => {
const cache = new SubscriptionCache();
const generation = cache.generationOf(1);
cache.bump(1, 1);
cache.write(1, generation, false);
expect(cache.read(1)).toBeNull();
});
it('cannot be walked backwards by a bump that arrives late', () => {
const cache = new SubscriptionCache();
cache.bump(1, 5);
cache.bump(1, 2);
cache.write(1, 5, true);
expect(cache.read(1)).toBe(true);
});
it('still advances when a bump names no generation', () => {
const cache = new SubscriptionCache();
cache.write(1, 0, true);
cache.bump(1);
expect(cache.read(1)).toBeNull();
expect(cache.generationOf(1)).toBe(1);
});
});
describe('bounds', () => {
it('never grows past its limit', () => {
const cache = new SubscriptionCache(3);
for (let userId = 1; userId <= 50; userId++)
cache.write(userId, 0, true);
expect(cache.size).toBe(3);
});
it('evicts the least recently read, not the least recently written', () => {
const cache = new SubscriptionCache(2);
cache.write(1, 0, true);
cache.write(2, 0, true);
cache.read(1);
cache.write(3, 0, true);
expect(cache.read(1)).toBe(true);
expect(cache.read(2)).toBeNull();
});
});
@@ -0,0 +1,111 @@
/*
* 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/>.
*/
/**
* The "does this user have any subscriptions at all" answer, per process.
*
* Nearly every user has none, and this is what lets a write on their behalf
* cost nothing: after the first miss the answer is in memory and dispatch never
* touches Redis again. That only holds if invalidation is pushed rather than
* polled, so entries are keyed by a per-user generation the subscribe and
* unsubscribe paths bump and broadcast — never by a timer, which would put the
* round trip back on the hot path at whatever rate the timer expired.
*
* Bounded, because one process sees an unbounded number of users over its
* lifetime and the useful entries are the ones being written to right now.
* Eviction is least-recently-used: a `Map` iterates in insertion order, so
* re-inserting on read is what moves an entry to the young end.
*/
interface CacheEntry {
generation: number;
/** `null` while unknown — a bump leaves the generation and clears this. */
hasAny: boolean | null;
}
export const SUBSCRIPTION_CACHE_MAX_USERS = 10_000;
export class SubscriptionCache {
readonly #entries = new Map<number, CacheEntry>();
readonly #maxUsers: number;
constructor(maxUsers: number = SUBSCRIPTION_CACHE_MAX_USERS) {
this.#maxUsers = Math.max(1, maxUsers);
}
get size(): number {
return this.#entries.size;
}
/** The generation this process believes the user is on. */
generationOf(userId: number): number {
return this.#entries.get(userId)?.generation ?? 0;
}
/** The cached answer, or `null` when this process has to go and look. */
read(userId: number): boolean | null {
const entry = this.#entries.get(userId);
if (!entry) return null;
// Touch on a hit so the hot users are the ones that survive eviction.
this.#entries.delete(userId);
this.#entries.set(userId, entry);
return entry.hasAny;
}
/**
* Record an answer against the generation it was read under. A bump that
* landed while the read was in flight leaves the generations mismatched,
* and the answer is dropped rather than cached stale.
*/
write(userId: number, generation: number, hasAny: boolean): void {
const entry = this.#entries.get(userId);
if (entry && entry.generation !== generation) return;
this.#set(userId, { generation, hasAny });
}
/**
* Invalidate a user, moving them to `generation` when it is ahead of what
* this process has. Two bumps can arrive out of order — the counter is what
* orders them, so the later one cannot be undone by the earlier.
*/
bump(userId: number, generation?: number): void {
const current = this.#entries.get(userId)?.generation ?? 0;
if (generation === undefined) {
this.#set(userId, { generation: current + 1, hasAny: null });
return;
}
// Already applied, or superseded by one that arrived first.
if (generation <= current) return;
this.#set(userId, { generation, hasAny: null });
}
clear(): void {
this.#entries.clear();
}
#set(userId: number, entry: CacheEntry): void {
this.#entries.delete(userId);
this.#entries.set(userId, entry);
while (this.#entries.size > this.#maxUsers) {
const oldest = this.#entries.keys().next();
if (oldest.done) break;
this.#entries.delete(oldest.value);
}
}
}
+38 -3
View File
@@ -22,6 +22,7 @@ import { posix as pathPosix } from 'node:path';
import type { TransformCallback } from 'node:stream';
import { pipeline, Readable, Transform } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import type { EventKey, EventMap } from '../../clients/event/types.js';
import {
BinaryPayload,
CompleteWriteRequest,
@@ -3767,6 +3768,36 @@ export class FSService extends PuterService {
} catch {
// Non-critical.
}
this.#dispatchEvents('fs.remove.node', entry);
}
/**
* Publish a committed change to whoever subscribed to it.
*
* Post-commit and fire-and-forget: the write already happened, so nothing
* here may fail it or slow it down. The ancestor walk is a thunk because
* almost every write belongs to a user with no subscriptions, and that user
* must not pay for the walk to find out.
*/
#dispatchEvents(key: EventKey, entry: FSEntry): void {
const events = this.services.events;
if (!events?.enabled) return;
try {
void events
.dispatchFs(key, entry, {
actingUserId: (Context.get('actor') as Actor | undefined)
?.user?.id,
ancestorUids: async () =>
(await this.getAncestorChain(entry.path)).map(
(ancestor) => ancestor.uid,
),
})
.catch((err: unknown) => {
console.warn('[fs] event dispatch failed', err);
});
} catch (err) {
console.warn('[fs] event dispatch failed', err);
}
}
/**
@@ -3783,25 +3814,28 @@ export class FSService extends PuterService {
* issue time) and per-flavor `fs.move.file` (move already emits
* `fs.move.node`).
*/
#emitFsEvent(
name: string,
#emitFsEvent<T extends EventKey>(
name: T,
entry: FSEntry,
extras: Record<string, unknown> = {},
): void {
try {
this.clients.event.emit(
name,
// The aliases are what existing handlers destructure; the cast
// is what lets one helper serve every key that carries them.
{
node: entry,
entry,
uid: entry.uuid,
...extras,
},
} as EventMap[T],
{},
);
} catch {
console.warn('missing event emissions');
}
this.#dispatchEvents(name, entry);
}
/**
@@ -3941,6 +3975,7 @@ export class FSService extends PuterService {
} catch {
// ignore — non-critical.
}
this.#dispatchEvents('fs.move.node', updated);
return updated;
}
+5
View File
@@ -28,6 +28,7 @@ import { OIDCService } from './auth/OIDCService';
import { TokenService } from './auth/TokenService';
import { BroadcastService } from './broadcast/BroadcastService';
import { CacheReplicationService } from './cache/CacheReplicationService';
import { EventsService } from './events/EventsService';
import { AppFeedbackService } from './feedback/AppFeedbackService';
import { FSService } from './fs/FSService';
import { ServerHealthService } from './health/ServerHealthService';
@@ -67,6 +68,7 @@ declare module './types' {
recommendedApps: RecommendedAppsService;
suggestedApps: SuggestedAppsService;
socket: SocketService;
events: EventsService;
notification: NotificationService;
appFeedback: AppFeedbackService;
broadcast: BroadcastService;
@@ -117,6 +119,9 @@ export const puterServices = {
recommendedApps: RecommendedAppsService,
suggestedApps: SuggestedAppsService,
socket: SocketService,
// Delivers through `socket` and resolves paths through `fs`, so it follows
// both; `fs` reaches back for dispatch at call time only.
events: EventsService,
notification: NotificationService,
// Declared after `auth` (origin → app uid resolution happens through
// AuthService.appUidFromOrigin).
@@ -632,6 +632,11 @@ export class SocketService extends PuterService {
// connection gives its slots back the same way a closed one does.
void this.#admitConnection(socket, actor, userId);
// Subscription verbs and the disconnect reaping that goes with
// them. Off unless events are enabled, in which case the verbs
// answer with `events_disabled` rather than going unanswered.
this.services.events.attachSocket(socket, actor);
// Peer-echo: one tab notifies others that trash is empty.
socket.on('trash.is_empty', (msg: unknown) => {
void this.#allowSocketEvent(userId, 'trash.is_empty').then(
@@ -0,0 +1,222 @@
/*
* 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 MockRedis from 'ioredis-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET } from '../../controllers/events/limits.js';
import { isHttpError } from '../../core/http/HttpError.js';
import type { IConfig } from '../../types.js';
import {
EventSubscriptionStore,
SESSION_SUBSCRIPTION_TTL_SECONDS,
type SessionSubscription,
} from './EventSubscriptionStore.js';
// ioredis-mock keeps one keyspace per process, so each test gets its own user
// rather than trying to reset a shared one between them.
let userSeq = 0;
let USER = 0;
let redis: InstanceType<typeof MockRedis.Cluster>;
let store: EventSubscriptionStore;
const makeSub = (
over: Partial<SessionSubscription> = {},
): SessionSubscription => ({
subId: over.subId ?? `sub-${Math.random().toString(36).slice(2)}`,
socketId: 'socket-a',
userId: USER,
subject: 'fs:/testuser/Documents',
token: 'f#anchor',
anchorUid: 'anchor',
anchorPath: '/testuser/Documents',
match: null,
op: null,
appUid: null,
...over,
});
beforeEach(() => {
USER = ++userSeq;
redis = new MockRedis.Cluster(['redis://localhost:7001']);
store = new EventSubscriptionStore(
{} as IConfig,
{ redis } as never,
{} as never,
);
});
describe('registration', () => {
it('makes the anchor watched and readable by its token', async () => {
const sub = makeSub();
await store.add(sub);
await expect(store.userHasAny(USER)).resolves.toBe(true);
await expect(
store.watchedTokens(USER, ['f#anchor', 'f#elsewhere']),
).resolves.toEqual(['f#anchor']);
await expect(store.getForTokens(USER, ['f#anchor'])).resolves.toEqual([
sub,
]);
});
it('keys every one of a user`s keys into one cluster slot', async () => {
await store.add(makeSub());
const keys = await redis.keys(`ev:*{${USER}}*`);
expect(keys.length).toBeGreaterThan(1);
for (const key of keys) expect(key).toContain(`{${USER}}`);
});
it('leaves nothing without an expiry to collect it', async () => {
await store.add(makeSub());
for (const key of await redis.keys(`ev:*{${USER}}*`))
expect(await redis.ttl(key)).toBeGreaterThan(0);
});
it('refreshes a live socket ahead of the backstop', async () => {
await store.add(makeSub());
await redis.expire(`ev:w:{${USER}}`, 5);
await store.refresh(USER, 'socket-a');
expect(await redis.ttl(`ev:w:{${USER}}`)).toBeGreaterThan(
SESSION_SUBSCRIPTION_TTL_SECONDS - 10,
);
});
});
describe('the per-socket cap', () => {
it('rejects the one past the limit with a stable code', async () => {
for (let i = 0; i < EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET; i++)
await store.add(makeSub({ subId: `sub-${i}`, token: `f#n${i}` }));
const overflow = store.add(makeSub({ subId: 'one-too-many' }));
await expect(overflow).rejects.toSatisfy(
(err: unknown) =>
isHttpError(err) &&
err.statusCode === 429 &&
err.legacyCode === 'events_subscription_limit',
);
});
it('counts per socket, not per user', async () => {
for (let i = 0; i < EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET; i++)
await store.add(makeSub({ subId: `a-${i}`, token: `f#n${i}` }));
await expect(
store.add(makeSub({ subId: 'b-0', socketId: 'socket-b' })),
).resolves.toBeGreaterThan(0);
});
});
describe('removal', () => {
it('stops the token being watched once its last row goes', async () => {
const sub = makeSub();
await store.add(sub);
await store.remove(USER, sub.socketId, sub.subId);
await expect(store.watchedTokens(USER, ['f#anchor'])).resolves.toEqual(
[],
);
await expect(store.userHasAny(USER)).resolves.toBe(false);
});
it('keeps a token watched while another socket still holds it', async () => {
const mine = makeSub({ subId: 'mine', socketId: 'socket-a' });
const theirs = makeSub({ subId: 'theirs', socketId: 'socket-b' });
await store.add(mine);
await store.add(theirs);
await store.remove(USER, 'socket-a', 'mine');
await expect(store.watchedTokens(USER, ['f#anchor'])).resolves.toEqual([
'f#anchor',
]);
await expect(store.getForTokens(USER, ['f#anchor'])).resolves.toEqual([
theirs,
]);
});
it('reports an id this socket never held as absent', async () => {
await store.add(makeSub({ subId: 'mine', socketId: 'socket-a' }));
await expect(
store.remove(USER, 'socket-b', 'mine'),
).resolves.toBeNull();
});
});
describe('disconnect', () => {
it('leaves no key behind for the socket that went', async () => {
await store.add(makeSub({ subId: 'a', token: 'f#one' }));
await store.add(makeSub({ subId: 'b', token: 'f#two' }));
await store.reapSocket(USER, 'socket-a');
await expect(store.userHasAny(USER)).resolves.toBe(false);
await expect(
store.watchedTokens(USER, ['f#one', 'f#two']),
).resolves.toEqual([]);
expect(await redis.smembers(`ev:s:{${USER}}:socket-a`)).toEqual([]);
});
it('leaves another socket`s subscriptions alone', async () => {
await store.add(makeSub({ subId: 'a', socketId: 'socket-a' }));
const survivor = makeSub({
subId: 'b',
socketId: 'socket-b',
token: 'f#other',
});
await store.add(survivor);
await store.reapSocket(USER, 'socket-a');
await expect(store.listForSocket(USER, 'socket-b')).resolves.toEqual([
survivor,
]);
});
it('says nothing changed when the socket held nothing', async () => {
await expect(store.reapSocket(USER, 'socket-z')).resolves.toBeNull();
});
});
describe('the generation counter', () => {
it('advances on every registration and removal', async () => {
const first = await store.add(makeSub({ subId: 'a' }));
const second = await store.add(makeSub({ subId: 'b', token: 'f#two' }));
expect(second).toBeGreaterThan(first);
const third = await store.remove(USER, 'socket-a', 'a');
expect(third).toBeGreaterThan(second);
await expect(store.getGeneration(USER)).resolves.toBe(third);
});
it('outlives the subscriptions it orders', async () => {
await store.add(makeSub());
await store.reapSocket(USER, 'socket-a');
expect(await redis.ttl(`ev:g:{${USER}}`)).toBeGreaterThan(
SESSION_SUBSCRIPTION_TTL_SECONDS,
);
});
});
@@ -0,0 +1,309 @@
/*
* 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 { EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET } from '../../controllers/events/limits.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { FsOp } from '../../services/events/subjects.js';
import { PuterStore } from '../types.js';
/**
* Session subscriptions: Redis only, keyed to the socket that holds them, gone
* when it disconnects. Nothing here outlives a connection, so none of it
* belongs in a table.
*
* Every key a user owns carries the same `{<userId>}` hash tag, so one user's
* whole set lives in one cluster slot and a pipeline over it never crosses
* slots. Four keys, each answering one question:
*
* ev:w:{<userId>} SET which tokens anyone is watching
* ev:t:{<userId>}:<token> HASH subId -> row, for one watched token
* ev:s:{<userId>}:<socketId> SET what this socket holds, for reaping
* ev:g:{<userId>} STR subscription-set generation
*
* `ev:w` is what dispatch asks first and is the only one on the hot path.
* Membership in it is exact rather than approximate: a token's row hash going
* empty is what removes it, so an unsubscribe really does stop the lookup.
*
* Everything carries a TTL. A socket process that dies without running its
* disconnect handler leaves keys behind, and the TTL is what collects them a
* live socket refreshes its own, so the backstop only ever fires on rows whose
* socket is gone.
*/
// -- Types ------------------------------------------------------------
export interface SessionSubscription {
subId: string;
socketId: string;
userId: number;
/** The subject as the client asked for it. */
subject: string;
/** Anchor token the row is indexed under. */
token: string;
anchorUid: string;
anchorPath: string;
/** Glob relative to the anchor, or `null` for a node-form subscription. */
match: string | null;
op: FsOp | null;
appUid: string | null;
}
// -- Keys -------------------------------------------------------------
const watchedKey = (userId: number | string): string => `ev:w:{${userId}}`;
const tokenKey = (userId: number | string, token: string): string =>
`ev:t:{${userId}}:${token}`;
const socketKey = (userId: number | string, socketId: string): string =>
`ev:s:{${userId}}:${socketId}`;
const generationKey = (userId: number | string): string => `ev:g:{${userId}}`;
/** `ev:s` members name the row they point at. */
const socketRef = (token: string, subId: string): string => `${token}|${subId}`;
const parseSocketRef = (ref: string): { token: string; subId: string } => {
const at = ref.indexOf('|');
return { token: ref.slice(0, at), subId: ref.slice(at + 1) };
};
// -- Lifetimes --------------------------------------------------------
/**
* How long a session key survives without its socket. Long enough that a
* refresh can be missed a few times over, short enough that a dead node's rows
* are gone well before anyone notices them.
*/
export const SESSION_SUBSCRIPTION_TTL_SECONDS = 60 * 60;
/**
* The generation counter outlives the subscriptions it orders a bump that
* expired and restarted at zero would let a stale cached answer look current
* again.
*/
const GENERATION_TTL_SECONDS = 24 * 60 * 60;
const subscriptionLimitReached = (): HttpError =>
new HttpError(
429,
`A connection may hold ${EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET} subscriptions`,
{ legacyCode: 'events_subscription_limit' },
);
export class EventSubscriptionStore extends PuterStore {
// -- Writes ------------------------------------------------------
/**
* Register one subscription. Returns the generation the write produced, so
* the caller can broadcast it.
*
* Ordering is deliberate: the row lands before the token joins the watched
* set, so dispatch never sees a token whose rows it cannot read yet.
*/
async add(sub: SessionSubscription): Promise<number> {
const { userId, socketId, token, subId } = sub;
const held = await this.clients.redis.scard(
socketKey(userId, socketId),
);
if (held >= EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET)
throw subscriptionLimitReached();
const pipeline = this.clients.redis.pipeline();
pipeline.hset(tokenKey(userId, token), subId, JSON.stringify(sub));
pipeline.expire(
tokenKey(userId, token),
SESSION_SUBSCRIPTION_TTL_SECONDS,
);
pipeline.sadd(socketKey(userId, socketId), socketRef(token, subId));
pipeline.expire(
socketKey(userId, socketId),
SESSION_SUBSCRIPTION_TTL_SECONDS,
);
pipeline.sadd(watchedKey(userId), token);
pipeline.expire(watchedKey(userId), SESSION_SUBSCRIPTION_TTL_SECONDS);
await pipeline.exec();
return this.bumpGeneration(userId);
}
/**
* Drop one subscription. Returns the new generation, or `null` when the
* subscription was not this socket's to remove the caller answers that as
* a 404 rather than telling a client which ids exist.
*/
async remove(
userId: number,
socketId: string,
subId: string,
): Promise<number | null> {
const refs = await this.clients.redis.smembers(
socketKey(userId, socketId),
);
const ref = refs.find((r) => parseSocketRef(r).subId === subId);
if (!ref) return null;
await this.#dropRefs(userId, socketId, [ref]);
return this.bumpGeneration(userId);
}
/**
* Drop everything a socket held. Runs on disconnect; the TTL is what covers
* the disconnect that never runs.
*/
async reapSocket(userId: number, socketId: string): Promise<number | null> {
const refs = await this.clients.redis.smembers(
socketKey(userId, socketId),
);
if (refs.length === 0) return null;
await this.#dropRefs(userId, socketId, refs);
return this.bumpGeneration(userId);
}
/**
* Remove rows and then any token whose rows are all gone. The token leaves
* the watched set only once its hash is empty, which is what keeps one
* socket's unsubscribe from silencing another's subscription on the same
* anchor.
*/
async #dropRefs(
userId: number,
socketId: string,
refs: string[],
): Promise<void> {
const parsed = refs.map(parseSocketRef);
const drop = this.clients.redis.pipeline();
for (const { token, subId } of parsed)
drop.hdel(tokenKey(userId, token), subId);
drop.srem(socketKey(userId, socketId), ...refs);
await drop.exec();
const tokens = [...new Set(parsed.map((p) => p.token))];
const counts = this.clients.redis.pipeline();
for (const token of tokens) counts.hlen(tokenKey(userId, token));
const results = (await counts.exec()) ?? [];
const orphaned = tokens.filter(
(_token, i) => Number(results[i]?.[1] ?? 0) === 0,
);
if (orphaned.length > 0)
await this.clients.redis.srem(watchedKey(userId), ...orphaned);
}
/** Keep a live socket's keys ahead of the TTL backstop. */
async refresh(userId: number, socketId: string): Promise<void> {
const pipeline = this.clients.redis.pipeline();
pipeline.expire(
socketKey(userId, socketId),
SESSION_SUBSCRIPTION_TTL_SECONDS,
);
pipeline.expire(watchedKey(userId), SESSION_SUBSCRIPTION_TTL_SECONDS);
await pipeline.exec();
}
// -- Reads -------------------------------------------------------
/**
* Whether this user has any subscriptions at all. One command, and the only
* thing a cold process needs before it can answer from memory.
*/
async userHasAny(userId: number): Promise<boolean> {
return (await this.clients.redis.exists(watchedKey(userId))) === 1;
}
/**
* Which of an event's tokens anyone is watching the dispatch hot path,
* and one command whatever the depth of the tree.
*/
async watchedTokens(
userId: number,
tokens: readonly string[],
): Promise<string[]> {
if (tokens.length === 0) return [];
const flags = await this.clients.redis.smismember(
watchedKey(userId),
...tokens,
);
return tokens.filter((_token, i) => Number(flags[i]) === 1);
}
/** The rows behind a set of watched tokens. */
async getForTokens(
userId: number,
tokens: readonly string[],
): Promise<SessionSubscription[]> {
if (tokens.length === 0) return [];
const pipeline = this.clients.redis.pipeline();
for (const token of tokens) pipeline.hvals(tokenKey(userId, token));
const results = (await pipeline.exec()) ?? [];
const subs: SessionSubscription[] = [];
for (const [, raw] of results) {
for (const row of (raw as string[] | null) ?? []) {
try {
subs.push(JSON.parse(row) as SessionSubscription);
} catch {
// A row we cannot read is a row we cannot deliver against.
}
}
}
return subs;
}
/** Everything one socket holds, newest first is not meaningful here. */
async listForSocket(
userId: number,
socketId: string,
): Promise<SessionSubscription[]> {
const refs = await this.clients.redis.smembers(
socketKey(userId, socketId),
);
if (refs.length === 0) return [];
const byToken = new Map<string, Set<string>>();
for (const ref of refs) {
const { token, subId } = parseSocketRef(ref);
const ids = byToken.get(token) ?? new Set<string>();
ids.add(subId);
byToken.set(token, ids);
}
const tokens = [...byToken.keys()];
const rows = await this.getForTokens(userId, tokens);
return rows.filter((row) => byToken.get(row.token)?.has(row.subId));
}
// -- Generation --------------------------------------------------
/**
* Advance the user's subscription-set generation. A single-key `INCR`, so
* it is cluster-safe and costs one command; the broadcast that carries it
* is what actually invalidates other processes.
*/
async bumpGeneration(userId: number | string): Promise<number> {
const key = generationKey(userId);
const next = await this.clients.redis.incr(key);
await this.clients.redis.expire(key, GENERATION_TTL_SECONDS);
return typeof next === 'number' ? next : 0;
}
async getGeneration(userId: number | string): Promise<number> {
const raw = await this.clients.redis.get(generationKey(userId));
const n = raw === null ? 0 : Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
}
+4
View File
@@ -21,6 +21,7 @@ import { AppFeedbackStore } from './appFeedback/AppFeedbackStore.js';
import { AppStore } from './app/AppStore.js';
import { FSEntryStore } from './fs/FSEntryStore.js';
import { GroupStore } from './group/GroupStore.js';
import { EventSubscriptionStore } from './events/EventSubscriptionStore.js';
import { CreditHoldStore } from './metering/CreditHoldStore.js';
import { MeteringBufferStore } from './metering/MeteringBufferStore.js';
import { NotificationStore } from './notification/NotificationStore.js';
@@ -60,6 +61,7 @@ declare module './types.js' {
session: SessionStore;
oidc: OIDCStore;
userBlock: UserBlockStore;
eventSubscription: EventSubscriptionStore;
}
}
@@ -89,4 +91,6 @@ export const puterStores = {
session: SessionStore,
oidc: OIDCStore,
userBlock: UserBlockStore,
// Redis only, no peer stores.
eventSubscription: EventSubscriptionStore,
} satisfies IPuterStoreRegistry;
+12
View File
@@ -1117,6 +1117,18 @@ interface IConfigOptional {
tiers?: Record<string, number>;
};
/**
* Subscribable events over filesystem and key-value changes.
*
* - `enabled` the master switch for the whole surface. Absent means off:
* the dispatch hooks short-circuit on a boolean before resolving
* anything, and the subscribe verbs reject with `events_disabled`. An
* install that has never heard of events pays nothing on its write path.
*/
events?: {
enabled?: boolean;
};
/**
* Display multiplier converting metered amounts into the "credits" clients
* show. Applied server-side by the usage-reporting endpoints, so raw
+20
View File
@@ -155,6 +155,26 @@ Recipients are emailed by default and opt out with the unsubscribe link the mail
Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**.
### Events
One write can reach many subscriptions, so events are bounded on both halves: how much you may register, and how much any one event may turn into.
| Limit | All accounts |
| -------------------------------------------- | ------------ |
| Subscriptions per connection | 50 |
| `subscribe` / `unsubscribe` calls per minute | 60 |
| Matched subscriptions per event | 50 |
| Filter evaluations per event | 200 |
| Deliveries per minute, per subscription | 600 |
Subscriptions live with the connection that made them: they are dropped when it closes, and a reconnecting client subscribes again. The 51st subscription on one connection fails with `events_subscription_limit`; over the call budget, `subscribe` and `unsubscribe` fail with `too_many_requests`. Subscribing to something you cannot read fails with `subject_does_not_exist` — the same answer as subscribing to something that is not there, so the call cannot be used to find out which.
Match patterns are compiled once when you subscribe and are capped at **256 characters** and **16 segments**; anything larger is rejected with `invalid_subject_pattern`. `**` crosses directories and costs no more than `*`.
**Deliveries are coalesced over 250 ms per subject.** A multipart upload, a save loop, or a recursive delete is one thing the user did, and it arrives as one event carrying the newest state rather than as one event per write. Two different files in the same window are two deliveries.
The three per-event ceilings do not fail your call — they truncate the delivery and send a `gap` marker in its place, an event with `op: 'gap'` and no `uid` or `path`. A gap means something happened that you were not told the details of, so a client that must not miss changes should re-read the anchor when it sees one rather than treat the silence as "nothing changed".
### Peer connections
| Limit | Paid | Free | Anonymous |