diff --git a/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts index 6dda5604..28042b6d 100644 --- a/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts +++ b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts @@ -125,6 +125,7 @@ export class NoopMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJourna export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort { private readonly maxBytes: number; private readonly rotatedFileCount: number; + private readonly appendChains = new Map>(); constructor( private readonly paths: MemberWorkSyncStorePaths, @@ -136,9 +137,24 @@ export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJourna } async append(event: MemberWorkSyncAuditEvent): Promise { + const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName); + const previous = this.appendChains.get(filePath) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(() => this.appendToFile(filePath, event)); + + this.appendChains.set(filePath, next); + + try { + await next; + } finally { + if (this.appendChains.get(filePath) === next) { + this.appendChains.delete(filePath); + } + } + } + + private async appendToFile(filePath: string, event: MemberWorkSyncAuditEvent): Promise { try { await this.paths.ensureMemberWorkSyncDir(event.teamName, event.memberName); - const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName); await mkdir(dirname(filePath), { recursive: true }); await withFileLock(filePath, async () => { await rotateIfNeeded(filePath, this.maxBytes, this.rotatedFileCount); diff --git a/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts index 3f1c0611..a559e0aa 100644 --- a/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts +++ b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts @@ -90,6 +90,26 @@ describe('FileMemberWorkSyncAuditJournal', () => { expect(latest.taskRefs[0].taskId).toHaveLength(243); }); + it('serializes concurrent appends for the same member journal', async () => { + const journal = new FileMemberWorkSyncAuditJournal(new MemberWorkSyncStorePaths(root)); + const events = Array.from({ length: 80 }, (_, index) => ({ + timestamp: `2026-04-30T00:01:${String(index).padStart(2, '0')}.000Z`, + teamName: 'team-a', + memberName: 'bob', + event: 'queue_coalesced' as const, + source: 'test', + reason: `event-${index}`, + })); + + await Promise.all(events.map((event) => journal.append(event))); + + const lines = (await readFile(journalPath(root), 'utf8')).trim().split('\n'); + expect(lines).toHaveLength(events.length); + expect(lines.map((line) => JSON.parse(line).reason)).toEqual( + events.map((event) => event.reason) + ); + }); + it('logs and swallows append failures', async () => { const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; const paths = new MemberWorkSyncStorePaths(root);