mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

Select the types of activity you want to include in your feed.

fix: drop orphaned jobs when an installation is uninstalled

Daniel Roe (Jul 10, 2026, 1:07 PM +0200) b0ac91b9 87b6b726

+60 -2
+10 -1
server/api/github/webhook.post.ts
··· 8 8 } from '@octokit/webhooks-types' 9 9 import { verify } from '@octokit/webhooks-methods' 10 10 import { sql } from 'drizzle-orm' 11 - import { installation, webhookEvent } from '#server/db/schema' 11 + import { installation, job, webhookEvent } from '#server/db/schema' 12 12 import { kickWorker } from '#server/utils/kick-worker' 13 13 import { enqueue } from '#server/utils/queue' 14 14 import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey' ··· 91 91 // Revoke the user's sh.tangled.publicKey PDS records first; the cascade 92 92 // below drops the local ssh_key rows that hold the rkeys we need. 93 93 await revokeKeysForInstallation(body.installation.id) 94 + // Cancel any still-pending work for this install. The `job` table has no 95 + // FK to `installation` (payload carries the id as JSON), so nothing 96 + // cascades; left alone, these jobs retry to exhaustion failing on the 97 + // now-missing FK target (e.g. the ssh_key insert). Drop only unstarted 98 + // work; a running job finishes and no-ops on its own guards. 99 + await db.delete(job).where(sql` 100 + ${job.status} in ('queued', 'failed') 101 + AND (${job.payload}->>'installationId')::bigint = ${body.installation.id} 102 + `) 94 103 // installation row deletion cascades to user_identity, ssh_key, repo_mapping. 95 104 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`) 96 105 }
+21 -1
server/utils/job-handlers.ts
··· 1 1 import type { OAuthSession } from '@atproto/oauth-client-node' 2 2 import { and, eq, sql } from 'drizzle-orm' 3 - import { repoMapping, userIdentity } from '../db/schema' 3 + import { installation, repoMapping, userIdentity } from '../db/schema' 4 4 import { useOAuthClient } from './atproto-oauth' 5 5 import { useDb } from './db' 6 6 import { installationOctokit } from './github-app' ··· 198 198 199 199 if (envelope.kind === 'atproto.publish-pubkey') { 200 200 const { did, installationId, force } = publishPubkeyPayload(envelope.payload) 201 + // The install may have been uninstalled after this job was enqueued; its 202 + // row (and any ssh_key FK target) is then gone, so the key insert would 203 + // fail forever on the foreign key. Drop cleanly instead of retrying, and 204 + // avoid publishing an orphan sh.tangled.publicKey record to the PDS. 205 + if (!(await installationExists(installationId))) return 201 206 const client = await useOAuthClient() 202 207 const session = await client.restore(did) 203 208 if (force) await rotateKey({ oauthSession: session, installationId }) ··· 384 389 const client = await useOAuthClient() 385 390 return client.restore(identity[0]!.did) 386 391 } 392 + 393 + /** 394 + * Whether the `installation` row still exists. Jobs enqueued before an 395 + * uninstall can outlive the row (which cascade-deletes user_identity, ssh_key, 396 + * repo_mapping); handlers use this to drop such orphaned work cleanly rather 397 + * than failing on a foreign-key violation. 398 + */ 399 + async function installationExists(installationId: number): Promise<boolean> { 400 + const db = useDb() 401 + const rows = await db.select({ id: installation.id }) 402 + .from(installation) 403 + .where(sql`${installation.id} = ${installationId}`) 404 + .limit(1) 405 + return rows.length > 0 406 + }
+29
test/unit/repository-handler.spec.ts
··· 317 317 expect(byRepoId[9002]?.disabledAt).toBeNull() 318 318 }) 319 319 }) 320 + 321 + describe('atproto.publish-pubkey install-existence guard', () => { 322 + beforeEach(async () => { 323 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 324 + clearEncryptionKeyCache() 325 + setDb(await createTestDb()) 326 + restoreMock.mockReset() 327 + restoreMock.mockResolvedValue({ did: 'did:plc:abc' }) 328 + }) 329 + 330 + afterEach(() => { 331 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 332 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 333 + clearEncryptionKeyCache() 334 + clearDb() 335 + }) 336 + 337 + it('drops the job without touching the PDS when the installation is gone', async () => { 338 + // No installation row: models a job enqueued before the user uninstalled. 339 + await dispatch({ 340 + id: 1, 341 + kind: 'atproto.publish-pubkey', 342 + payload: { did: 'did:plc:abc', installationId: 999, force: true }, 343 + attempts: 1, 344 + }) 345 + // Guard short-circuits before restoring the session or publishing a record. 346 + expect(restoreMock).not.toHaveBeenCalled() 347 + }) 348 + })