Files
PaperClipAI/server/src/__tests__/issue-references-service.test.ts
T
lorenzozane 1b2632facc fix: serialize concurrent issue reference syncs (#13898)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server keeps issue references synchronized with issue title and
description changes.
> - Concurrent updates can rebuild the same source issue's reference
mentions at the same time.
> - The replacement writes can then collide on the unique mention key
and return a 500 error.
> - This pull request serializes each source issue's reference rebuild
and adds concurrent regression coverage.
> - The benefit is that repeated unchanged descriptions complete without
duplicate-key failures.

## Linked Issues or Issue Description

Fixes #13731

## What Changed

- Lock the source issue row before replacing title and description
reference mentions.
- Add a 20-way concurrent regression test with repeated `COM-2`
references.

## Verification

- `pnpm exec vitest run
server/src/__tests__/issue-references-service.test.ts
packages/shared/src/issue-references.test.ts` passed with 10 tests.
- `pnpm --filter @paperclipai/server typecheck` passed under Node
22.22.1; the repository declares Node >=24.11 and emitted engine
warnings.
- `git diff --check` passed.

## Risks

- The row lock serializes rebuilds for one source issue and may reduce
concurrency for simultaneous updates to that issue.
- A theoretical lock-order risk remains for concurrent syncs of
different mutually referencing source issues. That path is outside this
fix and is not covered by the focused regression.
- Remote CI and Greptile review have not run yet.

## Model Used

OpenCode runtime, model `supercodes5/gpt-5.6-luna`; context window size
is not exposed. Repository tool use and reasoning were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-10-15 16:02:45 -07:00

288 lines
9.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
createDb,
documents,
issueComments,
issueDocuments,
issueReferenceMentions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueReferenceService } from "../services/issue-references.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function ensureIssueReferenceMentionsTable(db: ReturnType<typeof createDb>) {
await db.execute(sql.raw(`
CREATE TABLE IF NOT EXISTS "issue_reference_mentions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL,
"source_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
"target_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
"source_kind" text NOT NULL,
"source_record_id" uuid,
"document_key" text,
"matched_text" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_issue_idx"
ON "issue_reference_mentions" ("company_id", "source_issue_id");
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_target_issue_idx"
ON "issue_reference_mentions" ("company_id", "target_issue_id");
CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_issue_pair_idx"
ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id");
CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_uq"
ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id", "source_kind", "source_record_id");
`));
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue reference tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issueReferenceService", () => {
let db!: ReturnType<typeof createDb>;
let refs!: ReturnType<typeof issueReferenceService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-refs-");
db = createDb(tempDb.connectionString);
refs = issueReferenceService(db);
await ensureIssueReferenceMentionsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueReferenceMentions);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issues);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("tracks outbound and inbound references across issue fields, comments, and documents", async () => {
const companyId = randomUUID();
const sourceIssueId = randomUUID();
const targetTwoId = randomUUID();
const targetThreeId = randomUUID();
const inboundIssueId = randomUUID();
const commentId = randomUUID();
const documentId = randomUUID();
const issueDocumentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Coordinate PAP-2",
description: "Review /issues/pap-3 and ignore PAP-1 self references.",
status: "todo",
priority: "medium",
identifier: "PAP-1",
},
{
id: targetTwoId,
companyId,
title: "Target two",
status: "todo",
priority: "medium",
identifier: "PAP-2",
},
{
id: targetThreeId,
companyId,
title: "Target three",
status: "todo",
priority: "medium",
identifier: "PAP-3",
},
{
id: inboundIssueId,
companyId,
title: "Inbound reference",
description: "This one depends on PAP-1.",
status: "in_progress",
priority: "high",
identifier: "PAP-4",
},
]);
await refs.syncIssue(sourceIssueId);
await refs.syncIssue(inboundIssueId);
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId: sourceIssueId,
body: "Follow up in https://paperclip.test/issues/pap-2 after the document lands.",
});
await refs.syncComment(commentId);
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "Spec note: /PAP/issues/PAP-3",
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
id: issueDocumentId,
companyId,
issueId: sourceIssueId,
documentId,
key: "plan",
});
await refs.syncDocument(documentId);
const summary = await refs.listIssueReferenceSummary(sourceIssueId);
expect(summary.outbound.map((item) => item.issue.identifier)).toEqual(["PAP-2", "PAP-3"]);
expect(summary.outbound[0]?.mentionCount).toBe(2);
expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["title", "comment"]);
expect(summary.outbound[1]?.mentionCount).toBe(2);
expect(summary.outbound[1]?.sources.map((source) => source.label)).toEqual(["description", "plan"]);
expect(summary.inbound.map((item) => item.issue.identifier)).toEqual(["PAP-4"]);
await refs.deleteDocumentSource(documentId);
const withoutDocument = await refs.listIssueReferenceSummary(sourceIssueId);
const pap3 = withoutDocument.outbound.find((item) => item.issue.identifier === "PAP-3");
expect(pap3?.mentionCount).toBe(1);
expect(pap3?.sources.map((source) => source.label)).toEqual(["description"]);
});
it("serializes concurrent syncs for a description with repeated references", async () => {
const companyId = randomUUID();
const sourceIssueId = randomUUID();
const targetIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip Concurrent References",
issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Source issue",
description: "Coordinate COM-2 and confirm COM-2 again.",
status: "todo",
priority: "medium",
identifier: "COM-1",
},
{
id: targetIssueId,
companyId,
title: "Target issue",
status: "todo",
priority: "medium",
identifier: "COM-2",
},
]);
await expect(Promise.all(
Array.from({ length: 20 }, () => refs.syncIssue(sourceIssueId)),
)).resolves.toHaveLength(20);
const mentions = await db
.select()
.from(issueReferenceMentions);
expect(mentions).toHaveLength(1);
expect(mentions[0]?.targetIssueId).toBe(targetIssueId);
});
it("backfills existing references for a company without requiring write-time sync", async () => {
const companyId = randomUUID();
const sourceIssueId = randomUUID();
const targetIssueId = randomUUID();
const commentId = randomUUID();
const documentId = randomUUID();
const issueDocumentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip Backfill",
issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Legacy issue",
status: "todo",
priority: "medium",
identifier: "PAP-10",
},
{
id: targetIssueId,
companyId,
title: "Referenced legacy issue",
status: "todo",
priority: "medium",
identifier: "PAP-20",
},
]);
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId: sourceIssueId,
body: "Legacy comment points at PAP-20.",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Legacy plan",
format: "markdown",
latestBody: "Legacy plan also links /issues/PAP-20.",
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
id: issueDocumentId,
companyId,
issueId: sourceIssueId,
documentId,
key: "plan",
});
await refs.syncAllForCompany(companyId);
const summary = await refs.listIssueReferenceSummary(sourceIssueId);
expect(summary.outbound).toHaveLength(1);
expect(summary.outbound[0]?.issue.identifier).toBe("PAP-20");
expect(summary.outbound[0]?.mentionCount).toBe(2);
expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["plan", "comment"]);
});
});