mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-17 10:45:44 +00:00
* feat: durable event subscriptions store, cache, and routes (PUT-1673) * fix: durable subscription hardening (PUT-1673) - Expired rows stop delivering at dispatch time and no longer count toward the per-account cap, instead of waiting for the sweep. - The expiry sweep runs hourly with a jittered first pass shortly after boot; a 24 h interval never fired on a fleet that redeploys more often than that. - Only a durable generation bump marks peer regions cold. A session subscribe/unsubscribe in one region used to force a primary read in every other region on its next dispatch. - `subject`/`anchor_path` widen to varchar(4096) to match `fsentries.path`, and subjects longer than that are refused with `invalid_subject` rather than failing the insert on MySQL/Postgres. - The dispatch and durable integration suites wait for the specific delivery they expect and assert only within their own folder; the old any-delivery `settle()` let a late event from a previous test satisfy or pollute the next one under CI load.
163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } 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);
|
|
// Captured as a lookup would, right after the generation that matters.
|
|
const epoch = cache.generationOf(1);
|
|
cache.bump(1, 2); // stale — must not undo generation 5's invalidation
|
|
|
|
cache.write(1, epoch, 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);
|
|
});
|
|
|
|
it('does not let a number-less bump block a real one that follows it', () => {
|
|
// A number-less invalidation (`invalidateUser`'s bare call, or any
|
|
// other local reason to forget) must not plant a value a following
|
|
// *real* generation — a local subscribe's first-ever bump, often a
|
|
// small number — could compare behind and be ignored for. Mirrors a
|
|
// stale "nothing subscribed" answer surviving a subscribe that
|
|
// landed moments later.
|
|
const cache = new SubscriptionCache();
|
|
cache.write(1, cache.generationOf(1), false); // cached stale "false"
|
|
cache.bump(1); // e.g. a number-less invalidation
|
|
cache.write(1, cache.generationOf(1), false); // re-checked, still "false"
|
|
|
|
cache.bump(1, 1); // a real local publish landing shortly after
|
|
|
|
expect(cache.read(1)).toBeNull(); // forced to look again, not stuck
|
|
});
|
|
});
|
|
|
|
describe('cross-region generation mismatch', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('self-heals on a TTL even when a lower foreign generation is ignored', () => {
|
|
// A generation counter is region-local: a peer's bump can carry a
|
|
// number behind what this process already recorded for its own
|
|
// local traffic, purely because it was counted by a different
|
|
// counter. `bump()` treats that as already-applied and does not
|
|
// clear the cached answer — the TTL is what stops it surviving
|
|
// forever regardless.
|
|
const cache = new SubscriptionCache(10_000, 2_000);
|
|
cache.bump(1, 5); // a real local publish
|
|
cache.write(1, cache.generationOf(1), false); // cached under it
|
|
|
|
cache.bump(1, 1); // a peer's bump, numbered behind this process's own
|
|
expect(cache.read(1)).toBe(false); // not yet invalidated by number
|
|
|
|
vi.advanceTimersByTime(2_001);
|
|
expect(cache.read(1)).toBeNull(); // but stale past the TTL
|
|
});
|
|
|
|
it('keeps answering within the TTL without a bump at all', () => {
|
|
const cache = new SubscriptionCache(10_000, 2_000);
|
|
cache.write(1, 0, true);
|
|
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
expect(cache.read(1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|