mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 21:46:51 +00:00
feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract
This commit is contained in:
1 parent
9c9b3f2f2e
commit
35d53dbbcc
13 files changed
+1274
-48
No files matched your search
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 95;
|
||||
const CURRENT_SCHEMA_VERSION = 96;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -724,6 +724,17 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps notification.send_key unique', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(
|
||||
rows.find((r) => r.name === 'idx_notification_send_key'),
|
||||
).toMatchObject({ unique: 1 });
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_send_key`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -134,6 +134,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
[95, ['0100_notification-send-key.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
|
||||
-- per-file applied-state tracking, so every step is guarded and a replay does
|
||||
-- nothing.
|
||||
|
||||
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_send_key_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_send_key'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
|
||||
(`send_key`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_send_key_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
|
||||
ON notification (send_key);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
|
||||
-- schedule) that wrote this row or last replaced it, derived from the
|
||||
-- schedule and its slot. Unique, so one slot can never write two rows, and a
|
||||
-- send that crashed after writing finds its row again by it. Null on every row
|
||||
-- a delayed send never touched.
|
||||
|
||||
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
|
||||
ON `notification` (`send_key`);
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `notify({ sendAt })` without and with `services.schedules`, and the deferred
|
||||
* mailbox write an extension runs inside its own transaction, fenced on a guard
|
||||
* in a table of its own (a test table here).
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { BatchEntry } from '../../clients/database/DatabaseClient.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
DeferredSendPlan,
|
||||
ScheduleServiceContract,
|
||||
} from '../../core/schedules.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from '../../services/events/authorization.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import type {
|
||||
DeferredWriteTx,
|
||||
NotificationDriver,
|
||||
} from './NotificationDriver.js';
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
const BOOT_TIMEOUT_MS = 480_000;
|
||||
const TEST_TABLE = 'deferred_send_test';
|
||||
|
||||
let server: PuterServer;
|
||||
let holder: Actor;
|
||||
let app: { id: number; uid: string };
|
||||
let saved: unknown;
|
||||
let nextId = 1;
|
||||
|
||||
const db = () => server.clients.db;
|
||||
const driver = () =>
|
||||
server.drivers.notifications as unknown as NotificationDriver;
|
||||
const slot = () => server.services as unknown as { schedules?: unknown };
|
||||
/** Put `schedules` in the slot, or leave the slot empty. */
|
||||
const setSchedules = (schedules: unknown): void => {
|
||||
if (schedules === undefined) delete slot().schedules;
|
||||
else slot().schedules = schedules;
|
||||
};
|
||||
const asApp = (): Actor => makeActor({ user: holder.user, app });
|
||||
|
||||
const notify = (fields: Record<string, unknown>): Promise<unknown> =>
|
||||
runWithContext({ actor: asApp() }, () =>
|
||||
driver().create({
|
||||
object: { value: { type: 'app.message', title: 'Hi', ...fields } },
|
||||
}),
|
||||
);
|
||||
|
||||
const codeOf = async (run: Promise<unknown>): Promise<string | undefined> => {
|
||||
try {
|
||||
await run;
|
||||
} catch (err) {
|
||||
if (isHttpError(err)) return err.legacyCode;
|
||||
throw err;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const mailbox = () =>
|
||||
db().read(
|
||||
'SELECT * FROM `notification` WHERE `user_id` = ? AND `app_uid` = ? ' +
|
||||
'ORDER BY `id`',
|
||||
[holder.user!.id, app.uid],
|
||||
);
|
||||
|
||||
/** A row's value: JSON text on sqlite, parsed on the other engines. */
|
||||
const valueOf = (row: Record<string, unknown>) =>
|
||||
typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
|
||||
|
||||
/** A send claimed under `token`, as an extension's own row would be. */
|
||||
const claimed = async (token: string): Promise<number> => {
|
||||
const id = nextId++;
|
||||
await db().write(
|
||||
`INSERT INTO \`${TEST_TABLE}\` (\`id\`, \`token\`, \`state\`) VALUES (?, ?, 'sending')`,
|
||||
[id, token],
|
||||
);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** The transaction an extension wraps the write in: its row, then the mailbox. */
|
||||
const txFor = (id: number, token: string): DeferredWriteTx => ({
|
||||
guard: {
|
||||
table: TEST_TABLE,
|
||||
where: "`id` = ? AND `token` = ? AND `state` = 'delivered'",
|
||||
values: [id, token],
|
||||
},
|
||||
async commit(entries: readonly BatchEntry[]) {
|
||||
await db().batchWrite([
|
||||
{
|
||||
statement:
|
||||
`UPDATE \`${TEST_TABLE}\` SET \`state\` = 'delivered' ` +
|
||||
"WHERE `id` = ? AND `token` = ? AND `state` = 'sending'",
|
||||
values: [id, token],
|
||||
},
|
||||
...entries,
|
||||
]);
|
||||
const [row] = await db().pread(
|
||||
`SELECT \`state\`, \`token\` FROM \`${TEST_TABLE}\` WHERE \`id\` = ?`,
|
||||
[id],
|
||||
);
|
||||
return row?.state === 'delivered' && row.token === token;
|
||||
},
|
||||
});
|
||||
|
||||
const write = (
|
||||
sendKey: string,
|
||||
tx: DeferredWriteTx,
|
||||
over: {
|
||||
payload?: string | null;
|
||||
appUid?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) =>
|
||||
driver().writeDeferredAppMessage(
|
||||
{
|
||||
holderUserId: holder.user!.id,
|
||||
appUid: over.appUid ?? app.uid,
|
||||
payload:
|
||||
over.payload === undefined
|
||||
? JSON.stringify({ message: { title: 'Later' } })
|
||||
: over.payload,
|
||||
sendKey,
|
||||
signal: over.signal ?? new AbortController().signal,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
const fakeSchedules = (plan: Partial<DeferredSendPlan>) =>
|
||||
({
|
||||
onSubscriptionsChanged: vi.fn(async () => {}),
|
||||
createFor: vi.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
receivePeerChanges: vi.fn(),
|
||||
prepareSendAt: vi.fn(async (): Promise<DeferredSendPlan> => ({
|
||||
sendAt: 0,
|
||||
immediate: false,
|
||||
holderUserId: holder.user!.id,
|
||||
appUid: app.uid,
|
||||
handlerDepth: 0,
|
||||
quota: {
|
||||
recurringPerUser: 1,
|
||||
recurringPerApp: 1,
|
||||
oneShotsPerUser: 1,
|
||||
oneShotsPerApp: 1,
|
||||
budgetPerUser: 1,
|
||||
budgetPerApp: 1,
|
||||
},
|
||||
...plan,
|
||||
})),
|
||||
createDeferredAppMessage: vi.fn(async (p: DeferredSendPlan) => ({
|
||||
scheduleId: 'sched-1',
|
||||
sendAt: p.sendAt,
|
||||
})),
|
||||
}) satisfies ScheduleServiceContract;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
events: { enabled: true },
|
||||
} as IConfig);
|
||||
const username = `sendat-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
holder = makeActor({
|
||||
user: {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
username: row.username,
|
||||
email: row.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
});
|
||||
const name = `sendat-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
app = (await server.stores.app.create(
|
||||
{ name, title: name, index_url: `https://${name}.test/` },
|
||||
{ ownerUserId: row.id },
|
||||
)) as { id: number; uid: string };
|
||||
await server.services.permission.grantUserAppPermission(
|
||||
holder,
|
||||
app.uid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
await db().write(
|
||||
`CREATE TABLE \`${TEST_TABLE}\` (\`id\` INTEGER PRIMARY KEY, ` +
|
||||
'`token` VARCHAR(64) NOT NULL, `state` VARCHAR(16) NOT NULL)',
|
||||
[],
|
||||
);
|
||||
saved = slot().schedules;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db().write('DELETE FROM `notification` WHERE `app_uid` = ?', [
|
||||
app.uid,
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSchedules(saved);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('notify({ sendAt }) without services.schedules', () => {
|
||||
it('is refused events_disabled, even when due now, storing nothing', async () => {
|
||||
setSchedules(undefined);
|
||||
expect(await codeOf(notify({ sendAt: Date.now() }))).toBe(
|
||||
'events_disabled',
|
||||
);
|
||||
expect(await codeOf(notify({ sendAt: Date.now() + 3_600_000 }))).toBe(
|
||||
'events_disabled',
|
||||
);
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends as before without sendAt', async () => {
|
||||
setSchedules(undefined);
|
||||
const sent = (await notify({ text: 'now' })) as { uid: string };
|
||||
expect(sent.uid).toBeTruthy();
|
||||
expect(await mailbox()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notify({ sendAt }) with services.schedules', () => {
|
||||
it('hands a later send to createDeferredAppMessage and answers its id', async () => {
|
||||
const at = Date.now() + 3_600_000;
|
||||
const schedules = fakeSchedules({ sendAt: at });
|
||||
setSchedules(schedules);
|
||||
|
||||
await expect(notify({ sendAt: at, text: 'later' })).resolves.toEqual({
|
||||
scheduled: { scheduleId: 'sched-1', sendAt: at },
|
||||
});
|
||||
expect(schedules.prepareSendAt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: holder.user }),
|
||||
at,
|
||||
);
|
||||
expect(schedules.createDeferredAppMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sendAt: at }),
|
||||
{
|
||||
message: expect.objectContaining({
|
||||
title: 'Hi',
|
||||
text: 'later',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends at once when the plan says it is due', async () => {
|
||||
const schedules = fakeSchedules({ immediate: true });
|
||||
setSchedules(schedules);
|
||||
|
||||
const sent = (await notify({ sendAt: Date.now() })) as {
|
||||
uid: string;
|
||||
};
|
||||
expect(sent.uid).toBeTruthy();
|
||||
expect(schedules.createDeferredAppMessage).not.toHaveBeenCalled();
|
||||
expect(await mailbox()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes a refusal from prepareSendAt through', async () => {
|
||||
const schedules = fakeSchedules({});
|
||||
schedules.prepareSendAt.mockRejectedValueOnce(
|
||||
Object.assign(new Error('scoped'), {}),
|
||||
);
|
||||
setSchedules(schedules);
|
||||
await expect(notify({ sendAt: Date.now() })).rejects.toThrow('scoped');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeDeferredAppMessage', () => {
|
||||
it('writes one row keyed by the send, in the caller`s transaction', async () => {
|
||||
const token = uuidv4();
|
||||
const key = uuidv4();
|
||||
const id = await claimed(token);
|
||||
|
||||
await expect(write(key, txFor(id, token))).resolves.toBe('delivered');
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: key, send_key: key });
|
||||
expect(valueOf(rows[0])).toMatchObject({ title: 'Later' });
|
||||
});
|
||||
|
||||
it('writes nothing when the guard no longer holds: another claim took it', async () => {
|
||||
const id = await claimed(uuidv4());
|
||||
const key = uuidv4();
|
||||
|
||||
await expect(write(key, txFor(id, uuidv4()))).resolves.toBe('lost');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces the open row of its group, which takes the key', async () => {
|
||||
const open = (await notify({ group: 'g', text: 'first' })) as {
|
||||
uid: string;
|
||||
};
|
||||
const token = uuidv4();
|
||||
const key = uuidv4();
|
||||
const id = await claimed(token);
|
||||
|
||||
await expect(
|
||||
write(key, txFor(id, token), {
|
||||
payload: JSON.stringify({
|
||||
message: { title: 'Later', group: 'g' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe('delivered');
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: open.uid, send_key: key });
|
||||
expect(valueOf(rows[0])).toMatchObject({ title: 'Later' });
|
||||
});
|
||||
|
||||
it('drops a payload it can`t read, and a sender whose app is gone', async () => {
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { payload: '{"nope":1}' }),
|
||||
).resolves.toBe('dropped');
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { appUid: `app-${uuidv4()}` }),
|
||||
).resolves.toBe('dropped');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes nothing once the caller`s claim is lost', async () => {
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
const lost = new AbortController();
|
||||
lost.abort();
|
||||
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { signal: lost.signal }),
|
||||
).rejects.toThrow();
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A second claim taking a send over while the first one's transaction is open.
|
||||
* Needs two writers at once with row locks; sqlite serialises writers, so this
|
||||
* runs on postgres only.
|
||||
*/
|
||||
describe.runIf(process.env.PUTER_TEST_DB_ENGINE === 'postgres')(
|
||||
'a deferred write racing another writer',
|
||||
() => {
|
||||
/**
|
||||
* Hold the first statement matching `match` on its connection, before
|
||||
* or after it runs, until released.
|
||||
*/
|
||||
const holdStatement = (match: RegExp, when: 'before' | 'after') => {
|
||||
const client = db() as unknown as {
|
||||
query: (
|
||||
target: unknown,
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
const original = client.query.bind(client);
|
||||
let reached!: () => void;
|
||||
let release!: () => void;
|
||||
const atBarrier = new Promise<void>((r) => (reached = r));
|
||||
const released = new Promise<void>((r) => (release = r));
|
||||
let held = false;
|
||||
vi.spyOn(client as never, 'query' as never).mockImplementation(
|
||||
(async (target: unknown, sql: string, params?: unknown[]) => {
|
||||
if (held || !match.test(sql))
|
||||
return original(target, sql, params);
|
||||
held = true;
|
||||
if (when === 'before') {
|
||||
reached();
|
||||
await released;
|
||||
return original(target, sql, params);
|
||||
}
|
||||
const result = await original(target, sql, params);
|
||||
reached();
|
||||
await released;
|
||||
return result;
|
||||
}) as never,
|
||||
);
|
||||
return { atBarrier, release };
|
||||
};
|
||||
|
||||
const DELIVERED = /SET `state` = 'delivered'/;
|
||||
const REPLACE = /`replaced_at` = \?, `send_key` = \? WHERE/;
|
||||
|
||||
/** Whether `promise` is still pending after `ms`. */
|
||||
const stillPending = async (promise: Promise<unknown>, ms: number) => {
|
||||
let settled = false;
|
||||
const watched = (async () => {
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
settled = true;
|
||||
}
|
||||
})();
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
void watched;
|
||||
return !settled;
|
||||
};
|
||||
|
||||
it('a transaction that opened before a takeover writes nothing', async () => {
|
||||
const stale = uuidv4();
|
||||
const id = await claimed(stale);
|
||||
const key = uuidv4();
|
||||
const gate = holdStatement(DELIVERED, 'before');
|
||||
const first = write(key, txFor(id, stale));
|
||||
await gate.atBarrier;
|
||||
|
||||
await db().write(
|
||||
`UPDATE \`${TEST_TABLE}\` SET \`token\` = ? WHERE \`id\` = ?`,
|
||||
[uuidv4(), id],
|
||||
);
|
||||
gate.release();
|
||||
await expect(first).resolves.toBe('lost');
|
||||
expect(
|
||||
await server.stores.notification.getBySendKey(key),
|
||||
).toBeNull();
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('an immediate replacement waiting on the open transaction lands after it, without the key', async () => {
|
||||
const open = (await notify({ group: 'g', text: 'first' })) as {
|
||||
uid: string;
|
||||
};
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
const key = uuidv4();
|
||||
const gate = holdStatement(REPLACE, 'after');
|
||||
const deferred = write(key, txFor(id, token), {
|
||||
payload: JSON.stringify({
|
||||
message: { title: 'Later', group: 'g', text: 'later' },
|
||||
}),
|
||||
});
|
||||
await gate.atBarrier;
|
||||
|
||||
const immediate = notify({ group: 'g', text: 'newer' });
|
||||
// Past the group lock's wait, then blocked on the row.
|
||||
expect(await stillPending(immediate, 2_500)).toBe(true);
|
||||
gate.release();
|
||||
await expect(deferred).resolves.toBe('delivered');
|
||||
await immediate;
|
||||
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: open.uid, send_key: null });
|
||||
expect(valueOf(rows[0]).text).toBe('newer');
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -51,6 +51,14 @@ import {
|
||||
resolveNotificationWrite,
|
||||
type NotificationTypeName,
|
||||
} from '../../services/notification/notificationTypes.js';
|
||||
import {
|
||||
schedulesUnavailable,
|
||||
type DeferredAppMessage,
|
||||
type DeferredSendPlan,
|
||||
type ScheduleServiceContract,
|
||||
type SqlGuard,
|
||||
} from '../../core/schedules.js';
|
||||
import type { BatchEntry } from '../../clients/database/DatabaseClient.js';
|
||||
import { withRedisLock } from '../../util/redisLock.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
@@ -67,6 +75,44 @@ const GROUP_LOCK_RETRY_MS = 25;
|
||||
|
||||
type AppMessageWindow = 'minute' | 'day';
|
||||
|
||||
export type { SqlGuard };
|
||||
|
||||
/** What a delayed app message's fire-time write needs from its row. */
|
||||
export interface DeferredWriteInput {
|
||||
holderUserId: number;
|
||||
appUid: string;
|
||||
/** The stored {@link DeferredAppMessage}, as JSON text. */
|
||||
payload: string | null;
|
||||
/** The slot's key: the written row's uid or `send_key`, and its `sendId`. */
|
||||
sendKey: string;
|
||||
/** Aborted when the caller's claim is lost. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's transaction the mailbox write commits in. Atomicity is the
|
||||
* caller's to keep; the type can't enforce it.
|
||||
*/
|
||||
export interface DeferredWriteTx {
|
||||
/**
|
||||
* What every mailbox statement is fenced on: the caller's row marked
|
||||
* delivered by this claim.
|
||||
*/
|
||||
guard: SqlGuard;
|
||||
/**
|
||||
* Must be the whole delivery in one step: the caller's guarded transition
|
||||
* of its row to delivered and `entries`, in that order, in one
|
||||
* `batchWrite`, then a read of the row on the primary. True only when that
|
||||
* read shows this claim holding the row as delivered. Splitting the
|
||||
* transition from `entries` lets one slot write two rows.
|
||||
*/
|
||||
commit(entries: readonly BatchEntry[]): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** How a delayed send's write stood when it stopped. */
|
||||
export type DeferredWriteOutcome =
|
||||
'delivered' | 'dropped' | 'lost' | 'senderSuspended';
|
||||
|
||||
/** The mailbox slice a call named, in the terms the query takes it. */
|
||||
interface MailboxScope {
|
||||
audiences: readonly string[];
|
||||
@@ -124,6 +170,21 @@ const parseNotifyTarget = (to: unknown): string => {
|
||||
return target.handle;
|
||||
};
|
||||
|
||||
/** A stored delayed message, or `null` for one that can't be read back. */
|
||||
const parseDelayedAppMessage = (
|
||||
payload: string | null,
|
||||
): DeferredAppMessage | null => {
|
||||
try {
|
||||
const stored = JSON.parse(payload ?? 'null') as DeferredAppMessage;
|
||||
if (typeof stored?.message?.title !== 'string') return null;
|
||||
if (stored.to !== undefined && typeof stored.to !== 'string')
|
||||
return null;
|
||||
return stored;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether a row belongs to the slice, as the same SQL scope selected it. */
|
||||
const inScope = (
|
||||
row: Record<string, unknown>,
|
||||
@@ -354,7 +415,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
object: Record<string, unknown>,
|
||||
value: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { type, to, ...fields } = value;
|
||||
const { type, to, sendAt, ...fields } = value;
|
||||
if (type !== APP_MESSAGE_TYPE) {
|
||||
throw typeNotAllowed(`Apps can create only ${APP_MESSAGE_TYPE}`);
|
||||
}
|
||||
@@ -370,6 +431,12 @@ export class NotificationDriver extends PuterDriver {
|
||||
const handle = to === undefined ? undefined : parseNotifyTarget(to);
|
||||
const message = parseAppMessage(fields);
|
||||
assertValueFits(value);
|
||||
const timing =
|
||||
sendAt === undefined || sendAt === null
|
||||
? null
|
||||
: await this.#schedules().prepareSendAt(actor, sendAt);
|
||||
if (timing && !timing.immediate)
|
||||
return this.#scheduleAppMessage(actor, message, handle, timing);
|
||||
|
||||
if (handle === undefined) {
|
||||
const uid = await this.#sendAppMessage(
|
||||
@@ -410,12 +477,23 @@ export class NotificationDriver extends PuterDriver {
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
): Promise<string> {
|
||||
const send = () =>
|
||||
this.#replaceOrWriteAppMessage(recipientId, appUid, message);
|
||||
return this.#oneSendPerGroup(recipientId, appUid, message, () =>
|
||||
this.#replaceOrWriteAppMessage(recipientId, appUid, message),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One send per group at a time, so a burst into an empty group writes one
|
||||
* row that the rest replace. A lock that is busy past the wait or
|
||||
* unreachable still sends: the race costs a second row and its day slot.
|
||||
*/
|
||||
#oneSendPerGroup<T>(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
send: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!message.group) return send();
|
||||
// One send per group at a time, so a burst into an empty group writes
|
||||
// one row that the rest replace. A lock that is busy past the wait or
|
||||
// unreachable still sends: the race costs a second row and its day slot.
|
||||
return withRedisLock(
|
||||
this.clients.redis,
|
||||
`notif:app-message:group:${recipientId}:${appUid}:${message.group}`,
|
||||
@@ -430,6 +508,146 @@ export class NotificationDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Delayed sends -----------------------------------------------
|
||||
|
||||
#schedules(): ScheduleServiceContract {
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an app's message to go out at `sendAt`. A recipient named through a
|
||||
* share is checked now without spending its budget; every limit is spent
|
||||
* when it goes, and the share is checked again then.
|
||||
*/
|
||||
async #scheduleAppMessage(
|
||||
actor: UserActor,
|
||||
message: AppMessage,
|
||||
handle: string | undefined,
|
||||
plan: DeferredSendPlan,
|
||||
): Promise<{ scheduled: { scheduleId: string; sendAt: number } }> {
|
||||
if (handle !== undefined)
|
||||
await this.services.kvShare.resolveNotifyRecipient(actor, handle, {
|
||||
spendBudget: false,
|
||||
});
|
||||
return {
|
||||
scheduled: await this.#schedules().createDeferredAppMessage(
|
||||
plan,
|
||||
handle === undefined ? { message } : { message, to: handle },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the sender and recipient as they stand now, spend the recipient's
|
||||
* limits, and write the mailbox row through `tx`, fenced on its guard.
|
||||
* Called by extensions. Refusals are the same `HttpError`s an immediate
|
||||
* send throws.
|
||||
*/
|
||||
async writeDeferredAppMessage(
|
||||
input: DeferredWriteInput,
|
||||
tx: DeferredWriteTx,
|
||||
): Promise<DeferredWriteOutcome> {
|
||||
const { holderUserId, sendKey, signal } = input;
|
||||
const sender = await this.services.events.resolveScheduleActor(
|
||||
holderUserId,
|
||||
input.appUid,
|
||||
);
|
||||
if (!sender) {
|
||||
// A lagging replica can miss an app the primary has: retried.
|
||||
const there =
|
||||
(await this.stores.app.uidExistsOnPrimary(input.appUid)) &&
|
||||
(await this.stores.user.getById(holderUserId)) !== null;
|
||||
if (there) throw new Error('the sender could not be resolved');
|
||||
return 'dropped';
|
||||
}
|
||||
if (sender.user?.suspended) return 'senderSuspended';
|
||||
const stored = parseDelayedAppMessage(input.payload);
|
||||
if (!stored) return 'dropped';
|
||||
const { message } = stored;
|
||||
|
||||
let recipientId = holderUserId;
|
||||
let appUid = input.appUid;
|
||||
if (stored.to !== undefined) {
|
||||
const recipient =
|
||||
await this.services.kvShare.resolveNotifyRecipient(
|
||||
sender,
|
||||
stored.to,
|
||||
);
|
||||
recipientId = recipient.recipientUserId;
|
||||
appUid = recipient.appUid;
|
||||
}
|
||||
|
||||
return this.#oneSendPerGroup(recipientId, appUid, message, async () => {
|
||||
// Primary, as for an immediate send.
|
||||
const open = message.group
|
||||
? await this.stores.notification.getUnseenByGroup(
|
||||
recipientId,
|
||||
appUid,
|
||||
message.group,
|
||||
{ primary: true },
|
||||
)
|
||||
: null;
|
||||
await this.#spendAppMessageLimits(
|
||||
recipientId,
|
||||
appUid,
|
||||
open ? ['minute'] : ['minute', 'day'],
|
||||
);
|
||||
message.app = appMessageSender(
|
||||
appUid,
|
||||
await this.stores.app.getByUid(appUid),
|
||||
);
|
||||
// Another attempt may hold the lease now; hand the claim back.
|
||||
if (signal.aborted) throw new Error('delayed send lost its lease');
|
||||
|
||||
const registered = resolveNotificationWrite(
|
||||
APP_MESSAGE_TYPE,
|
||||
appUid,
|
||||
);
|
||||
const delivered = await tx.commit(
|
||||
this.stores.notification.sendEntries(
|
||||
{
|
||||
sendKey,
|
||||
userId: recipientId,
|
||||
value: { ...message, type: registered.type },
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
group: message.group ?? null,
|
||||
replaceUid: open ? String(open.uid) : null,
|
||||
at: Date.now(),
|
||||
},
|
||||
tx.guard,
|
||||
),
|
||||
);
|
||||
if (!delivered) return 'lost';
|
||||
if (open)
|
||||
await this.#spendDayIfFresh(recipientId, appUid, open, sendKey);
|
||||
return 'delivered';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A replacement that found its row shown since the lookup wrote a new one,
|
||||
* which costs a day slot. Already written, so a full day is not a refusal.
|
||||
*/
|
||||
async #spendDayIfFresh(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
open: Record<string, unknown>,
|
||||
sendKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const written =
|
||||
await this.stores.notification.getBySendKey(sendKey);
|
||||
if (written && written.uid !== open.uid)
|
||||
await this.#spendAppMessageLimits(recipientId, appUid, ['day']);
|
||||
} catch {
|
||||
// The row is written; a slot not spent is the only loss.
|
||||
}
|
||||
}
|
||||
|
||||
async #replaceOrWriteAppMessage(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
|
||||
@@ -1705,11 +1705,14 @@ export class KvShareService extends PuterService {
|
||||
* The other party of a share, for a notification one party sends through
|
||||
* its handle from the app whose data it is. Every check runs before the
|
||||
* pair budget is spent. A handle that is unknown, retired, not the
|
||||
* caller's, or seen from another app reads as absent.
|
||||
* caller's, or seen from another app reads as absent. `spendBudget: false`
|
||||
* checks without spending, for a send that is only being scheduled. Called
|
||||
* by extensions.
|
||||
*/
|
||||
async resolveNotifyRecipient(
|
||||
actor: Actor,
|
||||
handle: unknown,
|
||||
{ spendBudget = true }: { spendBudget?: boolean } = {},
|
||||
): Promise<KvShareNotifyTarget> {
|
||||
const named = typeof handle === 'string' ? handle : '';
|
||||
const senderId = actor.user?.id;
|
||||
@@ -1742,7 +1745,8 @@ export class KvShareService extends PuterService {
|
||||
if (!checked?.holds) throw unknownKvShareHandle(named);
|
||||
if (checked.refuses) throw recipientNotAcceptingShares();
|
||||
|
||||
await this.#spendNotifyBudget(senderId, recipientId, share.appUid);
|
||||
if (spendBudget)
|
||||
await this.#spendNotifyBudget(senderId, recipientId, share.appUid);
|
||||
return {
|
||||
recipientUserId: recipientId,
|
||||
appUid: share.appUid,
|
||||
|
||||
@@ -395,6 +395,36 @@ describe('NotificationService.notifyUpdate', () => {
|
||||
written.stop();
|
||||
});
|
||||
|
||||
it('sends the desktop rewrite before reading the row back, so a failed read still updates it', async () => {
|
||||
const user = await makeUser();
|
||||
const uid = await notifications.notify(
|
||||
[user.id],
|
||||
{ title: 'one share' },
|
||||
{ type: 'share.received' },
|
||||
);
|
||||
const pushed = collect('outer.gui.notif.message');
|
||||
const read = vi
|
||||
.spyOn(server.stores.notification, 'getByUid')
|
||||
.mockRejectedValueOnce(new Error('replica down'));
|
||||
try {
|
||||
await expect(
|
||||
notifications.notifyUpdate(
|
||||
uid,
|
||||
user.id,
|
||||
{ title: 'two shares' },
|
||||
{ type: 'share.received' },
|
||||
),
|
||||
).rejects.toThrow('replica down');
|
||||
expect(pushed.seen).toHaveLength(1);
|
||||
expect((pushed.seen[0] as NotifPush).response.notification).toEqual(
|
||||
{ title: 'two shares', type: 'share.received' },
|
||||
);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
pushed.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to fold a type that does not group', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
|
||||
@@ -181,25 +181,17 @@ export class NotificationService extends PuterService {
|
||||
}
|
||||
|
||||
if (!opts.silent) {
|
||||
// Listeners get their own copy; the socket sends `payload`.
|
||||
this.clients.event.emit(
|
||||
'notif.written',
|
||||
await this.emitWritten(
|
||||
{
|
||||
userId,
|
||||
uid,
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
value: structuredClone(payload),
|
||||
sendId: uuidv4(),
|
||||
value: payload,
|
||||
},
|
||||
{},
|
||||
{ sendId: uuidv4() },
|
||||
);
|
||||
await this.#push(userId, uid, payload, {
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
});
|
||||
}
|
||||
return uid;
|
||||
}),
|
||||
@@ -246,50 +238,100 @@ export class NotificationService extends PuterService {
|
||||
if (!updated) return false;
|
||||
if (opts.silent) return true;
|
||||
|
||||
// Before the read, so the desktop copy updates even if it fails.
|
||||
const desktop = inAccountMailbox(registered.type);
|
||||
if (desktop) {
|
||||
// Same uid as the original: the client replaces what it is already
|
||||
// showing rather than stacking another copy. It goes straight to
|
||||
// the wire rather than through dispatch — the row was already
|
||||
// published, and republishing it under its own id is a duplicate
|
||||
// to anything deduplicating on `event.id`.
|
||||
this.#socket().message(userId, uid, payload);
|
||||
this.#markDelivered(uid, userId);
|
||||
}
|
||||
if (desktop) this.#sendRewrite(userId, uid, payload);
|
||||
const row = await this.stores.notification.getByUid(uid, { userId });
|
||||
const app = notification.app as { uid?: unknown } | undefined;
|
||||
const appUid =
|
||||
row?.app_uid ?? (typeof app?.uid === 'string' ? app.uid : null);
|
||||
this.clients.event.emit(
|
||||
'notif.written',
|
||||
await this.emitWritten(
|
||||
{
|
||||
userId,
|
||||
uid,
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
value: structuredClone(payload),
|
||||
value: payload,
|
||||
updated: true,
|
||||
sendId: uuidv4(),
|
||||
replacedAt,
|
||||
},
|
||||
{ sendId: uuidv4(), rewriteSent: desktop },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a notification already written: `notif.written` for listeners,
|
||||
* then the live copy. `sendId` names this one send; a delayed send passes
|
||||
* the same one each time it delivers, so a listener can tell a repeat. An
|
||||
* `updated` row is an earlier notification rewritten in place, and
|
||||
* `replacedAt` marks it a replacement. `handlerDepth` is the depth the
|
||||
* write was made at, when no actor in context carries it. `rewriteSent`
|
||||
* says the caller already sent a desktop rewrite. Called by extensions.
|
||||
*/
|
||||
async emitWritten(
|
||||
written: {
|
||||
userId: number;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
updated?: boolean;
|
||||
replacedAt?: number;
|
||||
},
|
||||
opts: { sendId: string; handlerDepth?: number; rewriteSent?: boolean },
|
||||
): Promise<void> {
|
||||
const { userId, uid, value: payload, updated } = written;
|
||||
const scope = {
|
||||
type: written.type,
|
||||
audience: written.audience,
|
||||
appUid: written.appUid,
|
||||
};
|
||||
const desktop = inAccountMailbox(scope.type);
|
||||
if (updated && desktop && !opts.rewriteSent)
|
||||
this.#sendRewrite(userId, uid, payload);
|
||||
// Listeners get their own copy; the socket sends `payload`.
|
||||
this.clients.event.emit(
|
||||
'notif.written',
|
||||
{
|
||||
userId,
|
||||
uid,
|
||||
...scope,
|
||||
value: structuredClone(payload),
|
||||
...(updated ? { updated: true as const } : {}),
|
||||
sendId: opts.sendId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
// A type kept off the desktop has dispatch as its only live path, so
|
||||
// the rewrite goes out there again, under the same uid.
|
||||
if (!desktop) {
|
||||
// A rewrite of a type kept off the desktop has dispatch as its only
|
||||
// live path, so it goes out there again, under the same uid.
|
||||
if (!updated || !desktop)
|
||||
await this.#push(
|
||||
userId,
|
||||
uid,
|
||||
payload,
|
||||
{
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
},
|
||||
replacedAt,
|
||||
scope,
|
||||
written.replacedAt,
|
||||
opts.handlerDepth,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same uid as the original: the client replaces what it is already showing
|
||||
* rather than stacking another copy. It goes straight to the wire rather
|
||||
* than through dispatch — the row was already published, and republishing
|
||||
* it under its own id is a duplicate to anything deduplicating on
|
||||
* `event.id`.
|
||||
*/
|
||||
#sendRewrite(
|
||||
userId: number,
|
||||
uid: string,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
this.#socket().message(userId, uid, payload);
|
||||
this.#markDelivered(uid, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,6 +456,7 @@ export class NotificationService extends PuterService {
|
||||
payload: Record<string, unknown>,
|
||||
scope: { type: string; audience: string; appUid: string | null },
|
||||
replacedAt?: number,
|
||||
depth?: number,
|
||||
): Promise<void> {
|
||||
const desktop = inAccountMailbox(scope.type);
|
||||
if (!notificationsFoldInEnabled(this.config)) {
|
||||
@@ -433,9 +476,8 @@ export class NotificationService extends PuterService {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerDepth = handlerDepthOf(
|
||||
Context.get('actor') as Actor | undefined,
|
||||
);
|
||||
const handlerDepth =
|
||||
depth ?? handlerDepthOf(Context.get('actor') as Actor | undefined);
|
||||
this.clients.event.emit(
|
||||
'notif.created',
|
||||
{
|
||||
|
||||
@@ -33,6 +33,9 @@ import { PuterStore } from '../types';
|
||||
* }} NotificationInsert
|
||||
*/
|
||||
|
||||
/** How long a probe that found no `send_key` column stands. */
|
||||
const SEND_KEY_REPROBE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Fixed width and compared byte for byte on every engine, whatever `group`
|
||||
* holds.
|
||||
@@ -75,6 +78,15 @@ const sliceWhere = (userId, { audience, appUid, excludeTypes }) => {
|
||||
};
|
||||
|
||||
export class NotificationStore extends PuterStore {
|
||||
#sendKeyPresent = false;
|
||||
#sendKeyProbedAt = Number.NEGATIVE_INFINITY;
|
||||
/** @type {Promise<void> | null} */
|
||||
#sendKeyProbe = null;
|
||||
|
||||
async onServerStart() {
|
||||
await this.#hasSendKey();
|
||||
}
|
||||
|
||||
// -- Reads --------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -96,6 +108,20 @@ export class NotificationStore extends PuterStore {
|
||||
return this.#normalizeRow(rows[0]) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a delayed send wrote or last replaced, from the primary. Called
|
||||
* by extensions.
|
||||
*
|
||||
* @param {string} sendKey
|
||||
*/
|
||||
async getBySendKey(sendKey) {
|
||||
const rows = await this.clients.db.pread(
|
||||
'SELECT * FROM `notification` WHERE `send_key` = ? LIMIT 1',
|
||||
[sendKey],
|
||||
);
|
||||
return this.#normalizeRow(rows[0]) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest row `appUid` wrote for the user under `group`, or null when it
|
||||
* was shown or acknowledged: an older row is never picked up in its place.
|
||||
@@ -341,6 +367,7 @@ export class NotificationStore extends PuterStore {
|
||||
type,
|
||||
group_key: key,
|
||||
replaced_at: null,
|
||||
send_key: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,6 +381,82 @@ export class NotificationStore extends PuterStore {
|
||||
return (await this.create(args)).uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The statements that write a delayed send's notification, for the caller's
|
||||
* transaction. Each runs only while `guard` holds. With `replaceUid` the
|
||||
* open row is replaced in place and takes `sendKey`; the insert, whose uid
|
||||
* is `sendKey`, then runs only if that row was shown or dismissed in
|
||||
* between. Called by extensions.
|
||||
*
|
||||
* @param {{
|
||||
* sendKey: string;
|
||||
* userId: number;
|
||||
* value: unknown;
|
||||
* type: string;
|
||||
* audience: string;
|
||||
* appUid: string | null;
|
||||
* group?: string | null;
|
||||
* replaceUid?: string | null;
|
||||
* at: number;
|
||||
* }} send
|
||||
* `at` is epoch ms, the row's time either way.
|
||||
* @param {{ table: string; where: string; values: unknown[] }} guard
|
||||
* @returns {{ statement: string; values: unknown[] }[]}
|
||||
*/
|
||||
sendEntries(send, guard) {
|
||||
const db = this.clients.db;
|
||||
const serialized = JSON.stringify(send.value ?? {});
|
||||
const seconds = Math.floor(send.at / 1000);
|
||||
const fence = `SELECT 1 FROM \`${guard.table}\` WHERE ${guard.where}`;
|
||||
const entries = [];
|
||||
if (send.replaceUid) {
|
||||
entries.push({
|
||||
statement:
|
||||
'UPDATE `notification` SET `value` = ?, `shown` = NULL, ' +
|
||||
`\`created_at\` = ${db.timestampFromUnixSql()}, ` +
|
||||
'`replaced_at` = ?, `send_key` = ? WHERE `uid` = ? ' +
|
||||
'AND `user_id` = ? AND `shown` IS NULL ' +
|
||||
`AND \`acknowledged\` IS NULL AND EXISTS (${fence})`,
|
||||
values: [
|
||||
serialized,
|
||||
seconds,
|
||||
send.at,
|
||||
send.sendKey,
|
||||
send.replaceUid,
|
||||
send.userId,
|
||||
...guard.values,
|
||||
],
|
||||
});
|
||||
}
|
||||
// Postgres can't type a bare parameter in a SELECT list.
|
||||
const param = (type) =>
|
||||
db.case({ postgres: `CAST(? AS ${type})`, otherwise: '?' });
|
||||
entries.push({
|
||||
statement:
|
||||
'INSERT INTO `notification` (`uid`, `user_id`, `value`, `type`, ' +
|
||||
'`audience`, `app_uid`, `group_key`, `created_at`, `send_key`) ' +
|
||||
`SELECT ${param('TEXT')}, ${param('INTEGER')}, ${param('JSONB')}, ` +
|
||||
`${param('TEXT')}, ${param('TEXT')}, ${param('TEXT')}, ` +
|
||||
`${param('TEXT')}, ${db.timestampFromUnixSql()}, ${param('TEXT')} ` +
|
||||
`FROM \`${guard.table}\` WHERE ${guard.where} AND NOT EXISTS ` +
|
||||
'(SELECT 1 FROM `notification` WHERE `send_key` = ?)',
|
||||
values: [
|
||||
send.sendKey,
|
||||
send.userId,
|
||||
serialized,
|
||||
send.type,
|
||||
send.audience,
|
||||
send.appUid,
|
||||
groupKey(send.group),
|
||||
seconds,
|
||||
send.sendKey,
|
||||
...guard.values,
|
||||
send.sendKey,
|
||||
],
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a notification the recipient hasn't dismissed. `shown` is cleared
|
||||
* with it, so changed wording goes out again on their next connect —
|
||||
@@ -362,6 +465,10 @@ export class NotificationStore extends PuterStore {
|
||||
* `onlyUnseen` also skips a row already shown. `replacedAt`, in epoch ms,
|
||||
* records the rewrite as a replacement and dates the row to it.
|
||||
*
|
||||
* `send_key` is cleared with it whenever the column exists, on every node:
|
||||
* the content is no longer what a delayed send wrote, so its recovery must
|
||||
* not deliver it under that send's key.
|
||||
*
|
||||
* False when no such row exists, so callers can fall back to a fresh
|
||||
* notification instead of dropping what they were reporting.
|
||||
*
|
||||
@@ -378,6 +485,7 @@ export class NotificationStore extends PuterStore {
|
||||
const serialized =
|
||||
typeof value === 'string' ? value : JSON.stringify(value ?? {});
|
||||
const set = ['`value` = ?', '`shown` = NULL'];
|
||||
if (await this.#hasSendKey()) set.push('`send_key` = NULL');
|
||||
const params = [serialized];
|
||||
if (replacedAt !== undefined) {
|
||||
set.push(
|
||||
@@ -478,6 +586,45 @@ export class NotificationStore extends PuterStore {
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether `notification.send_key` exists, asked of the primary. Present is
|
||||
* kept for the process; absent is asked again at most once a minute, so a
|
||||
* node started before the column was added clears it soon after.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #hasSendKey() {
|
||||
if (this.#sendKeyPresent) return true;
|
||||
if (Date.now() - this.#sendKeyProbedAt < SEND_KEY_REPROBE_MS)
|
||||
return false;
|
||||
if (this.#sendKeyProbe) {
|
||||
await this.#sendKeyProbe;
|
||||
return this.#sendKeyPresent;
|
||||
}
|
||||
const probe = this.#probeSendKey();
|
||||
this.#sendKeyProbe = probe;
|
||||
try {
|
||||
await probe;
|
||||
} finally {
|
||||
if (this.#sendKeyProbe === probe) this.#sendKeyProbe = null;
|
||||
}
|
||||
return this.#sendKeyPresent;
|
||||
}
|
||||
|
||||
/** Never rejects: an error reads as no column. */
|
||||
async #probeSendKey() {
|
||||
try {
|
||||
await this.clients.db.pread(
|
||||
'SELECT `send_key` FROM `notification` WHERE 1 = 0',
|
||||
[],
|
||||
);
|
||||
this.#sendKeyPresent = true;
|
||||
} catch {
|
||||
this.#sendKeyPresent = false;
|
||||
}
|
||||
this.#sendKeyProbedAt = Date.now();
|
||||
}
|
||||
|
||||
#normalizeRow(row) {
|
||||
if (!row) return null;
|
||||
if (typeof row.value === 'string') {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `send_key` is cleared on every rewrite once the column exists, whichever node
|
||||
* rewrites and whatever its config: a row a delayed send wrote and another node
|
||||
* replaced must not be delivered again under that send's key.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { NotificationStore } from './NotificationStore.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let userId: number;
|
||||
|
||||
/** A store on the same database, as another node with its own config. */
|
||||
const node = (config: Partial<IConfig>): NotificationStore =>
|
||||
new NotificationStore(
|
||||
config as IConfig,
|
||||
server.clients as never,
|
||||
server.stores as never,
|
||||
);
|
||||
|
||||
/** A row as a delayed send writes it, keyed `sendKey`. */
|
||||
const writeSent = async (store: NotificationStore): Promise<string> => {
|
||||
const sendKey = uuidv4();
|
||||
await server.clients.db.batchWrite(
|
||||
store.sendEntries(
|
||||
{
|
||||
sendKey,
|
||||
userId,
|
||||
value: { title: 'later' },
|
||||
type: 'app-message',
|
||||
audience: 'account',
|
||||
appUid: null,
|
||||
at: Date.now(),
|
||||
},
|
||||
{ table: 'user', where: '`id` = ?', values: [userId] },
|
||||
),
|
||||
);
|
||||
return sendKey;
|
||||
};
|
||||
|
||||
const sendKeyOf = async (uid: string): Promise<unknown> => {
|
||||
const [row] = await server.clients.db.pread(
|
||||
'SELECT `send_key` FROM `notification` WHERE `uid` = ?',
|
||||
[uid],
|
||||
);
|
||||
return row?.send_key;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
const username = `sendkey-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('clearing send_key on a rewrite', () => {
|
||||
it('clears it from a row another node wrote, whatever either config says', async () => {
|
||||
const writer = node({ events: { enabled: true } } as Partial<IConfig>);
|
||||
const rewriter = node({});
|
||||
const sendKey = await writeSent(writer);
|
||||
expect(await sendKeyOf(sendKey)).toBe(sendKey);
|
||||
|
||||
await expect(
|
||||
rewriter.updateValue(sendKey, userId, { title: 'replaced' }),
|
||||
).resolves.toBe(true);
|
||||
expect(await sendKeyOf(sendKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('probes once and keeps a present column for the process', async () => {
|
||||
const store = node({});
|
||||
const pread = vi.spyOn(server.clients.db, 'pread');
|
||||
const first = await writeSent(store);
|
||||
const second = await writeSent(store);
|
||||
|
||||
await store.updateValue(first, userId, { v: 1 });
|
||||
await store.updateValue(second, userId, { v: 2 });
|
||||
|
||||
const probes = pread.mock.calls.filter(([sql]) =>
|
||||
String(sql).includes('WHERE 1 = 0'),
|
||||
);
|
||||
expect(probes).toHaveLength(1);
|
||||
expect(await sendKeyOf(first)).toBeNull();
|
||||
expect(await sendKeyOf(second)).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites without naming the column while it is absent, and clears it after the next probe once it exists', async () => {
|
||||
const db = server.clients.db;
|
||||
const store = node({});
|
||||
const sendKey = await writeSent(store);
|
||||
const write = vi.spyOn(db, 'write');
|
||||
const realPread = db.pread.bind(db);
|
||||
let columnExists = false;
|
||||
vi.spyOn(db, 'pread').mockImplementation(async (sql, values) => {
|
||||
if (!columnExists && String(sql).includes('WHERE 1 = 0'))
|
||||
throw new Error('no such column: send_key');
|
||||
return realPread(sql, values);
|
||||
});
|
||||
let now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const rewriteSql = () =>
|
||||
String(
|
||||
write.mock.calls.findLast(([sql]) =>
|
||||
String(sql).startsWith('UPDATE `notification` SET `value`'),
|
||||
)?.[0],
|
||||
);
|
||||
|
||||
await store.updateValue(sendKey, userId, { v: 1 });
|
||||
expect(rewriteSql()).not.toContain('send_key');
|
||||
expect(await sendKeyOf(sendKey)).toBe(sendKey);
|
||||
|
||||
// Within the minute the absent answer stands.
|
||||
columnExists = true;
|
||||
now += 30_000;
|
||||
await store.updateValue(sendKey, userId, { v: 2 });
|
||||
expect(rewriteSql()).not.toContain('send_key');
|
||||
expect(await sendKeyOf(sendKey)).toBe(sendKey);
|
||||
|
||||
now += 31_000;
|
||||
await store.updateValue(sendKey, userId, { v: 3 });
|
||||
expect(rewriteSql()).toContain('`send_key` = NULL');
|
||||
expect(await sendKeyOf(sendKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('shares one probe between rewrites that race it', async () => {
|
||||
const db = server.clients.db;
|
||||
const store = node({});
|
||||
const keys = await Promise.all([writeSent(store), writeSent(store)]);
|
||||
const pread = vi.spyOn(db, 'pread');
|
||||
|
||||
await Promise.all(
|
||||
keys.map((uid) => store.updateValue(uid, userId, { v: 1 })),
|
||||
);
|
||||
|
||||
const probes = pread.mock.calls.filter(([sql]) =>
|
||||
String(sql).includes('WHERE 1 = 0'),
|
||||
);
|
||||
expect(probes).toHaveLength(1);
|
||||
for (const uid of keys) expect(await sendKeyOf(uid)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -346,7 +346,9 @@ describe('NotificationStore', () => {
|
||||
const uids = (rows) => rows.map((r) => r.uid).sort();
|
||||
|
||||
expect(
|
||||
uids(await store.listScoped(u.id, { audience: 'developer', appUid })),
|
||||
uids(
|
||||
await store.listScoped(u.id, { audience: 'developer', appUid }),
|
||||
),
|
||||
).toEqual([mine.uid]);
|
||||
// `null` is the rows naming no app, not "any app".
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user