fix(core): suppress activity-log writes for polling-emitted events

When multiple TaskStore instances watch the same SQLite DB (dashboard +
engine runtime + per-project stores in one dashboard process), every
column move was recorded once per polling instance — inflating
task:moved rows 3x and amplifying failure noise (146k+ moves and 781
task:failed events in 24h against ~165 real done tasks).

Set suppressActivityLogForPollingEmit while checkForChanges fires
re-emit events, and skip the activity-log listeners when it's set. The
originating instance's in-process emit path remains the sole writer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-19 16:25:53 -07:00
parent 95707dc98f
commit 19e2ff0279
3 changed files with 77 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix activity-log triple-write caused by multiple TaskStore instances polling the same SQLite DB. When the dashboard, engine runtime, and per-project stores each `watch()` the same database, every column move was previously recorded once per instance — inflating `task:moved` rows ~3x (146k+/day) and amplifying failure noise. TaskStore now suppresses activity-log writes for events re-emitted from its polling loop, leaving the originating instance as the sole audit writer.

View File

@@ -260,6 +260,32 @@ describe("TaskStore", () => {
expect(logs[0].type).toBe("settings:updated"); expect(logs[0].type).toBe("settings:updated");
}); });
it("a second TaskStore polling the same DB does not double-log activity", async () => {
// Reproduces the duplicate-emitter bug: dashboard + engine each construct
// their own TaskStore against the same SQLite file. Without suppression,
// every move was recorded once per polling instance.
const observer = new TaskStore(rootDir, globalDir);
await observer.init();
await observer.watch();
try {
const task = await store.createTask({ description: "Test polling dedup" });
await store.moveTask(task.id, "todo");
// Drive the observer's poll cycle directly so we don't wait 1s.
await (observer as any).checkForChanges();
await new Promise((r) => setTimeout(r, 10));
const movedLogs = await store.getActivityLog({ type: "task:moved" });
const moves = movedLogs.filter((l) => l.taskId === task.id);
expect(moves).toHaveLength(1);
const createdLogs = await store.getActivityLog({ type: "task:created" });
const creates = createdLogs.filter((l) => l.taskId === task.id);
expect(creates).toHaveLength(1);
} finally {
await observer.close();
}
});
it("records activity on task:deleted", async () => { it("records activity on task:deleted", async () => {
const task = await store.createTask({ description: "Test deleted event" }); const task = await store.createTask({ description: "Test deleted event" });
await store.deleteTask(task.id); await store.deleteTask(task.id);

View File

@@ -834,6 +834,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/** SQLite database for structured data storage */ /** SQLite database for structured data storage */
private _db: Database | null = null; private _db: Database | null = null;
private activityListenersWired = false; private activityListenersWired = false;
/**
* When true, the activity-log listeners skip recording. Set by the polling
* loop (`checkForChanges`) so that events re-emitted after observing another
* TaskStore instance's DB write don't double- or triple-log to activityLog.
* The in-process emit path (moveTask, updateTask, etc.) leaves this false
* and remains the sole source of truth for activity rows.
*/
private suppressActivityLogForPollingEmit = false;
/** Separate SQLite database for compact archived task snapshots. */ /** Separate SQLite database for compact archived task snapshots. */
private _archiveDb: ArchiveDatabase | null = null; private _archiveDb: ArchiveDatabase | null = null;
@@ -2069,6 +2077,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Task created // Task created
this.on("task:created", (task) => { this.on("task:created", (task) => {
if (this.suppressActivityLogForPollingEmit) return;
this.recordActivityFromListener( this.recordActivityFromListener(
{ {
type: "task:created", type: "task:created",
@@ -2082,6 +2091,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Task moved // Task moved
this.on("task:moved", (data) => { this.on("task:moved", (data) => {
if (this.suppressActivityLogForPollingEmit) return;
this.recordActivityFromListener( this.recordActivityFromListener(
{ {
type: "task:moved", type: "task:moved",
@@ -2111,6 +2121,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Task updated (check for failures) // Task updated (check for failures)
this.on("task:updated", (task) => { this.on("task:updated", (task) => {
if (this.suppressActivityLogForPollingEmit) return;
if (task.status === "failed") { if (task.status === "failed") {
this.recordActivityFromListener( this.recordActivityFromListener(
{ {
@@ -2155,6 +2166,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Task deleted // Task deleted
this.on("task:deleted", (task) => { this.on("task:deleted", (task) => {
if (this.suppressActivityLogForPollingEmit) return;
this.recordActivityFromListener( this.recordActivityFromListener(
{ {
type: "task:deleted", type: "task:deleted",
@@ -6616,13 +6628,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const cached = this.taskCache.get(id); const cached = this.taskCache.get(id);
if (!cached) continue; if (!cached) continue;
this.taskCache.delete(id); this.taskCache.delete(id);
if (archivedSet.has(id)) { this.suppressActivityLogForPollingEmit = true;
// Task moved to archive — emit task:moved (matching what try {
// archiveTask emits in-process) so the activity-log listener if (archivedSet.has(id)) {
// records it correctly. // Task moved to archive — emit task:moved (matching what
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" }); // archiveTask emits in-process) so other subscribers can react.
} else { // Activity-log listeners skip this emit; the originating
this.emit("task:deleted", cached); // TaskStore instance wrote the row in-process.
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" });
} else {
this.emit("task:deleted", cached);
}
} finally {
this.suppressActivityLogForPollingEmit = false;
} }
} }
} }
@@ -6643,24 +6661,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const task = this.rowToTask(row); const task = this.rowToTask(row);
const cached = this.taskCache.get(task.id); const cached = this.taskCache.get(task.id);
if (task.deletedAt) { this.suppressActivityLogForPollingEmit = true;
if (cached) { try {
this.taskCache.delete(task.id); if (task.deletedAt) {
this.emit("task:deleted", cached); if (cached) {
this.taskCache.delete(task.id);
this.emit("task:deleted", cached);
}
continue;
} }
continue;
}
if (!cached) { if (!cached) {
this.taskCache.set(task.id, { ...task }); this.taskCache.set(task.id, { ...task });
this.emit("task:created", task); this.emit("task:created", task);
} else if (cached.column !== task.column) { } else if (cached.column !== task.column) {
const from = cached.column; const from = cached.column;
this.taskCache.set(task.id, { ...task }); this.taskCache.set(task.id, { ...task });
this.emit("task:moved", { task, from, to: task.column, source: "engine" }); this.emit("task:moved", { task, from, to: task.column, source: "engine" });
} else { } else {
this.taskCache.set(task.id, { ...task }); this.taskCache.set(task.id, { ...task });
this.emit("task:updated", task); this.emit("task:updated", task);
}
} finally {
this.suppressActivityLogForPollingEmit = false;
} }
// Yield every ~50 rows to prevent blocking the event loop during large updates // Yield every ~50 rows to prevent blocking the event loop during large updates