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
@@ -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(