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:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user