mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
* fix(events): rename carries from, one retry on deploy-timeout, forward-path counters - FSService.rename passes the pre-rename path so a folder subscription sees op move with from, like a real move does - deploy-timeout from the dispatcher gets one retry with the deployed header and no second upload (ALREADY_DEPLOYED_MISS_REASONS) - OTel counters events.forward.sent/received and events.single.attempt - cross-app KV subscribe error hints at the three-segment parse - docs: lease is 60 s, kv prefix example is fully qualified, move covers rename * feat(events): forward session subscriptions across regions, fast bumps, worker session cleanup - session (onLocal) subscriptions now receive writes committed in other regions: a transition-maintained remote-watch index (ev:sc / ev:rw), watch/event forward items, replay through dispatchForwarded against session rows only; events.forwardSession=false is the kill switch - subscription and presence generation bumps also ride the addressed forward channel (kind bump) so a peer sees a new durable row within a queue window; the webhook fan stays as backstop - workers.destroy revokes every holder's events:handlers session; app deletion reaps the app's rows, backlog and handlers and revokes the sessions; an hourly sweep revokes sessions whose app is gone - docs: cross-region latency, per-region caps footnote, session lifecycle
411 lines
14 KiB
TypeScript
411 lines
14 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/>.
|
|
*/
|
|
|
|
/**
|
|
* `DispatcherInvokeTransport` against an injected fetch, so the handled/error
|
|
* header contract is pinned without a real dispatcher on the other end.
|
|
*/
|
|
|
|
import type { fetch as undiciFetch } from 'undici';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { IConfig } from '../../types.js';
|
|
import {
|
|
DispatcherInvokeTransport,
|
|
EVENTS_DEPLOYED_HEADER,
|
|
EVENTS_DISPATCH_ERROR_HEADER,
|
|
EVENTS_DISPATCH_PATH,
|
|
EVENTS_ERROR_HEADER,
|
|
EVENTS_HANDLED_HEADER,
|
|
EventsWorkerInvokerClient,
|
|
type WorkerInvokeRequest,
|
|
} from './EventsWorkerInvokerClient.js';
|
|
|
|
type FetchImpl = typeof undiciFetch;
|
|
|
|
const CALL = {
|
|
script: 'evw-test',
|
|
appUid: 'app-test',
|
|
key: 'k1:test',
|
|
body: '{}',
|
|
timeoutMs: 5_000,
|
|
};
|
|
|
|
/** A stub fetch that answers the same way on every call. */
|
|
const stubFetch = (
|
|
status: number,
|
|
headers: Record<string, string> = {},
|
|
): FetchImpl =>
|
|
(async () =>
|
|
new Response(null, { status, headers })) as unknown as FetchImpl;
|
|
|
|
/** A stub fetch that never answers until the abort signal fires. */
|
|
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () =>
|
|
reject(new Error('This operation was aborted')),
|
|
);
|
|
})) as unknown as FetchImpl;
|
|
|
|
/** A stub fetch that records the URL it was called with. */
|
|
const capturingFetch = (
|
|
calls: string[],
|
|
status = 200,
|
|
): FetchImpl =>
|
|
(async (url: string) => {
|
|
calls.push(url);
|
|
return new Response(null, { status });
|
|
}) as unknown as FetchImpl;
|
|
|
|
describe('DispatcherInvokeTransport', () => {
|
|
it('reports a handled 400 with no error — the handler`s own refusal', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: 400,
|
|
handled: true,
|
|
});
|
|
});
|
|
|
|
it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(404),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: 404,
|
|
handled: false,
|
|
});
|
|
});
|
|
|
|
it('turns a dispatch-error header into a null status with the reason', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: null,
|
|
error: 'dispatcher: deploy-failed (502)',
|
|
dispatchReason: 'deploy-failed',
|
|
});
|
|
});
|
|
|
|
it('adds the deployed header when the call says so', async () => {
|
|
const headers: Array<Record<string, string>> = [];
|
|
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
headers.push(init?.headers as Record<string, string>);
|
|
return new Response(null, { status: 200 });
|
|
}) as unknown as FetchImpl;
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl,
|
|
});
|
|
|
|
await transport.send(CALL);
|
|
await transport.send({ ...CALL, deployed: true });
|
|
|
|
expect(headers[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
|
expect(headers[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
|
});
|
|
|
|
it('reports a handled 200 as settled with no error', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: 200,
|
|
handled: true,
|
|
});
|
|
});
|
|
|
|
it('passes a 429 through as-is', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(429),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: 429,
|
|
handled: false,
|
|
});
|
|
});
|
|
|
|
it('answers null with an error when the request times out', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: hangingFetch,
|
|
});
|
|
const result = await transport.send({ ...CALL, timeoutMs: 10 });
|
|
expect(result.status).toBeNull();
|
|
expect(result.error).toMatch(/abort/i);
|
|
});
|
|
|
|
it('surfaces the runtime`s own error header alongside its status', async () => {
|
|
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(500, {
|
|
[EVENTS_HANDLED_HEADER]: '1',
|
|
[EVENTS_ERROR_HEADER]: 'handler-threw',
|
|
}),
|
|
});
|
|
expect(await transport.send(CALL)).toEqual({
|
|
status: 500,
|
|
handled: true,
|
|
error: 'handler-threw',
|
|
});
|
|
});
|
|
|
|
it('preserves a path prefix on the dispatcher URL', async () => {
|
|
const calls: string[] = [];
|
|
const transport = new DispatcherInvokeTransport(
|
|
'http://dispatcher/prefix/',
|
|
's',
|
|
{ fetchImpl: capturingFetch(calls) },
|
|
);
|
|
await transport.send(CALL);
|
|
expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]);
|
|
});
|
|
});
|
|
|
|
const REQUEST: WorkerInvokeRequest = {
|
|
script: 'evw-test',
|
|
appUid: 'app-test',
|
|
handler: 'h',
|
|
token: 't',
|
|
key: 'k1:test',
|
|
event: {},
|
|
ctx: {},
|
|
};
|
|
|
|
const makeClient = () => new EventsWorkerInvokerClient({} as unknown as IConfig);
|
|
|
|
/** Answers `missing` until it sees the deployed header, then settles. */
|
|
const missUntilDeployedFetch = (
|
|
headersSeen: Array<Record<string, string>>,
|
|
): FetchImpl =>
|
|
(async (_url: string, init?: RequestInit) => {
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
headersSeen.push(headers);
|
|
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
|
return new Response(null, {
|
|
status: 200,
|
|
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
|
});
|
|
return new Response(null, {
|
|
status: 404,
|
|
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
|
});
|
|
}) as unknown as FetchImpl;
|
|
|
|
/** Answers `missing` no matter what, deployed header included. */
|
|
const alwaysMissingFetch = (calls: unknown[]): FetchImpl =>
|
|
(async () => {
|
|
calls.push(null);
|
|
return new Response(null, {
|
|
status: 404,
|
|
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
|
});
|
|
}) as unknown as FetchImpl;
|
|
|
|
/**
|
|
* Answers `deploy-timeout` once, then 200 once it sees the deployed header —
|
|
* the shape of a script that finished propagating between the two calls.
|
|
*/
|
|
const deployTimeoutThenOkFetch = (
|
|
headersSeen: Array<Record<string, string>>,
|
|
): FetchImpl =>
|
|
(async (_url: string, init?: RequestInit) => {
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
headersSeen.push(headers);
|
|
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
|
return new Response(null, {
|
|
status: 200,
|
|
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
|
});
|
|
return new Response(null, {
|
|
status: 503,
|
|
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout' },
|
|
});
|
|
}) as unknown as FetchImpl;
|
|
|
|
describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
|
it('deploys the script and retries once with the deployed header', async () => {
|
|
const headersSeen: Array<Record<string, string>> = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: missUntilDeployedFetch(headersSeen),
|
|
}),
|
|
);
|
|
const missCalls: Array<[string, string]> = [];
|
|
client.setMissHandler(async (appUid, script) => {
|
|
missCalls.push([appUid, script]);
|
|
return 'deployed';
|
|
});
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
expect(missCalls).toEqual([[REQUEST.appUid, REQUEST.script]]);
|
|
expect(headersSeen).toHaveLength(2);
|
|
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
|
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
|
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
|
});
|
|
|
|
it('does not retry when the miss handler reports the script is stale', async () => {
|
|
const calls: unknown[] = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: alwaysMissingFetch(calls),
|
|
}),
|
|
);
|
|
client.setMissHandler(async () => 'stale');
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(result).toEqual({
|
|
outcome: 'retriable',
|
|
status: null,
|
|
error: 'deploy: stale',
|
|
});
|
|
});
|
|
|
|
it('never asks the miss handler twice for one invocation', async () => {
|
|
const calls: unknown[] = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: alwaysMissingFetch(calls),
|
|
}),
|
|
);
|
|
let handlerCalls = 0;
|
|
client.setMissHandler(async () => {
|
|
handlerCalls++;
|
|
return 'deployed';
|
|
});
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
expect(handlerCalls).toBe(1);
|
|
// The first send plus the one retry — never a third.
|
|
expect(calls).toHaveLength(2);
|
|
expect(result.outcome).toBe('retriable');
|
|
expect(result.status).toBeNull();
|
|
});
|
|
|
|
it('leaves behavior unchanged with no miss handler set', async () => {
|
|
const calls: unknown[] = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: alwaysMissingFetch(calls),
|
|
}),
|
|
);
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(result).toEqual({
|
|
outcome: 'retriable',
|
|
status: null,
|
|
error: 'dispatcher: missing (404)',
|
|
});
|
|
});
|
|
|
|
it('does not trigger the miss handler for a forbidden dispatch reason', async () => {
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: stubFetch(403, {
|
|
[EVENTS_DISPATCH_ERROR_HEADER]: 'forbidden',
|
|
}),
|
|
}),
|
|
);
|
|
let handlerCalled = false;
|
|
client.setMissHandler(async () => {
|
|
handlerCalled = true;
|
|
return 'deployed';
|
|
});
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
expect(handlerCalled).toBe(false);
|
|
expect(result).toEqual({
|
|
outcome: 'retriable',
|
|
status: null,
|
|
error: 'dispatcher: forbidden (403)',
|
|
});
|
|
});
|
|
|
|
it('retries a deploy-timeout with the deployed header instead of redeploying', async () => {
|
|
const headersSeen: Array<Record<string, string>> = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: deployTimeoutThenOkFetch(headersSeen),
|
|
}),
|
|
);
|
|
const deployCalls: unknown[] = [];
|
|
client.setMissHandler(async () => {
|
|
deployCalls.push(null);
|
|
return 'deployed';
|
|
});
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
// The script was already deployed once — the one that led to this
|
|
// deploy-timeout answer — so this client must not add a second.
|
|
expect(deployCalls).toHaveLength(0);
|
|
expect(headersSeen).toHaveLength(2);
|
|
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
|
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
|
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
|
});
|
|
|
|
it('gives up after one retry when propagation is still not done', async () => {
|
|
const calls: unknown[] = [];
|
|
const client = makeClient();
|
|
client.setTransport(
|
|
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
|
fetchImpl: (async () => {
|
|
calls.push(null);
|
|
return new Response(null, {
|
|
status: 503,
|
|
headers: {
|
|
[EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout',
|
|
},
|
|
});
|
|
}) as unknown as FetchImpl,
|
|
}),
|
|
);
|
|
const deployCalls: unknown[] = [];
|
|
client.setMissHandler(async () => {
|
|
deployCalls.push(null);
|
|
return 'deployed';
|
|
});
|
|
|
|
const result = await client.invoke(REQUEST);
|
|
|
|
// The first send plus the one retry — never a third, and never a
|
|
// deploy. The delivery comes back on its own backoff instead.
|
|
expect(calls).toHaveLength(2);
|
|
expect(deployCalls).toHaveLength(0);
|
|
expect(result).toEqual({
|
|
outcome: 'retriable',
|
|
status: null,
|
|
error: 'dispatcher: deploy-timeout (503)',
|
|
});
|
|
});
|
|
});
|