mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-25 06:36:00 +00:00
feat: session event subscriptions and dispatch hot path (PUT-1666) (#3675)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user