mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-07 00:32:19 +00:00
improve: reduce idle delivery and notification database work (#168643)
* perf(sqlite): reuse idle delivery and notification facts Keep queue and Web Push presence in the existing physical-database receipt owner. Preserve separate audit durability points while omitting readback for current newly inserted events. Audit collection, retention, and schema are unchanged. * fix(tooling): include delivery cache in extracted PR wrappers
This commit is contained in:
parent
53b8118647
commit
566005dc13
13 changed files with 245 additions and 79 deletions
|
|
@ -36,6 +36,14 @@ are not part of the correctness contract. SQLite statements in autocommit see
|
|||
committed rows without a freshness query. Keep explicit read transactions only
|
||||
where multiple statements need one consistent snapshot.
|
||||
|
||||
Idle outbound recovery and the Web Push subscription-presence check reuse
|
||||
physical-database facts on the Gateway host, avoiding worker requests as well as
|
||||
SQL. Queue insertions and replacements invalidate the empty recovery fact;
|
||||
subscription mutations publish or invalidate presence through the existing
|
||||
commit receipts. These facts do not authorize sends: delivery custody and current
|
||||
subscription policy remain with their existing owners. Mention Inbox head checks
|
||||
use the same physical-database admission lifecycle.
|
||||
|
||||
Outside writers, including CLI commands, Doctor, cron processes, native companion
|
||||
processes, and plugin children, must route through the Gateway or acquire exclusive
|
||||
ownership while it is stopped. Existing direct writers that have not adopted that
|
||||
|
|
|
|||
|
|
@ -824,6 +824,7 @@ src/infra/dedupe.ts
|
|||
src/infra/deep-merge.ts
|
||||
src/infra/deferred-plugin-migrations.contract.ts
|
||||
src/infra/deferred-plugin-migrations.ts
|
||||
src/infra/delivery-queue-cache.ts
|
||||
src/infra/delivery-queue-sqlite-bound.ts
|
||||
src/infra/delivery-queue-sqlite.types.ts
|
||||
src/infra/diagnostic-event-listener-presence.ts
|
||||
|
|
|
|||
|
|
@ -637,8 +637,14 @@ export function recordAuditEventInDatabase(
|
|||
if (!Number.isSafeInteger(insertedSequence) || insertedSequence < 1) {
|
||||
throw new Error("audit event sequence is outside the supported integer range");
|
||||
}
|
||||
pruneAuditEventsAfterInsert(db, Date.now());
|
||||
const row = queries.read(insertedSequence);
|
||||
const now = Date.now();
|
||||
pruneAuditEventsAfterInsert(db, now);
|
||||
// Current events cannot be removed by retention: sequence allocation puts
|
||||
// them after every existing row. An expired replay may be pruned immediately.
|
||||
const row =
|
||||
values.occurred_at < now - AUDIT_EVENT_RETENTION_MS
|
||||
? queries.read(insertedSequence)
|
||||
: { ...values, sequence: insertedSequence };
|
||||
recordConfirmedTerminalMessageExecutionBinding(db, {
|
||||
eventId: row?.event_id,
|
||||
token: executionToken,
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ describe("audit event persistence", () => {
|
|||
...database,
|
||||
database: openOpenClawStateDatabase(database),
|
||||
});
|
||||
recordAuditEventInDatabase(
|
||||
const middle = recordAuditEventInDatabase(
|
||||
auditInput({
|
||||
occurredAt: now + 1,
|
||||
sourceSequence: 2,
|
||||
|
|
@ -179,7 +179,7 @@ describe("audit event persistence", () => {
|
|||
}),
|
||||
{ ...database, database: openOpenClawStateDatabase(database) },
|
||||
);
|
||||
recordAuditEventInDatabase(
|
||||
const newest = recordAuditEventInDatabase(
|
||||
auditInput({
|
||||
occurredAt: now + 2,
|
||||
sourceSequence: 3,
|
||||
|
|
@ -195,12 +195,14 @@ describe("audit event persistence", () => {
|
|||
);
|
||||
|
||||
const first = await listAuditEvents({ database, limit: 2 });
|
||||
expect(first.events).toEqual([newest, middle]);
|
||||
expect(first.events.map((event) => event.sourceSequence)).toEqual([3, 2]);
|
||||
expect(first.nextCursor).toBe(first.events[1]?.sequence);
|
||||
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const second = await listAuditEvents({ database, limit: 2, cursor: first.nextCursor });
|
||||
expect(second.events).toEqual([oldest]);
|
||||
expect(second.events.map((event) => event.sourceSequence)).toEqual([1]);
|
||||
expect(second.events[0]?.eventId).toBe(oldest?.eventId);
|
||||
expect(second.nextCursor).toBeUndefined();
|
||||
|
|
@ -328,6 +330,42 @@ describe("audit event persistence", () => {
|
|||
expect(db.prepare("SELECT COUNT(*) AS count FROM audit_events").get()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it("returns an expired append only while bounded pruning still retains it", () => {
|
||||
const database = createDatabaseOptions();
|
||||
const owner = openOpenClawStateDatabase(database);
|
||||
const now = Date.now();
|
||||
const occurredAt = now - AUDIT_EVENT_RETENTION_MS_CONTRACT - 1_000;
|
||||
owner.db
|
||||
.prepare(
|
||||
`WITH RECURSIVE numbers(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < ?
|
||||
)
|
||||
INSERT INTO audit_events (
|
||||
event_id, source_id, source_sequence, occurred_at, kind, action, status,
|
||||
actor_type, actor_id, agent_id, run_id
|
||||
)
|
||||
SELECT 'old-event-' || n, 'old-source-' || n, n, ?, 'agent_run',
|
||||
'agent.run.started', 'started', 'agent', 'main', 'main', 'old-run'
|
||||
FROM numbers`,
|
||||
)
|
||||
.run(AUDIT_EVENT_PRUNE_BATCH_ROWS_CONTRACT, occurredAt);
|
||||
|
||||
const retained = recordAuditEventInDatabase(auditInput({ occurredAt: occurredAt + 1 }), {
|
||||
...database,
|
||||
database: owner,
|
||||
});
|
||||
expect(retained?.occurredAt).toBe(occurredAt + 1);
|
||||
expect(
|
||||
recordAuditEventInDatabase(auditInput({ occurredAt: occurredAt + 2 }), {
|
||||
...database,
|
||||
database: owner,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(owner.db.prepare("SELECT COUNT(*) AS count FROM audit_events").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reused run ids distinct across actual event timestamps", async () => {
|
||||
const database = createDatabaseOptions();
|
||||
const occurredAt = Date.now();
|
||||
|
|
|
|||
6
src/infra/delivery-queue-cache.ts
Normal file
6
src/infra/delivery-queue-cache.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { SqliteDatabaseAdmissionKey } from "./sqlite-database-admission.js";
|
||||
|
||||
export const emptyOutboundDeliveryQueueAdmission: SqliteDatabaseAdmissionKey<true | undefined> = {
|
||||
name: "state.empty-outbound-delivery-queue",
|
||||
read: (value) => (value === true ? true : undefined),
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
|
|||
import type { RawBuilder, Selectable } from "kysely";
|
||||
import type { OpenClawStateDatabase } from "../state/openclaw-state-db-contract.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { emptyOutboundDeliveryQueueAdmission } from "./delivery-queue-cache.js";
|
||||
import type { DeliveryQueueEntryState } from "./delivery-queue-sqlite.types.js";
|
||||
import {
|
||||
createSqliteQueryCache,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
prepareSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { publishSqliteDatabaseAdmission } from "./sqlite-database-admission.js";
|
||||
import { coerceRequiredSqliteNumber as sqliteNumber } from "./sqlite-number.js";
|
||||
|
||||
type QueueStatus = "pending" | "failed" | "completed";
|
||||
|
|
@ -302,7 +304,12 @@ export function upsertBoundDeliveryQueueEntryInDatabase(
|
|||
): boolean {
|
||||
const queries = deliveryQueueUpserts(database.db);
|
||||
const query = (queries[bound.mode] ??= createDeliveryQueueUpsert(database.db, bound.mode));
|
||||
return query(bound.row).numAffectedRows === 1n;
|
||||
const changed = query(bound.row).numAffectedRows === 1n;
|
||||
if (changed) {
|
||||
// Every queue insertion/replacement passes here; deletion cannot make an empty queue nonempty.
|
||||
publishSqliteDatabaseAdmission(database.db, emptyOutboundDeliveryQueueAdmission, undefined);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Recovery and media custody share the same inventory of unfinished work. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { OpenClawStateDatabase } from "../../state/openclaw-state-db-contract.js";
|
||||
import { emptyOutboundDeliveryQueueAdmission } from "../delivery-queue-cache.js";
|
||||
import {
|
||||
deliveryQueueEntriesQuery,
|
||||
inflateDeliveryQueueRow,
|
||||
|
|
@ -7,11 +8,12 @@ import {
|
|||
} from "../delivery-queue-sqlite-bound.js";
|
||||
import { transitionOwnedDeliveryQueueEntryInDatabase } from "../delivery-queue-sqlite-claim.kernel.js";
|
||||
import { upsertDeliveryQueueEntryInDatabase } from "../delivery-queue-sqlite.kernel.js";
|
||||
import { createSqliteQueryCache, executeSqliteQuerySync } from "../kysely-sync.js";
|
||||
import { executeSqliteQuerySync } from "../kysely-sync.js";
|
||||
import {
|
||||
getSqliteReadOperationRevision,
|
||||
type SqliteReadOperationRevision,
|
||||
} from "../sqlite-schema-facts.js";
|
||||
getSqliteDatabaseAdmission,
|
||||
publishSqliteDatabaseAdmission,
|
||||
readSqliteDatabaseWriteRevision,
|
||||
} from "../sqlite-database-admission.js";
|
||||
import {
|
||||
OUTBOUND_EXECUTABLE_QUEUE_NAMES,
|
||||
outboundDeliveryQueueName,
|
||||
|
|
@ -21,10 +23,6 @@ import { projectOutboundDelivery } from "./delivery-queue-projection.js";
|
|||
import type { OutboundDeliveryStorageEntry } from "./delivery-queue-storage.types.js";
|
||||
import type { QueuedDelivery } from "./delivery-queue-types.js";
|
||||
|
||||
const unfinishedDeliveryFacts = createSqliteQueryCache<{
|
||||
empty?: SqliteReadOperationRevision;
|
||||
}>(() => ({}));
|
||||
|
||||
/** Restore the exact pre-attempt row while its original owner still holds custody. */
|
||||
export function restoreDeliveryAttemptBeforeDispatchInDatabase(
|
||||
database: OpenClawStateDatabase,
|
||||
|
|
@ -85,14 +83,11 @@ export function readOutboundDeliveriesInDatabase(
|
|||
database: Pick<OpenClawStateDatabase, "db">,
|
||||
input: { id?: string; mode: "pending" | "unfinished" },
|
||||
): OutboundDeliveryStorageEntry[] {
|
||||
const idle =
|
||||
input.id === undefined && input.mode === "unfinished"
|
||||
? unfinishedDeliveryFacts(database.db)
|
||||
: undefined;
|
||||
const revision = idle && getSqliteReadOperationRevision(database.db);
|
||||
if (revision && idle?.empty === revision) {
|
||||
const idle = input.id === undefined && input.mode === "unfinished";
|
||||
if (idle && getSqliteDatabaseAdmission(database.db, emptyOutboundDeliveryQueueAdmission)) {
|
||||
return [];
|
||||
}
|
||||
const revision = idle && readSqliteDatabaseWriteRevision(database.db);
|
||||
let query = deliveryQueueEntriesQuery(database, OUTBOUND_EXECUTABLE_QUEUE_NAMES, input.mode)
|
||||
.select("queue_name")
|
||||
.orderBy("enqueued_at", "asc")
|
||||
|
|
@ -112,12 +107,17 @@ export function readOutboundDeliveriesInDatabase(
|
|||
seen.add(entry.id);
|
||||
return [{ queueName: row.queue_name, entry: projectOutboundDelivery(row.queue_name, entry) }];
|
||||
});
|
||||
if (idle) {
|
||||
// The enclosing read admission probes foreign commits on every new poll.
|
||||
idle.empty =
|
||||
entries.length === 0 && getSqliteReadOperationRevision(database.db) === revision
|
||||
? revision
|
||||
: undefined;
|
||||
if (
|
||||
idle &&
|
||||
!database.db.isTransaction &&
|
||||
revision !== undefined &&
|
||||
revision === readSqliteDatabaseWriteRevision(database.db)
|
||||
) {
|
||||
publishSqliteDatabaseAdmission(
|
||||
database.db,
|
||||
emptyOutboundDeliveryQueueAdmission,
|
||||
entries.length === 0 ? true : undefined,
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
hydrateOpenClawStateWorkerError,
|
||||
retainOpenClawStateWorkerErrorPayload,
|
||||
} from "../../state/openclaw-state-worker-error.js";
|
||||
import { emptyOutboundDeliveryQueueAdmission } from "../delivery-queue-cache.js";
|
||||
import type { InitialDeliveryProducerClaim } from "../delivery-queue-sqlite-claim.kernel.js";
|
||||
import {
|
||||
captureDeliveryQueueStateContext,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
import { executeDeliveryQueueOperation } from "../delivery-queue-worker-store.js";
|
||||
import type { DeliveryQueueWorkerOperations } from "../delivery-queue.worker-contract.js";
|
||||
import { generateSecureUuid } from "../secure-random.js";
|
||||
import { getOrLoadSqliteDatabaseAdmissionForPath } from "../sqlite-database-admission.js";
|
||||
import { createSqliteWorkerOperationAdmission } from "../sqlite-worker-operation-admission.js";
|
||||
import type { SqliteWorkerOperationSettlement } from "../sqlite-worker-operation-settlement.js";
|
||||
import { OutboundDeliveryError, PlatformMessageNotDispatchedError } from "./deliver-types.js";
|
||||
|
|
@ -362,6 +364,18 @@ async function readOutboundDeliveries(
|
|||
context?: DeliveryQueueStateContext,
|
||||
): Promise<QueuedDelivery[]> {
|
||||
const captured = context ?? captureDeliveryQueueStateContext(stateDir);
|
||||
if (
|
||||
input.id === undefined &&
|
||||
input.mode === "unfinished" &&
|
||||
getOrLoadSqliteDatabaseAdmissionForPath(
|
||||
captured.workerContext.admission.databasePath,
|
||||
emptyOutboundDeliveryQueueAdmission,
|
||||
() => undefined,
|
||||
)
|
||||
) {
|
||||
captured.workerContext.admission.assertCurrent();
|
||||
return [];
|
||||
}
|
||||
const reply = await executeExistingOpenClawStateRead(
|
||||
{
|
||||
path: captured.workerContext.admission.databasePath,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import {
|
|||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { updateDeliveryQueueEntryInDatabase } from "../delivery-queue-sqlite.kernel.js";
|
||||
import { openNodeSqliteDatabase } from "../node-sqlite.js";
|
||||
import {
|
||||
updateDeliveryQueueEntryInDatabase,
|
||||
upsertDeliveryQueueEntryInDatabase,
|
||||
} from "../delivery-queue-sqlite.kernel.js";
|
||||
import { runSqliteReadOperationSync } from "../sqlite-schema-facts.js";
|
||||
import { failPendingDelivery } from "./delivery-queue-ack.js";
|
||||
import { ackDeliveryInDatabase } from "./delivery-queue-ack.kernel.js";
|
||||
|
|
@ -25,6 +27,7 @@ import {
|
|||
failDeliveryAfterPlatformSend,
|
||||
failDeliveryBeforePlatformSend,
|
||||
loadPendingDelivery,
|
||||
loadUnfinishedDeliveries,
|
||||
markDeliveryPlatformOutcomeUnknown,
|
||||
markDeliveryPlatformSendDispatched,
|
||||
markDeliveryPlatformSendAttemptStarted,
|
||||
|
|
@ -41,56 +44,52 @@ describe("delivery-queue storage", () => {
|
|||
const enqueueTextDelivery = (params: Parameters<typeof enqueueDelivery>[0], rootDir = tmpDir()) =>
|
||||
enqueueDelivery(params, rootDir);
|
||||
|
||||
it("skips unchanged empty recovery scans and observes foreign, local, and rolled-back writes", async () => {
|
||||
const options = { env: { ...process.env, OPENCLAW_STATE_DIR: tmpDir() } };
|
||||
it("refreshes empty recovery facts after worker and native writes without publishing rollback", async () => {
|
||||
const stateDir = tmpDir();
|
||||
const options = { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } };
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
const reads = trackSqliteStatementExecutions(database.db, ["queue"], (sql) =>
|
||||
/^select .+ from "delivery_queue_entries"/i.test(sql) ? "queue" : null,
|
||||
);
|
||||
const read = () =>
|
||||
runSqliteReadOperationSync(database.db, () =>
|
||||
readOutboundDeliveriesInDatabase(database, { mode: "unfinished" }),
|
||||
);
|
||||
const peer = openNodeSqliteDatabase(database.path);
|
||||
try {
|
||||
expect(read()).toEqual([]);
|
||||
expect(read()).toEqual([]);
|
||||
expect(reads.counts.queue).toBe(1);
|
||||
const read = () => loadUnfinishedDeliveries(stateDir);
|
||||
expect(await read()).toEqual([]);
|
||||
expect(await read()).toEqual([]);
|
||||
|
||||
const id = await enqueueTextDelivery({
|
||||
channel: "directchat",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "Queued after an idle poll" }],
|
||||
});
|
||||
expect(read().map(({ entry }) => entry.id)).toEqual([id]);
|
||||
database.db
|
||||
.prepare("UPDATE delivery_queue_entries SET status = 'completed' WHERE id = ?")
|
||||
.run(id);
|
||||
expect(read()).toEqual([]);
|
||||
expect(read()).toEqual([]);
|
||||
expect(reads.counts.queue).toBe(3);
|
||||
|
||||
peer.prepare("UPDATE delivery_queue_entries SET status = 'pending' WHERE id = ?").run(id);
|
||||
expect(read().map(({ entry }) => entry.id)).toEqual([id]);
|
||||
expect(reads.counts.queue).toBe(4);
|
||||
|
||||
expect(() =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
() => {
|
||||
database.db
|
||||
.prepare("UPDATE delivery_queue_entries SET status = 'completed' WHERE id = ?")
|
||||
.run(id);
|
||||
expect(read()).toEqual([]);
|
||||
throw new Error("roll back recovery fixture");
|
||||
},
|
||||
{ ...options, database },
|
||||
),
|
||||
).toThrow("roll back recovery fixture");
|
||||
expect(read().map(({ entry }) => entry.id)).toEqual([id]);
|
||||
} finally {
|
||||
peer.close();
|
||||
reads.restore();
|
||||
const id = await enqueueTextDelivery({
|
||||
channel: "directchat",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "Queued after an idle poll" }],
|
||||
});
|
||||
expect((await read()).map((entry) => entry.id)).toEqual([id]);
|
||||
const entry = await loadPendingDelivery(id, stateDir);
|
||||
if (!entry) {
|
||||
throw new Error("Expected pending delivery");
|
||||
}
|
||||
await ackDelivery(id, stateDir);
|
||||
expect(await read()).toEqual([]);
|
||||
expect(await read()).toEqual([]);
|
||||
|
||||
expect(() =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
() => {
|
||||
upsertDeliveryQueueEntryInDatabase(
|
||||
{ queueName: OUTBOUND_DELIVERY_QUEUE_NAME, entry },
|
||||
database,
|
||||
);
|
||||
expect(
|
||||
runSqliteReadOperationSync(database.db, () =>
|
||||
readOutboundDeliveriesInDatabase(database, { mode: "unfinished" }),
|
||||
).map(({ entry: pending }) => pending.id),
|
||||
).toEqual([id]);
|
||||
throw new Error("roll back recovery fixture");
|
||||
},
|
||||
{ ...options, database },
|
||||
),
|
||||
).toThrow("roll back recovery fixture");
|
||||
expect(await read()).toEqual([]);
|
||||
|
||||
upsertDeliveryQueueEntryInDatabase(
|
||||
{ queueName: OUTBOUND_DELIVERY_QUEUE_NAME, entry },
|
||||
database,
|
||||
);
|
||||
expect((await read()).map((pending) => pending.id)).toEqual([id]);
|
||||
});
|
||||
|
||||
function readStatus(id: string): string | undefined {
|
||||
|
|
|
|||
6
src/infra/push-web-store.cache.ts
Normal file
6
src/infra/push-web-store.cache.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { SqliteDatabaseAdmissionKey } from "./sqlite-database-admission.js";
|
||||
|
||||
export const boundWebPushSubscriptionsAdmission: SqliteDatabaseAdmissionKey<boolean> = {
|
||||
name: "state.web-push-bound-subscriptions",
|
||||
read: (value) => (typeof value === "boolean" ? value : undefined),
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { normalizeWebPushDevicePreferences } from "./push-web-preferences.js";
|
||||
import { boundWebPushSubscriptionsAdmission } from "./push-web-store.cache.js";
|
||||
import {
|
||||
WEB_PUSH_VAPID_STATE_KEY,
|
||||
WebPushSubscriptionBindingError,
|
||||
|
|
@ -32,6 +33,11 @@ import {
|
|||
type WebPushMutationProfiles,
|
||||
type WebPushMutationProfileFacts,
|
||||
} from "./push-web-store.records.js";
|
||||
import {
|
||||
getSqliteDatabaseAdmission,
|
||||
publishSqliteDatabaseAdmission,
|
||||
readSqliteDatabaseWriteRevision,
|
||||
} from "./sqlite-database-admission.js";
|
||||
import { requestSqliteWorkerOperationAdmission } from "./sqlite-worker-operation-admission.js";
|
||||
import { getSqliteWorkerStateContext } from "./sqlite-worker-state-context.js";
|
||||
|
||||
|
|
@ -188,7 +194,12 @@ export function listWebPushSubscriptionsInDatabase(
|
|||
export function hasBoundWebPushSubscriptionsInDatabase(database: OpenClawStateDatabase): boolean {
|
||||
ensureWebPushSubscriptionBindingSchema(database);
|
||||
const { db } = database;
|
||||
return Boolean(
|
||||
const admitted = getSqliteDatabaseAdmission(db, boundWebPushSubscriptionsAdmission);
|
||||
if (admitted !== undefined) {
|
||||
return admitted;
|
||||
}
|
||||
const revision = readSqliteDatabaseWriteRevision(db);
|
||||
const hasBound = Boolean(
|
||||
executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
getNodeSqliteKysely<WebPushDatabase>(db)
|
||||
|
|
@ -199,6 +210,10 @@ export function hasBoundWebPushSubscriptionsInDatabase(database: OpenClawStateDa
|
|||
.limit(1),
|
||||
),
|
||||
);
|
||||
if (revision !== undefined && revision === readSqliteDatabaseWriteRevision(db)) {
|
||||
publishSqliteDatabaseAdmission(db, boundWebPushSubscriptionsAdmission, hasBound);
|
||||
}
|
||||
return hasBound;
|
||||
}
|
||||
|
||||
/** Lists only subscriptions reconciled by an authenticated browser device. */
|
||||
|
|
@ -501,6 +516,11 @@ export function upsertWebPushSubscriptionInDatabase(
|
|||
}),
|
||||
),
|
||||
);
|
||||
publishSqliteDatabaseAdmission(
|
||||
db,
|
||||
boundWebPushSubscriptionsAdmission,
|
||||
row.device_id ? true : undefined,
|
||||
);
|
||||
return subscription;
|
||||
});
|
||||
}
|
||||
|
|
@ -527,7 +547,11 @@ export function deleteBoundWebPushSubscriptionInDatabase(
|
|||
params.expectedUserProfileId,
|
||||
),
|
||||
);
|
||||
return Number(result.numAffectedRows ?? 0) > 0;
|
||||
const deleted = Number(result.numAffectedRows ?? 0) > 0;
|
||||
if (deleted) {
|
||||
publishSqliteDatabaseAdmission(db, boundWebPushSubscriptionsAdmission, undefined);
|
||||
}
|
||||
return deleted;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -551,7 +575,11 @@ export function deleteWebPushSubscriptionIfCurrentInDatabase(params: {
|
|||
.where("auth", "=", subscription.keys.auth)
|
||||
.where("updated_at_ms", "=", subscription.updatedAtMs),
|
||||
);
|
||||
return Number(result.numAffectedRows ?? 0) > 0;
|
||||
const deleted = Number(result.numAffectedRows ?? 0) > 0;
|
||||
if (deleted) {
|
||||
publishSqliteDatabaseAdmission(db, boundWebPushSubscriptionsAdmission, undefined);
|
||||
}
|
||||
return deleted;
|
||||
}, webPushDatabaseOptions(params.database));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
executeOpenClawStateWorker,
|
||||
runOpenClawStateWorkerOperation,
|
||||
} from "../state/openclaw-state-worker-store.js";
|
||||
import { boundWebPushSubscriptionsAdmission } from "./push-web-store.cache.js";
|
||||
import {
|
||||
WebPushSubscriptionBindingError,
|
||||
type WebPushMutationGuard,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
type WebPushSnapshotAction,
|
||||
} from "./push-web-store.scope.js";
|
||||
import type { WebPushWorkerOperations } from "./push-web-store.worker-contract.js";
|
||||
import { getOrLoadSqliteDatabaseAdmissionForPath } from "./sqlite-database-admission.js";
|
||||
import { createSqliteWorkerOperationAdmission } from "./sqlite-worker-operation-admission.js";
|
||||
export {
|
||||
WebPushSubscriptionBindingError,
|
||||
|
|
@ -177,7 +179,16 @@ export function listWebPushSubscriptions(stateDir?: string) {
|
|||
}
|
||||
|
||||
export function hasBoundWebPushSubscriptions(stateDir?: string) {
|
||||
return executeWebPushCommand("webPush.hasBoundWebPushSubscriptions", undefined, stateDir);
|
||||
const captured = context(stateDir);
|
||||
captured.admission.assertCurrent();
|
||||
const admitted = getOrLoadSqliteDatabaseAdmissionForPath(
|
||||
captured.admission.databasePath,
|
||||
boundWebPushSubscriptionsAdmission,
|
||||
() => undefined,
|
||||
);
|
||||
return admitted === undefined
|
||||
? executeWebPushCommand("webPush.hasBoundWebPushSubscriptions", undefined, captured)
|
||||
: Promise.resolve(admitted);
|
||||
}
|
||||
|
||||
export function prepareWebPushApprovalDeliveries(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
closeOpenClawStateDatabaseAsync,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import * as stateWorker from "../state/openclaw-state-worker-store.js";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
import {
|
||||
deleteWebPushApprovalDeliveryTargets,
|
||||
|
|
@ -136,6 +137,7 @@ beforeEach(async () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
|
@ -332,8 +334,13 @@ describe("subscription CRUD", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("keeps legacy unbound rows test-only until browser reconciliation", async () => {
|
||||
it("retains bound-subscription presence until registration or removal changes it", async () => {
|
||||
const execute = vi.spyOn(stateWorker, "executeOpenClawStateWorker");
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
execute.mockClear();
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
await registerSubscription(endpoint);
|
||||
expect(await readBoundSubscriptions(tmpDir)).toEqual([]);
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
|
|
@ -348,6 +355,9 @@ describe("subscription CRUD", () => {
|
|||
binding: { deviceId: "browser-device", userProfileId: null },
|
||||
});
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(true);
|
||||
execute.mockClear();
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(true);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(await readBoundSubscriptions(tmpDir)).toEqual([
|
||||
{
|
||||
...rebound,
|
||||
|
|
@ -356,6 +366,24 @@ describe("subscription CRUD", () => {
|
|||
devicePreferences: defaultDevicePreferences,
|
||||
},
|
||||
]);
|
||||
await registerSubscription(endpoint);
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
await registerSubscription(endpoint, {
|
||||
binding: { deviceId: "browser-device", userProfileId: null },
|
||||
});
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(true);
|
||||
expect(
|
||||
await clearBoundWebPushSubscription({
|
||||
endpoint,
|
||||
expectedDeviceId: "browser-device",
|
||||
expectedUserProfileId: null,
|
||||
baseDir: tmpDir,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
execute.mockClear();
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves bindings when an older writer updates only the original columns", async () => {
|
||||
|
|
@ -887,6 +915,20 @@ describe("sending", () => {
|
|||
expect(await listWebPushSubscriptions(tmpDir)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("refreshes bound-subscription presence after expired-delivery cleanup", async () => {
|
||||
await registerSubscription("https://push.example.com/expired-bound", {
|
||||
binding: { deviceId: "browser-device", userProfileId: null },
|
||||
});
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(true);
|
||||
vi.mocked(webPush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error("gone"), { statusCode: 410 }),
|
||||
);
|
||||
expect(await broadcastWebPush({ title: "Expired" }, tmpDir)).toEqual([
|
||||
expect.objectContaining({ ok: false, statusCode: 410 }),
|
||||
]);
|
||||
expect(await hasBoundWebPushSubscriptions(tmpDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delete an expired subscription after a legacy claim appears", async ({ signal }) => {
|
||||
const endpoint = "https://push.example.com/pending-claim";
|
||||
const subscription = await registerSubscription(endpoint);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue