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:
5
.changeset/fix-activity-log-duplicate-emit.md
Normal file
5
.changeset/fix-activity-log-duplicate-emit.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user