mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
refactor: remove obsolete external object batch refresh entrypoints
Exercise lease, selection, and recovery behavior through the registered scheduler owner. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4771986772
commit
4e97daafa0
5 files changed
+43
-95
No files matched your search
@@ -100,14 +100,6 @@ const active: ExternalObjectResolveResult = { ok: true, snapshot: {
|
||||
});
|
||||
}
|
||||
|
||||
it("captures the old configured-empty aggregate SQL cost", async () => {
|
||||
const svc = service(); await fixture();
|
||||
const selects = vi.spyOn(db, "select");
|
||||
for (let i = 0; i < 3; i++) await svc.refreshDueObjectsForActiveCompanies();
|
||||
expect(selects).toHaveBeenCalledTimes(6);
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates saved work when the ordinary feature and company settings enable it", async () => {
|
||||
clock(); const settings = instanceSettingsService(db);
|
||||
await settings.updateExperimental({ enableExternalObjects: true });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -733,6 +734,18 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function runRefreshWork(service: ReturnType<typeof externalObjectService>) {
|
||||
const errors: unknown[] = [];
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
|
||||
onError: error => errors.push(error) });
|
||||
try {
|
||||
await service.registerRefreshWork(coordinator, () => true).ready;
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await coordinator.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function createIssue(companyId = randomUUID()) {
|
||||
const issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -882,9 +895,9 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
|
||||
expect(object.nextRefreshAt).toBeInstanceOf(Date);
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
|
||||
await runRefreshWork(svc);
|
||||
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect((await db.select().from(externalObjects))[0]?.lastResolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -921,15 +934,13 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
await svc.syncIssue(issueId);
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
const dueRefresh = svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
|
||||
const dueRefresh = runRefreshWork(svc);
|
||||
await resolverStarted;
|
||||
const manualRefresh = svc.refreshObject(object.id, { companyId, force: true });
|
||||
finishResolver();
|
||||
|
||||
const [dueResults, manualResult] = await Promise.all([dueRefresh, manualRefresh]);
|
||||
const [, manualResult] = await Promise.all([dueRefresh, manualRefresh]);
|
||||
|
||||
expect(dueResults).toHaveLength(1);
|
||||
expect(dueResults[0]?.refreshed).toBe(true);
|
||||
expect(manualResult.refreshed).toBe(true);
|
||||
expect(manualResult.object.statusLabel).toBe("Open");
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
@@ -969,14 +980,12 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
await scheduledService.syncIssue(issueId);
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
|
||||
const dueRefresh = runRefreshWork(scheduledService);
|
||||
await resolverStarted;
|
||||
const manualRefresh = await manualService.refreshObject(object.id, { companyId, force: true });
|
||||
finishResolver();
|
||||
const dueResults = await dueRefresh;
|
||||
await dueRefresh;
|
||||
|
||||
expect(dueResults).toHaveLength(1);
|
||||
expect(dueResults[0]?.refreshed).toBe(true);
|
||||
expect(manualRefresh.refreshed).toBe(false);
|
||||
expect(manualRefresh.reason).toBe("refresh_in_progress");
|
||||
expect(manualRefresh.object).not.toHaveProperty("refreshToken");
|
||||
@@ -1032,7 +1041,7 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
const claimedAt = new Date(Date.now() + 1_000);
|
||||
|
||||
const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, claimedAt);
|
||||
const dueRefresh = runRefreshWork(scheduledService);
|
||||
await resolverStarted;
|
||||
const replacementResult = await replacementService.refreshObject(object.id, {
|
||||
companyId,
|
||||
@@ -1040,11 +1049,10 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
now: new Date(claimedAt.getTime() + 301_000),
|
||||
});
|
||||
finishResolver();
|
||||
const dueResults = await dueRefresh;
|
||||
await dueRefresh;
|
||||
const finalObject = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
|
||||
expect(replacementResult.refreshed).toBe(true);
|
||||
expect(dueResults[0]).toMatchObject({ refreshed: false, reason: "refresh_superseded" });
|
||||
expect(finalObject.statusLabel).toBe("Closed");
|
||||
expect(finalObject.refreshStartedAt).toBeNull();
|
||||
expect(finalObject.refreshToken).toBeNull();
|
||||
@@ -1078,9 +1086,7 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
await svc.syncIssue(active.issueId);
|
||||
await svc.syncIssue(paused.issueId);
|
||||
|
||||
const result = await svc.refreshDueObjectsForActiveCompanies(50, new Date(Date.now() + 1_000));
|
||||
|
||||
expect(result).toEqual({ companies: 1, checked: 1, refreshed: 1 });
|
||||
await runRefreshWork(svc);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
const rows = await db.select().from(externalObjects);
|
||||
const activeObject = rows.find((row) => row.companyId === active.companyId);
|
||||
@@ -1139,9 +1145,8 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
.set({ nextRefreshAt: new Date(0) })
|
||||
.where(eq(externalObjects.id, object.id));
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId);
|
||||
await runRefreshWork(svc);
|
||||
|
||||
expect(refreshed).toEqual([]);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1184,7 +1189,7 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
});
|
||||
|
||||
it("re-checks previously frozen GitHub not-found objects so they can self-heal", async () => {
|
||||
const { companyId } = await createIssue();
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
const inserted = await db.insert(externalObjects).values({
|
||||
@@ -1225,9 +1230,10 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
expect(object.isTerminal).toBe(true);
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId);
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect(refreshed[0]?.refreshed).toBe(true);
|
||||
const [linked] = await db.select().from(externalObjects).where(eq(externalObjects.companyId, companyId));
|
||||
await db.insert(externalObjectMentions).values({ companyId, sourceIssueId: issueId,
|
||||
sourceKind: "description", objectId: linked!.id });
|
||||
await runRefreshWork(svc);
|
||||
|
||||
const updated = await db.select().from(externalObjects).then((rows) => rows[0]!);
|
||||
expect(updated.statusLabel).toBe("Open");
|
||||
@@ -1236,7 +1242,7 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
});
|
||||
|
||||
it("does not re-check probe-verified GitHub not-found objects (deleted references are terminal)", async () => {
|
||||
const { companyId } = await createIssue();
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
await db.insert(externalObjects).values({
|
||||
@@ -1273,14 +1279,16 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
};
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId);
|
||||
const [linked] = await db.select().from(externalObjects).where(eq(externalObjects.companyId, companyId));
|
||||
await db.insert(externalObjectMentions).values({ companyId, sourceIssueId: issueId,
|
||||
sourceKind: "description", objectId: linked!.id });
|
||||
await runRefreshWork(svc);
|
||||
|
||||
expect(refreshed).toEqual([]);
|
||||
expect(resolver.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-check terminal objects that are not GitHub not-found verdicts", async () => {
|
||||
const { companyId } = await createIssue();
|
||||
const { companyId, issueId } = await createIssue();
|
||||
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
|
||||
if (!canonical) throw new Error("expected canonical url");
|
||||
await db.insert(externalObjects).values({
|
||||
@@ -1316,9 +1324,11 @@ describeEmbeddedPostgres("externalObjectService", () => {
|
||||
};
|
||||
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
|
||||
|
||||
const refreshed = await svc.refreshDueObjects(companyId);
|
||||
const [linked] = await db.select().from(externalObjects).where(eq(externalObjects.companyId, companyId));
|
||||
await db.insert(externalObjectMentions).values({ companyId, sourceIssueId: issueId,
|
||||
sourceKind: "description", objectId: linked!.id });
|
||||
await runRefreshWork(svc);
|
||||
|
||||
expect(refreshed).toEqual([]);
|
||||
expect(resolver.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ const {
|
||||
const externalObjectsServiceMock = {
|
||||
registerRefreshWork: vi.fn((coordinator: { register: typeof deliveryWorkRegisterMock }, _canRun: () => boolean) =>
|
||||
coordinator.register("external-object-refresh", { run: async () => undefined, hasPending: async () => false })),
|
||||
refreshDueObjectsForActiveCompanies: vi.fn(async () => ({ companies: 0, checked: 0, refreshed: 0 })),
|
||||
};
|
||||
const externalObjectsServiceFactoryMock = vi.fn(() => externalObjectsServiceMock);
|
||||
const routineServiceMock = {
|
||||
@@ -1404,7 +1403,6 @@ describe("startServer feedback export wiring", () => {
|
||||
expect(externalObjectsServiceMock.registerRefreshWork).toHaveBeenCalledTimes(1);
|
||||
expect(externalObjectsServiceMock.registerRefreshWork).toHaveBeenCalledWith(expect.any(Object), expect.any(Function));
|
||||
expect(deliveryWorkRegisterMock.mock.calls.some(([queue]) => queue === DELIVERY_QUEUES.externalObjectRefresh)).toBe(true);
|
||||
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).not.toHaveBeenCalled();
|
||||
expect(issueThreadInteractionServiceMock.sweepMergedPullRequestConfirmations).toHaveBeenCalledTimes(1);
|
||||
expect(executionWorkspaceServiceMock.sweepTerminalWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(routineServiceMock.tickScheduledTriggers).toHaveBeenCalledTimes(1);
|
||||
@@ -1434,7 +1432,6 @@ describe("startServer feedback export wiring", () => {
|
||||
});
|
||||
await startServer();
|
||||
expect(externalObjectsServiceMock.registerRefreshWork).toHaveBeenCalledOnce();
|
||||
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps external object refresh active when heartbeat scheduling is disabled", async () => {
|
||||
@@ -1467,7 +1464,6 @@ describe("startServer feedback export wiring", () => {
|
||||
expect(externalObjectsServiceMock.registerRefreshWork).toHaveBeenCalledTimes(1);
|
||||
expect(externalObjectsServiceMock.registerRefreshWork).toHaveBeenCalledWith(expect.any(Object), expect.any(Function));
|
||||
expect(deliveryWorkRegisterMock.mock.calls.some(([queue]) => queue === DELIVERY_QUEUES.externalObjectRefresh)).toBe(true);
|
||||
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).not.toHaveBeenCalled();
|
||||
expect(routineServiceMock.tickScheduledTriggers).not.toHaveBeenCalled();
|
||||
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
|
||||
expect(environmentCustomImagesServiceMock.cleanupExpiredSetupSessions).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1109,56 +1109,6 @@ export function externalObjectService(
|
||||
return results;
|
||||
}
|
||||
|
||||
async function refreshDueObjectsUnchecked(companyId: string, limit = 50, now = new Date()) {
|
||||
const staleRefreshStartedBefore = new Date(now.getTime() - DEFAULT_REFRESH_LEASE_SECONDS * 1000);
|
||||
const refreshable = refreshableObjectWhere();
|
||||
const due = await db
|
||||
.select({ id: externalObjects.id })
|
||||
.from(externalObjects)
|
||||
.where(
|
||||
and(
|
||||
eq(externalObjects.companyId, companyId),
|
||||
refreshable,
|
||||
lte(externalObjects.nextRefreshAt, now),
|
||||
or(
|
||||
isNull(externalObjects.refreshStartedAt),
|
||||
lte(externalObjects.refreshStartedAt, staleRefreshStartedBefore),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(limit);
|
||||
const results = [];
|
||||
for (const row of due) {
|
||||
results.push(await refreshObject(row.id, {
|
||||
companyId,
|
||||
actor: { actorType: "system", actorId: "external-object-resolver", agentId: null, runId: null },
|
||||
now,
|
||||
}));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
|
||||
if (!(await isEnabled())) return [];
|
||||
return refreshDueObjectsUnchecked(companyId, limit, now);
|
||||
}
|
||||
|
||||
async function refreshDueObjectsForActiveCompanies(limitPerCompany = 50, now = new Date()) {
|
||||
if (!(await isEnabled())) return { companies: 0, checked: 0, refreshed: 0 };
|
||||
const activeCompanies = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.status, "active"));
|
||||
let checked = 0;
|
||||
let refreshed = 0;
|
||||
for (const company of activeCompanies) {
|
||||
const results = await refreshDueObjectsUnchecked(company.id, limitPerCompany, now);
|
||||
checked += results.length;
|
||||
refreshed += results.filter((result) => result.refreshed).length;
|
||||
}
|
||||
return { companies: activeCompanies.length, checked, refreshed };
|
||||
}
|
||||
|
||||
function registerRefreshWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
canRun: () => boolean,
|
||||
@@ -1234,8 +1184,6 @@ export function externalObjectService(
|
||||
getProjectSummary,
|
||||
refreshObject,
|
||||
refreshIssueObjects,
|
||||
refreshDueObjects,
|
||||
refreshDueObjectsForActiveCompanies,
|
||||
registerRefreshWork,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user