Skip to content

Commit 2fa15bf

Browse files
committed
fix: audit packagist writes inside their own transaction
Signed-off-by: anilb <epipav@gmail.com>
1 parent 1b51541 commit 2fa15bf

8 files changed

Lines changed: 117 additions & 51 deletions

File tree

services/apps/packages_worker/src/packagist/__tests__/downloads.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

3-
import { insertLast30dDownloadIfAbsent } from '@crowd/data-access-layer/src/packages'
3+
import {
4+
insertLast30dDownloadIfAbsent,
5+
logAuditFieldChanges,
6+
} from '@crowd/data-access-layer/src/packages'
47
import type { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
58

69
import { monthlyWindowFor, persistPackagist30dWindow } from '../downloads'
710

811
vi.mock('@crowd/data-access-layer/src/packages', () => ({
912
insertLast30dDownloadIfAbsent: vi.fn().mockResolvedValue([]),
13+
logAuditFieldChanges: vi.fn(),
1014
}))
1115

1216
const mockWindow = vi.mocked(insertLast30dDownloadIfAbsent)
17+
const mockAudit = vi.mocked(logAuditFieldChanges)
1318

14-
const qx = {} as QueryExecutor
19+
const qx = {
20+
tx: vi.fn((cb: (t: QueryExecutor) => Promise<string[]>) => cb(qx)),
21+
} as unknown as QueryExecutor
1522
const PURL = 'pkg:composer/monolog/monolog'
1623

1724
beforeEach(() => {
@@ -45,30 +52,37 @@ describe('monthlyWindowFor', () => {
4552
// is never overwritten — the value is the observation closest to the boundary.
4653
// insertLast30dDownloadIfAbsent does the presence check and the write atomically, so
4754
// there is no separate "does it exist" call to assert on here — a race is exercised
48-
// directly against Postgres in downloadsLast30d's own DAL-level coverage.
55+
// directly against Postgres in downloadsLast30d's own DAL-level coverage. The audit
56+
// record shares the same transaction as the write.
4957
describe('persistPackagist30dWindow', () => {
50-
it('writes the window with the mirror and returns the changed fields when the month has no row yet', async () => {
58+
it('writes the window with the mirror, audits it, and returns the changed fields when the month has no row yet', async () => {
5159
mockWindow.mockResolvedValue(['downloads_last_30d.count', 'packages.downloads_last_30d'])
5260

5361
const changedFields = await persistPackagist30dWindow(qx, PURL, 300, '2026-07-01')
5462

5563
expect(mockWindow).toHaveBeenCalledWith(qx, PURL, '2026-06-01', '2026-07-01', 300, true)
64+
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, [
65+
'downloads_last_30d.count',
66+
'packages.downloads_last_30d',
67+
])
5668
expect(changedFields).toEqual(['downloads_last_30d.count', 'packages.downloads_last_30d'])
5769
})
5870

59-
it('returns no changed fields when a window for the month already exists', async () => {
71+
it('returns no changed fields and does not audit when a window for the month already exists', async () => {
6072
mockWindow.mockResolvedValue([])
6173

6274
const changedFields = await persistPackagist30dWindow(qx, PURL, 300, '2026-07-15')
6375

6476
expect(mockWindow).toHaveBeenCalledWith(qx, PURL, '2026-06-01', '2026-07-01', 300, true)
77+
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, [])
6578
expect(changedFields).toEqual([])
6679
})
6780

6881
it('writes nothing and returns no changed fields when the registry reported no monthly count', async () => {
6982
const changedFields = await persistPackagist30dWindow(qx, PURL, null, '2026-07-01')
7083

7184
expect(mockWindow).not.toHaveBeenCalled()
85+
expect(mockAudit).not.toHaveBeenCalled()
7286
expect(changedFields).toEqual([])
7387
})
7488
})

services/apps/packages_worker/src/packagist/__tests__/ingest.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ const mockFetchList = vi.mocked(fetchPackagistPackageList)
7272
const mockParseList = vi.mocked(parsePackagistPackageList)
7373
const mockSeedInsert = vi.mocked(insertPackagistPackages)
7474

75-
const qx = {} as QueryExecutor
75+
const qx = {
76+
tx: vi.fn((cb: (t: QueryExecutor) => Promise<void>) => cb(qx)),
77+
} as unknown as QueryExecutor
7678
const PURL = 'pkg:composer/monolog/monolog'
7779
const RUN_DATE = '2026-07-15'
7880
const SCHEDULED_AT = '2026-07-15T00:00:00.000Z'
@@ -111,7 +113,7 @@ describe('ingestOnePackagistMetadata', () => {
111113
})
112114
}
113115

114-
it('audits phase 1 immediately and phase 2 separately, then marks scanned with the fresh Last-Modified', async () => {
116+
it('persists phase 1 immediately and phase 2 separately, then marks scanned with the fresh Last-Modified', async () => {
115117
happyMocks()
116118

117119
await ingestOnePackagistMetadata(qx, candidate, SCHEDULED_AT)
@@ -120,10 +122,9 @@ describe('ingestOnePackagistMetadata', () => {
120122
expect(mockFetchP2).toHaveBeenCalledWith('monolog/monolog', 'Tue, 30 Jun 2026 00:00:00 GMT')
121123
expect(mockExpand).toHaveBeenCalledWith(minified)
122124
expect(mockPersistMetadata).toHaveBeenCalledWith(qx, PURL, expanded)
123-
// Two separate audit rows, not one merged call — phase 1 is logged as soon as it's
124-
// committed, before the p2 fetch (which can throw) ever runs.
125-
expect(mockAudit).toHaveBeenNthCalledWith(1, qx, 'packagist', PURL, ['packages.description'])
126-
expect(mockAudit).toHaveBeenNthCalledWith(2, qx, 'packagist', PURL, ['versions.number'])
125+
// Each phase audits its own writes atomically inside its own persist* call (see
126+
// persistPackageInfo.test.ts / persistMetadata.test.ts) — phase 1 is committed and
127+
// audited before the p2 fetch (which can throw) ever runs.
127128
expect(mockMarkMetadata).toHaveBeenCalledWith(
128129
qx,
129130
PURL,
@@ -209,8 +210,9 @@ describe('ingestOnePackagistMetadata', () => {
209210
)
210211
})
211212

212-
it('audits phase-1 changes even when the p2 fetch gives up', async () => {
213-
// the dynamic-endpoint writes are committed by then — their audit rows must not be dropped
213+
it('still persists (and self-audits) phase 1 even when the p2 fetch gives up', async () => {
214+
// the dynamic-endpoint writes are committed and audited by persistPackagistPackageInfo
215+
// itself before the p2 fetch (which can throw) ever runs — see persistPackageInfo.test.ts
214216
vi.useFakeTimers()
215217
mockFetchStats.mockResolvedValue(statsJson as never)
216218
mockPersistInfo.mockResolvedValue({ found: true, changedFields: ['packages.description'] })
@@ -224,7 +226,7 @@ describe('ingestOnePackagistMetadata', () => {
224226
await vi.runAllTimersAsync()
225227
await p
226228

227-
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, ['packages.description'])
229+
expect(mockPersistInfo).toHaveBeenCalledWith(qx, PURL, expect.anything())
228230
expect(mockMarkMetadata).toHaveBeenCalledWith(
229231
qx,
230232
PURL,
@@ -233,32 +235,30 @@ describe('ingestOnePackagistMetadata', () => {
233235
)
234236
})
235237

236-
it('throws on a transient p2 result without marking scanned, but still audits phase 1', async () => {
238+
it('throws on a transient p2 result without marking scanned, but phase 1 already persisted', async () => {
237239
mockFetchStats.mockResolvedValue(statsJson as never)
238240
mockPersistInfo.mockResolvedValue({ found: true, changedFields: ['packages.description'] })
239241
mockFetchP2.mockResolvedValue({ kind: 'TRANSIENT', message: 'HTTP 502' } as never)
240242

241243
await expect(ingestOnePackagistMetadata(qx, candidate, SCHEDULED_AT)).rejects.toThrow()
242244
expect(mockMarkMetadata).not.toHaveBeenCalled()
243-
// phase-1 writes are already committed when the throw happens — a retry re-runs
244-
// phase 1 idempotently and reports no changes, so this audit event can't be deferred
245-
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, ['packages.description'])
245+
// phase-1 writes (and their audit row) are already committed by
246+
// persistPackagistPackageInfo when the throw happens — a retry re-runs phase 1
247+
// idempotently and reports no changes, so nothing here can lose that audit event
248+
expect(mockPersistInfo).toHaveBeenCalledWith(qx, PURL, expect.anything())
246249
})
247250
})
248251

249252
// The monthly downloads-30d lane: dynamic fetch, window row only.
250253
describe('ingestOnePackagist30dWindow', () => {
251-
it('persists the observed rolling window, audits the change, and marks the run processed', async () => {
254+
it('persists the observed rolling window (self-audited) and marks the run processed', async () => {
252255
mockFetchStats.mockResolvedValue(statsJson as never)
253256
mockPersist30d.mockResolvedValue(['downloads_last_30d.count', 'packages.downloads_last_30d'])
254257

255258
await ingestOnePackagist30dWindow(qx, PURL, RUN_DATE, SCHEDULED_AT)
256259

257260
expect(mockPersist30d).toHaveBeenCalledWith(qx, PURL, 300, RUN_DATE)
258-
expect(mockAudit).toHaveBeenCalledWith(qx, 'packagist', PURL, [
259-
'downloads_last_30d.count',
260-
'packages.downloads_last_30d',
261-
])
261+
// persistPackagist30dWindow audits its own write atomically — see downloads.test.ts
262262
expect(mockMark30d).toHaveBeenCalledWith(
263263
qx,
264264
PURL,

services/apps/packages_worker/src/packagist/__tests__/persistMetadata.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import {
44
getPackagistPackageIdsByNames,
5+
logAuditFieldChanges,
56
reconcileVersionDependencies,
67
updatePackagistVersionAggregates,
78
upsertPackagistVersions,
@@ -16,12 +17,14 @@ vi.mock('@crowd/data-access-layer/src/packages', () => ({
1617
upsertPackagistVersions: vi.fn(),
1718
getPackagistPackageIdsByNames: vi.fn(),
1819
reconcileVersionDependencies: vi.fn().mockResolvedValue([]),
20+
logAuditFieldChanges: vi.fn(),
1921
}))
2022

2123
const mockAggregates = vi.mocked(updatePackagistVersionAggregates)
2224
const mockVersions = vi.mocked(upsertPackagistVersions)
2325
const mockIds = vi.mocked(getPackagistPackageIdsByNames)
2426
const mockDeps = vi.mocked(reconcileVersionDependencies)
27+
const mockAudit = vi.mocked(logAuditFieldChanges)
2528

2629
const qx = {
2730
tx: vi.fn((cb: (t: QueryExecutor) => Promise<void>) => cb(qx)),
@@ -120,6 +123,13 @@ describe('persistPackagistMetadata', () => {
120123
expect(result.unresolvedDependencyTargets).toBe(0)
121124
// dependency-edge changes must reach the audit log alongside aggregates/versions
122125
expect(result.changedFields).toContain('package_dependencies.depends_on_id')
126+
// audited atomically inside the same transaction as the writes above
127+
expect(mockAudit).toHaveBeenCalledWith(
128+
qx,
129+
'packagist',
130+
PURL,
131+
expect.arrayContaining(['packages.latest_version', 'package_dependencies.depends_on_id']),
132+
)
123133
})
124134

125135
it('skips and counts dependency targets that do not resolve to a packages row', async () => {
@@ -170,6 +180,7 @@ describe('persistPackagistMetadata', () => {
170180
expect(mockVersions).not.toHaveBeenCalled()
171181
expect(mockIds).not.toHaveBeenCalled()
172182
expect(mockDeps).not.toHaveBeenCalled()
183+
expect(mockAudit).not.toHaveBeenCalled()
173184
})
174185

175186
it('handles a dev-branches-only package: aggregates written, no version or dependency writes', async () => {

services/apps/packages_worker/src/packagist/__tests__/persistPackageInfo.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import {
44
getOrCreateRepoByUrl,
5+
logAuditFieldChanges,
56
removeDeclaredPackageRepo,
67
updatePackagistPackageStats,
78
upsertPackageMaintainers,
@@ -18,13 +19,15 @@ vi.mock('@crowd/data-access-layer/src/packages', () => ({
1819
getOrCreateRepoByUrl: vi.fn(),
1920
upsertPackageRepo: vi.fn().mockResolvedValue([]),
2021
removeDeclaredPackageRepo: vi.fn().mockResolvedValue([]),
22+
logAuditFieldChanges: vi.fn(),
2123
}))
2224

2325
const mockUpdate = vi.mocked(updatePackagistPackageStats)
2426
const mockMaintainers = vi.mocked(upsertPackageMaintainers)
2527
const mockRepoGet = vi.mocked(getOrCreateRepoByUrl)
2628
const mockRepoLink = vi.mocked(upsertPackageRepo)
2729
const mockRepoRemove = vi.mocked(removeDeclaredPackageRepo)
30+
const mockAudit = vi.mocked(logAuditFieldChanges)
2831

2932
const qx = {
3033
tx: vi.fn((cb: (t: QueryExecutor) => Promise<void>) => cb(qx)),
@@ -82,6 +85,13 @@ describe('persistPackagistPackageInfo', () => {
8285
expect(result.changedFields).toContain('packages.description')
8386
// maintainer changes must reach the audit log too, matching the npm/pypi paths
8487
expect(result.changedFields).toContain('maintainers.display_name')
88+
// audited atomically inside the same transaction as the writes above
89+
expect(mockAudit).toHaveBeenCalledWith(
90+
qx,
91+
'packagist',
92+
PURL,
93+
expect.arrayContaining(['packages.description', 'maintainers.display_name']),
94+
)
8595
})
8696

8797
it('skips maintainers for a non-critical package but still links the repo', async () => {
@@ -187,6 +197,7 @@ describe('persistPackagistPackageInfo', () => {
187197
expect(result).toEqual({ found: false, changedFields: [] })
188198
expect(mockRepoGet).not.toHaveBeenCalled()
189199
expect(mockMaintainers).not.toHaveBeenCalled()
200+
expect(mockAudit).not.toHaveBeenCalled()
190201
})
191202

192203
it('strips NUL bytes from the description before writing (Postgres rejects them)', async () => {

services/apps/packages_worker/src/packagist/activities.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,10 @@ export async function ingestOnePackagistMetadata(
147147
}
148148

149149
const stats = normalizePackagistStats(info.value.package)
150-
const persistedInfo = await persistPackagistPackageInfo(qx, candidate.purl, stats)
151-
// Audit phase 1 immediately: those writes are already committed
152-
await logAuditFieldChanges(qx, WORKER, candidate.purl, persistedInfo.changedFields)
150+
// persistPackagistPackageInfo audits its own writes atomically, inside the same
151+
// transaction — phase 1 is committed-and-audited before the p2 fetch (which can
152+
// throw) ever runs.
153+
await persistPackagistPackageInfo(qx, candidate.purl, stats)
153154

154155
// Phase 2: p2 endpoint
155156
const p2 = await fetchWithFastRetry(
@@ -172,21 +173,20 @@ export async function ingestOnePackagistMetadata(
172173
}
173174

174175
let lastModified: string | null = null
175-
let phase2ChangedFields: string[] = []
176176
if (!isP2NotModified(p2.value)) {
177177
const expanded = expandComposerMetadata(p2.value.minifiedVersions)
178+
// persistPackagistMetadata audits its own writes atomically, inside the same
179+
// transaction as the aggregate/version/dependency writes.
178180
const persistResult = await persistPackagistMetadata(qx, candidate.purl, expanded)
179181
if (persistResult.unresolvedDependencyTargets > 0) {
180182
log.debug(
181183
{ purl: candidate.purl, unresolved: persistResult.unresolvedDependencyTargets },
182184
'packagist dependency targets not found in packages — edges skipped',
183185
)
184186
}
185-
phase2ChangedFields = persistResult.changedFields
186187
lastModified = p2.value.lastModified
187188
}
188189

189-
await logAuditFieldChanges(qx, WORKER, candidate.purl, phase2ChangedFields)
190190
await markPackagistMetadataScanned(
191191
qx,
192192
candidate.purl,
@@ -219,13 +219,9 @@ export async function ingestOnePackagist30dWindow(
219219
return
220220
}
221221

222-
const changedFields = await persistPackagist30dWindow(
223-
qx,
224-
purl,
225-
info.value.package.downloads?.monthly ?? null,
226-
runDate,
227-
)
228-
await logAuditFieldChanges(qx, WORKER, purl, changedFields)
222+
// persistPackagist30dWindow audits its own write atomically, inside the same
223+
// transaction as the insert-if-absent.
224+
await persistPackagist30dWindow(qx, purl, info.value.package.downloads?.monthly ?? null, runDate)
229225
await markPackagist30dProcessed(qx, purl, { status: 'success', attempts: info.attempts })
230226
}
231227

@@ -258,10 +254,15 @@ export async function ingestOnePackagistDailyDownload(
258254

259255
const daily = info.value.package.downloads?.daily
260256
if (typeof daily === 'number') {
261-
const changedFields = await insertDailyDownloads(qx, candidate.packageId, [
262-
{ day: runDate, downloads: daily },
263-
])
264-
await logAuditFieldChanges(qx, WORKER, candidate.purl, changedFields)
257+
// Insert + audit share one transaction so a failed audit insert can never leave a
258+
// committed row unaudited — a retry would hit ON CONFLICT DO NOTHING and report no
259+
// changes, permanently losing the audit event otherwise.
260+
await qx.tx(async (t) => {
261+
const changedFields = await insertDailyDownloads(t, candidate.packageId, [
262+
{ day: runDate, downloads: daily },
263+
])
264+
await logAuditFieldChanges(t, WORKER, candidate.purl, changedFields)
265+
})
265266
}
266267
await markPackagistDailyProcessed(qx, candidate.purl, {
267268
status: 'success',

services/apps/packages_worker/src/packagist/downloads.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { insertLast30dDownloadIfAbsent } from '@crowd/data-access-layer/src/packages'
1+
import {
2+
insertLast30dDownloadIfAbsent,
3+
logAuditFieldChanges,
4+
} from '@crowd/data-access-layer/src/packages'
25
import type { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
36

7+
const WORKER = 'packagist'
8+
49
// Compute the monthly window for observed rolling 30d downloads.
510
// endDate = first of the run month; startDate = endDate minus 30 days.
611
export function monthlyWindowFor(runDate: string): { startDate: string; endDate: string } {
@@ -21,8 +26,8 @@ export function monthlyWindowFor(runDate: string): { startDate: string; endDate:
2126
// the value is the observation closest to the boundary. insertLast30dDownloadIfAbsent
2227
// does the presence check and the insert (+ mirror) atomically in one statement, so
2328
// concurrent runs for the same purl+month can't both pass a check-then-insert gap and
24-
// have the later one clobber the earlier one's count. Returns the changed fields (like
25-
// the sibling persist*/upsert* functions) — the caller audits them.
29+
// have the later one clobber the earlier one's count. The audit record shares the same
30+
// transaction, so a failed audit insert can never leave a committed window unaudited.
2631
export async function persistPackagist30dWindow(
2732
qx: QueryExecutor,
2833
purl: string,
@@ -32,5 +37,16 @@ export async function persistPackagist30dWindow(
3237
if (monthly == null) return []
3338

3439
const { startDate, endDate } = monthlyWindowFor(runDate)
35-
return insertLast30dDownloadIfAbsent(qx, purl, startDate, endDate, monthly, true)
40+
return qx.tx(async (t) => {
41+
const changedFields = await insertLast30dDownloadIfAbsent(
42+
t,
43+
purl,
44+
startDate,
45+
endDate,
46+
monthly,
47+
true,
48+
)
49+
await logAuditFieldChanges(t, WORKER, purl, changedFields)
50+
return changedFields
51+
})
3652
}

0 commit comments

Comments
 (0)