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),
+ );