fix(api): retry commands that clash with a concurrent write

Services run commands through a CommandRunner that runs one again (up to
5 times, waiting a little longer each time) when its aggregate changed
underneath, e.g. a saga writing to the same ticket. Each command loads its
aggregate fresh and stores its events in one transaction, so a
ConcurrencyError means nothing was stored and running it again is safe.
People no longer get a 409 for it, and the e2e suite stops flaking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:00:08 +02:00
parent 76312c079d
commit e14f445e6b
23 changed files with 208 additions and 114 deletions

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
ActivityReadModel,
@@ -79,7 +80,7 @@ export interface IssueLinkInput {
@Injectable()
export class IssuesService {
constructor(
private readonly commandBus: CommandBus,
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
@@ -97,7 +98,7 @@ export class IssuesService {
useTemplate,
...details
} = input;
return this.commandBus.execute(
return this.commands.execute(
new CreateIssueCommand(
actorId,
projectId,
@@ -118,7 +119,7 @@ export class IssuesService {
issueId: string,
changes: IssueDetails,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new UpdateIssueCommand(actorId, issueId, changes),
);
}
@@ -129,7 +130,7 @@ export class IssuesService {
status: string,
reason?: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
);
}
@@ -140,13 +141,13 @@ export class IssuesService {
issueId: string,
assigneeId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new AssignIssueCommand(actorId, issueId, assigneeId),
);
}
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new LinkIssuesCommand(
actorId,
input.issueId,
@@ -157,7 +158,7 @@ export class IssuesService {
}
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new UnlinkIssuesCommand(
actorId,
input.issueId,
@@ -173,7 +174,7 @@ export class IssuesService {
issueId: string,
releaseId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new SetIssueReleaseCommand(actorId, issueId, releaseId),
);
}
@@ -185,7 +186,7 @@ export class IssuesService {
file: IncomingFile,
commentId: string | null = null,
): Promise<AttachmentResult> {
return this.commandBus.execute(
return this.commands.execute(
new AttachFileCommand(actorId, issueId, file, commentId),
);
}
@@ -195,7 +196,7 @@ export class IssuesService {
issueId: string,
attachmentId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new DetachFileCommand(actorId, issueId, attachmentId),
);
}
@@ -206,7 +207,7 @@ export class IssuesService {
issueId: string,
watching: boolean,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching),
);
}
@@ -236,7 +237,7 @@ export class IssuesService {
issueId: string,
parentId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new SetIssueParentCommand(actorId, issueId, parentId),
);
}
@@ -248,7 +249,7 @@ export class IssuesService {
above: string | null,
below: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new RankIssueCommand(actorId, issueId, above, below),
);
}
@@ -278,7 +279,7 @@ export class IssuesService {
if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined)
await this.commandBus.execute(
await this.commands.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
@@ -306,12 +307,12 @@ export class IssuesService {
/** To the trash (whoever opened it, or a manager); back with `restore`. */
trash(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
return this.commands.execute(new TrashIssueCommand(actorId, issueId));
}
/** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> {
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
return this.commands.execute(new DeleteIssueCommand(actorId, issueId));
}
/**
@@ -326,12 +327,12 @@ export class IssuesService {
}
restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
return this.commands.execute(new RestoreIssueCommand(actorId, issueId));
}
/** Votes for the ticket (`on`) or takes the vote back. */
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
return this.commands.execute(new VoteIssueCommand(actorId, issueId, on));
}
/**
@@ -378,7 +379,7 @@ export class IssuesService {
issueId: string,
text: string,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new AddChecklistItemCommand(actorId, issueId, text),
);
}
@@ -389,7 +390,7 @@ export class IssuesService {
itemId: string,
changes: { text?: string; done?: boolean },
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
);
}
@@ -399,7 +400,7 @@ export class IssuesService {
issueId: string,
itemId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
return this.commands.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId),
);
}