diff --git a/src/backend/clients/database/DatabaseClient.test.ts b/src/backend/clients/database/DatabaseClient.test.ts index 04ca504a9..65071c617 100644 --- a/src/backend/clients/database/DatabaseClient.test.ts +++ b/src/backend/clients/database/DatabaseClient.test.ts @@ -285,6 +285,18 @@ describe('AbstractDatabaseClient — SQL generation', () => { ); }); + it('reads the database clock as epoch milliseconds in each dialect', () => { + expect(sqlite().nowMsExpr()).toBe( + "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)", + ); + expect(mysql().nowMsExpr()).toBe( + "(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)", + ); + expect(postgres().nowMsExpr()).toBe( + 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)', + ); + }); + it('describes a stored timestamp the way each driver reads it back', () => { const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000; expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09'); diff --git a/src/backend/clients/database/DatabaseClient.ts b/src/backend/clients/database/DatabaseClient.ts index b4ba0b82b..0e60cc93b 100644 --- a/src/backend/clients/database/DatabaseClient.ts +++ b/src/backend/clients/database/DatabaseClient.ts @@ -401,6 +401,21 @@ export class AbstractDatabaseClient extends PuterClient { }); } + /** + * The database's own clock as epoch milliseconds, so nodes whose clocks + * disagree still agree on it. The mysql form ignores the session time zone, + * so a DST change can't shift it. + */ + nowMsExpr(): string { + return this.case({ + sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)", + postgres: + 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)', + otherwise: + "(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)", + }); + } + /** * What a read of a timestamp column holding `seconds` returns: sqlite gives * back the UTC text it stored, mysql2 and pg a `Date` read in node's time diff --git a/src/backend/controllers/events/limits.ts b/src/backend/controllers/events/limits.ts index 6ac426ef5..88872ec39 100644 --- a/src/backend/controllers/events/limits.ts +++ b/src/backend/controllers/events/limits.ts @@ -448,11 +448,13 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000; // finish backs off the same way but is not the handler's failure, so it never // counts toward that run. -/** Wait before the first retry of a delivery a handler could not take. */ -export const EVENTS_RETRY_BASE_MS = 2_000; - -/** Longest wait between retries, however many have failed. */ -export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000; +// Wait before the first retry of a delivery a handler could not take, the +// longest wait, and the doubling between them. Shared with system jobs. +export { + deliveryBackoffMs, + EVENTS_RETRY_BASE_MS, + EVENTS_RETRY_MAX_MS, +} from '../../util/retryBackoff.js'; /** * Handler failures in a row before a subscription is suspended. Counted per @@ -468,13 +470,6 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5; */ export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000; -/** How long to hold a delivery whose handler has failed `attempts` times. */ -export const deliveryBackoffMs = (attempts: number): number => - Math.min( - EVENTS_RETRY_MAX_MS, - EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), - ); - // -- Suspended backlog ----------------------------------------------- // // A suspended subscription stops metering but keeps what it is owed, and that diff --git a/src/backend/util/retryBackoff.test.ts b/src/backend/util/retryBackoff.test.ts new file mode 100644 index 000000000..fdce55ed6 --- /dev/null +++ b/src/backend/util/retryBackoff.test.ts @@ -0,0 +1,34 @@ +/* + * 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 . + */ + +import { describe, expect, it } from 'vitest'; +import { + deliveryBackoffMs, + EVENTS_RETRY_BASE_MS, + EVENTS_RETRY_MAX_MS, +} from './retryBackoff.js'; + +describe('deliveryBackoffMs', () => { + it('doubles from the base per failure and stops at the ceiling', () => { + expect(deliveryBackoffMs(0)).toBe(EVENTS_RETRY_BASE_MS); + expect(deliveryBackoffMs(1)).toBe(EVENTS_RETRY_BASE_MS); + expect(deliveryBackoffMs(3)).toBe(EVENTS_RETRY_BASE_MS * 4); + expect(deliveryBackoffMs(50)).toBe(EVENTS_RETRY_MAX_MS); + }); +}); diff --git a/src/backend/util/retryBackoff.ts b/src/backend/util/retryBackoff.ts new file mode 100644 index 000000000..82829a176 --- /dev/null +++ b/src/backend/util/retryBackoff.ts @@ -0,0 +1,34 @@ +/* + * 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 . + */ + +// A retry on a fixed cadence turns one broken dependency into a permanent load +// on it, so the wait doubles per attempt up to a ceiling. + +/** Wait before the first retry. */ +export const EVENTS_RETRY_BASE_MS = 2_000; + +/** Longest wait between retries, however many have failed. */ +export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000; + +/** How long to wait after `attempts` failures in a row. */ +export const deliveryBackoffMs = (attempts: number): number => + Math.min( + EVENTS_RETRY_MAX_MS, + EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), + );