Files
PaperClipAI/server/src/services/retention-work-notifications.ts
T
DottaandPaperclip 255713e0cd fix(server): bound and coalesce issue retention invalidations
Bound reverse ancestor discovery before materializing adjacency, deduplicate cycles and emit one canonical affected-issue batch per write. Overflow delegates complete company reconstruction to existing paging instead of dropping eligibility or creating per-ancestor worker debt. Prove real high-fanin producer bursts, PostgreSQL execution limits, canonical cyclic batches and far ancestor archive deadlines.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-30 20:02:36 -05:00

97 lines
6.1 KiB
TypeScript

import { notifyApprovalWork } from "./approval-work-notifications.js";
import { RECOVERY_ORIGIN_KINDS, parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js";
import { sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { AttentionSourceKind } from "@paperclipai/shared";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export const ATTENTION_RETENTION_MAX_ISSUE_IDS = 100;
export type AttentionRetentionScope = { companyId?: string; sourceKind?: AttentionSourceKind;
issueId?: string; issueIds?: string[]; sourceId?: string };
export async function notifyAttentionRetentionWork(transaction: object, scope: AttentionRetentionScope = {}) {
const issueIds = [...new Set([...(scope.issueIds ?? []), ...(scope.issueId ? [scope.issueId] : [])])].sort();
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.attentionRetention, JSON.stringify({
companyId: scope.companyId, sourceKind: scope.sourceKind, sourceId: scope.sourceId,
issueId: issueIds.length === 1 ? issueIds[0] : undefined,
issueIds: issueIds.length > 1 && issueIds.length <= ATTENTION_RETENTION_MAX_ISSUE_IDS ? issueIds : undefined,
}));
}
export async function notifyIssueAttentionRetentionWork(transaction: object,
scope: { companyId: string; issueId?: string; parentId?: string | null; runId?: string; originKind?: string | null; originId?: string | null }) {
const database = transaction as Db;
const seeds = [scope.issueId, scope.parentId].filter((id): id is string => Boolean(id));
if (!seeds.length && scope.runId) {
const rows = await database.execute(sql`select issue_id::text as id from heartbeat_runs
where company_id = ${scope.companyId}::uuid and id = ${scope.runId}::uuid and issue_id is not null
union select native_issue_id::text from heartbeat_runs
where company_id = ${scope.companyId}::uuid and id = ${scope.runId}::uuid and native_issue_id is not null
union select issue.id::text from heartbeat_runs run inner join issues issue
on issue.company_id = run.company_id
and issue.id::text = coalesce(run.context_snapshot->>'issueId', run.context_snapshot->>'taskId')
where run.company_id = ${scope.companyId}::uuid and run.id = ${scope.runId}::uuid`);
for (const row of rows) seeds.push(row.id as string);
}
if (!seeds.length) return notifyAttentionRetentionWork(transaction, { companyId: scope.companyId });
const origins = await database.execute(sql`select id::text as id, origin_kind as "originKind", origin_id as "originId" from issues
where company_id = ${scope.companyId}::uuid
and id in (${sql.join([...new Set(seeds)].map(id => sql`${id}::uuid`), sql`, `)})`);
const current = origins.find(source => source.id === scope.issueId);
origins.push({ originKind: scope.originKind ?? current?.originKind, originId: scope.originId ?? current?.originId });
const references: string[] = [];
for (const source of origins) {
if (source.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery && typeof source.originId === "string") {
references.push(source.originId);
}
const incident = source.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation
? parseIssueGraphLivenessIncidentKey(source.originId as string | null) : null;
if (incident?.companyId === scope.companyId) references.push(incident.issueId, incident.leafIssueId);
}
if (references.length) {
const trusted = await database.execute(sql`select id::text as id from issues
where company_id = ${scope.companyId}::uuid
and id::text in (${sql.join([...new Set(references)].map(id => sql`${id}`), sql`, `)})`);
for (const row of trusted) seeds.push(row.id as string);
}
// UNION deduplicates visited UUIDs (including cycles). Each relation edge is
// unique by schema, so 101 neighbours guarantee overflow of a 100-ID scope.
// Bound that adjacency before materialization and stream at most 101 unique
// IDs to the caller; an inner UNION DISTINCT would consume the full fan-in.
const affected = await database.execute(sql`with recursive affected(id) as (
select unnest(array[${sql.join([...new Set(seeds)].map(id => sql`${id}::uuid`), sql`, `)}])
union
select parent.id from affected current cross join lateral (
(select related_issue_id as id from issue_relations where company_id = ${scope.companyId}::uuid
and issue_id = current.id and type = 'blocks' limit ${ATTENTION_RETENTION_MAX_ISSUE_IDS + 1})
union all select parent_id from issues where company_id = ${scope.companyId}::uuid
and id = current.id and parent_id is not null
) parent
) select id::text as "issueId" from affected limit ${ATTENTION_RETENTION_MAX_ISSUE_IDS + 1}`);
// The sentinel means the affected set is incomplete. Reconstruct the whole
// company through the existing paged authority instead of dropping ancestors.
await notifyAttentionRetentionWork(transaction, { companyId: scope.companyId,
issueIds: affected.length <= ATTENTION_RETENTION_MAX_ISSUE_IDS ? affected.map(row => row.issueId as string) : undefined,
});
}
export async function notifyRetentionWork(transaction: object, kind: AttentionSourceKind, scope: { companyId?: string; sourceId?: string } = {}) {
if (kind === "approval") await notifyDeliveryWork(transaction, DELIVERY_QUEUES.approvalRetention);
if (kind === "join_request") await notifyDeliveryWork(transaction, DELIVERY_QUEUES.joinRequestRetention);
if (kind !== "approval" && kind !== "join_request") await notifyAttentionRetentionWork(transaction, { ...scope, sourceKind: kind,
sourceId: kind === "blocker_attention" ? undefined : scope.sourceId });
}
export async function notifyCompanyRetentionWork(transaction: object, companyId: string) {
await notifyAttentionRetentionWork(transaction, { companyId });
await notifyApprovalWork(transaction, companyId);
await notifyRetentionWork(transaction, "join_request");
}
export function writeJoinRequestChange<T>(db: Db, write: (transaction: Db) => Promise<T>): Promise<T> {
return db.transaction(async tx => {
await notifyRetentionWork(tx, "join_request");
return write(tx as unknown as Db);
});
}