Skip to content

Commit b342b7c

Browse files
committed
fix: add force to backfill
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 4535615 commit b342b7c

5 files changed

Lines changed: 257 additions & 7 deletions

File tree

services/apps/packages_worker/src/bin/maven-backfill.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { getServiceLogger } from '@crowd/logging'
22

33
import { getMavenConfig } from '../config'
44
import { getPackagesDb } from '../db'
5-
import { runMavenCriticalBackfill } from '../maven/runMavenEnrichmentLoop'
5+
import {
6+
runMavenCriticalBackfill,
7+
runMavenCriticalForceBackfill,
8+
} from '../maven/runMavenEnrichmentLoop'
69

710
const log = getServiceLogger()
811

@@ -25,7 +28,14 @@ const main = async () => {
2528
process.env.MAVEN_FETCHER_BASE_URL_BACKFILL ??
2629
'https://maven-central.storage-download.googleapis.com/maven2'
2730

28-
log.info('maven backfill starting (one-shot, full extraction)...')
31+
// --force: re-run POM extraction over EVERY critical row, ignoring the
32+
// staleness window. Use to fully re-apply extraction changes (e.g. SCM
33+
// interpolation) after the queue has already drained. The default path only
34+
// picks rows due by refreshDays and cannot be coaxed into a full pass by
35+
// setting refreshDays=0 (that reprocesses the first batch forever).
36+
const force = process.argv.includes('--force')
37+
38+
log.info({ force }, 'maven backfill starting (one-shot, full extraction)...')
2939

3040
const config = getMavenConfig()
3141
log.info(config, 'Config loaded')
@@ -34,7 +44,9 @@ const main = async () => {
3444
await qx.selectOne('SELECT 1')
3545
log.info('Connected to packages-db.')
3646

37-
const totals = await runMavenCriticalBackfill(qx, config, () => shuttingDown)
47+
const totals = force
48+
? await runMavenCriticalForceBackfill(qx, config, () => shuttingDown)
49+
: await runMavenCriticalBackfill(qx, config, () => shuttingDown)
3850

3951
log.info({ ...totals }, 'maven backfill complete')
4052
process.exit(0)

services/apps/packages_worker/src/maven/__tests__/normalize.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { normalizeScmUrl } from '../extract'
3+
import { interpolateProperties, normalizeScmUrl } from '../extract'
44
import { pickStableRelease } from '../metadata'
55
import { isPrerelease, parseRepoUrl } from '../normalize'
66

@@ -195,6 +195,12 @@ describe('normalizeScmUrl', () => {
195195
expect(normalizeScmUrl('http://cvs.sourceforge.net/cgi-bin/viewcvs.cgi/foo')).toBeNull()
196196
})
197197

198+
it('returns null when an unresolved placeholder is embedded in the repo path', () => {
199+
// Would otherwise parse to github.com/owner/%7BartifactId%7D and slip through.
200+
expect(normalizeScmUrl('https://github.com/owner/${artifactId}')).toBeNull()
201+
expect(normalizeScmUrl('scm:git:https://github.com/${owner}/repo.git')).toBeNull()
202+
})
203+
198204
// SCP colon form: "host:owner/repo" where the colon is a path separator, not a port
199205
it('recovers bare host:owner/repo SCP colon form', () => {
200206
expect(normalizeScmUrl('github.com:japgolly/scalacss.git')).toBe(
@@ -243,3 +249,56 @@ describe('normalizeScmUrl', () => {
243249
expect(normalizeScmUrl('https://android.googlesource.com/platform/tools/base')).toBeNull()
244250
})
245251
})
252+
253+
describe('interpolateProperties', () => {
254+
it('resolves a single ${...} placeholder from properties', () => {
255+
expect(
256+
interpolateProperties('${scm.github.url}', {
257+
'scm.github.url': 'https://github.com/owner/repo',
258+
}),
259+
).toBe('https://github.com/owner/repo')
260+
})
261+
262+
it('resolves multiple placeholders in one string', () => {
263+
expect(
264+
interpolateProperties('https://gitlab.com/${projectPath}', {
265+
projectPath: 'group/project',
266+
}),
267+
).toBe('https://gitlab.com/group/project')
268+
})
269+
270+
it('resolves nested placeholders recursively', () => {
271+
expect(
272+
interpolateProperties('${scm.url}', {
273+
'scm.url': '${scm.base}/repo',
274+
'scm.base': 'https://github.com/owner',
275+
}),
276+
).toBe('https://github.com/owner/repo')
277+
})
278+
279+
it('resolves built-in project.* style placeholders', () => {
280+
expect(
281+
interpolateProperties('https://github.com/acme/${project.artifactId}', {
282+
'project.artifactId': 'my-lib',
283+
}),
284+
).toBe('https://github.com/acme/my-lib')
285+
})
286+
287+
it('leaves unresolved placeholders literal (missing property / method call)', () => {
288+
expect(interpolateProperties('${scm.github.url}', {})).toBe('${scm.github.url}')
289+
expect(
290+
interpolateProperties('${pom.artifactId.substring(8)}', { 'pom.artifactId': 'foo' }),
291+
).toBe('${pom.artifactId.substring(8)}')
292+
})
293+
294+
it('does not loop forever on self-referential placeholders', () => {
295+
expect(interpolateProperties('${a}', { a: '${b}', b: '${a}' })).toContain('${')
296+
})
297+
298+
it('composes with the normalizer end-to-end', () => {
299+
const resolved = interpolateProperties('${scm.github.url}', {
300+
'scm.github.url': 'scm:git:https://github.com/Owner/Repo.git',
301+
})
302+
expect(normalizeScmUrl(resolved)).toBe('https://github.com/owner/repo')
303+
})
304+
})

services/apps/packages_worker/src/maven/extract.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ interface PomData {
4747
developers?: { developer?: unknown }
4848
contributors?: { contributor?: unknown }
4949
parent?: { groupId?: unknown; artifactId?: unknown; version?: unknown }
50+
properties?: unknown
5051
}
5152

5253
interface PomPerson {
@@ -262,6 +263,9 @@ interface ResolvedFields {
262263
developers: PomMaintainer[]
263264
contributors: PomMaintainer[]
264265
hops: number
266+
// Merged <properties> across the resolved parent chain (child overrides parent),
267+
// used to interpolate ${...} placeholders in the SCM URL.
268+
properties: Record<string, string>
265269
}
266270

267271
// prettier-ignore
@@ -273,9 +277,12 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
273277
const scmUrl = extractStr(pom.scm?.url ?? pom.scm?.connection)
274278
const developers = extractPersons(pom.developers?.developer, 'author')
275279
const contributors = extractPersons(pom.contributors?.contributor, 'maintainer')
280+
const properties = extractProperties(pom)
276281

277282
const missingLicense = licenses.length === 0
278-
const missingScm = !scmUrl
283+
// An unresolved ${...} placeholder counts as missing: the property that defines it
284+
// may live in a parent POM, so we still need to walk the chain to collect it.
285+
const missingScm = !scmUrl || scmUrl.includes('${')
279286
const missingDevelopers = developers.length === 0 || contributors.length === 0
280287
const parent = extractParent(pom)
281288

@@ -306,6 +313,8 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
306313
developers: developers.length > 0 ? developers : parentFields.developers,
307314
contributors: contributors.length > 0 ? contributors : parentFields.contributors,
308315
hops: parentFields.hops,
316+
// Child properties override the parent's.
317+
properties: { ...parentFields.properties, ...properties },
309318
}
310319
}
311320
}
@@ -319,6 +328,7 @@ async function resolveWithInheritance(groupId: string, artifactId: string, versi
319328
developers,
320329
contributors,
321330
hops: depth,
331+
properties,
322332
}
323333
}
324334

@@ -355,7 +365,12 @@ export async function extractArtifactDirect(groupId: string, artifactId: string,
355365
}
356366

357367
const licenses = extractLicenses(pom)
358-
const scmUrl = extractStr(pom.scm?.url ?? pom.scm?.connection)
368+
const rawScmUrl = extractStr(pom.scm?.url ?? pom.scm?.connection)
369+
const props = {
370+
...extractProperties(pom),
371+
...builtinProjectProperties(groupId, artifactId, version),
372+
}
373+
const scmUrl = rawScmUrl ? interpolateProperties(rawScmUrl, props) : null
359374
const developers = extractPersons(pom.developers?.developer, 'author')
360375
const contributors = extractPersons(pom.contributors?.contributor, 'maintainer')
361376

@@ -414,6 +429,14 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
414429
new Set(),
415430
baseUrl,
416431
)
432+
// Resolve ${...} placeholders in the SCM URL using the merged chain properties
433+
// plus the leaf's built-in project.* values. Best-effort: unresolved placeholders
434+
// stay literal and are rejected downstream by normalizeScmUrl.
435+
const props = {
436+
...resolved.properties,
437+
...builtinProjectProperties(groupId, artifactId, version),
438+
}
439+
const scmUrl = resolved.scmUrl ? interpolateProperties(resolved.scmUrl, props) : null
417440
return {
418441
groupId,
419442
artifactId,
@@ -422,7 +445,7 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
422445
description: resolved.description,
423446
licenses: resolved.licenses,
424447
licensesRaw: resolved.licensesRaw,
425-
scmUrl: resolved.scmUrl,
448+
scmUrl,
426449
homepageUrl: resolved.homepageUrl,
427450
developers: resolved.developers,
428451
contributors: resolved.contributors,
@@ -524,6 +547,12 @@ export function normalizeScmUrl(raw: string | null): string | null {
524547
let s = raw.trim()
525548
if (!s) return null
526549

550+
// A leftover ${...} means best-effort interpolation upstream could not resolve it.
551+
// Reject outright: a placeholder embedded in the path (e.g. github.com/owner/${x})
552+
// otherwise survives URL parsing (percent-encoded to %7B…%7D) and would yield a junk
553+
// repository_url instead of null.
554+
if (s.includes('${')) return null
555+
527556
// Strip Maven scm:git: / scm: prefix
528557
s = s.replace(/^scm:git:/i, '').replace(/^scm:/i, '')
529558

@@ -612,6 +641,59 @@ function extractPersons(raw: unknown, role: 'author' | 'maintainer'): PomMaintai
612641
}))
613642
}
614643

644+
/** Flattens a POM's <properties> block into a string→string map (non-string values skipped). */
645+
function extractProperties(pom: PomData): Record<string, string> {
646+
const raw = pom.properties
647+
if (!raw || typeof raw !== 'object') return {}
648+
const out: Record<string, string> = {}
649+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
650+
if (typeof value === 'string') out[key] = value
651+
else if (typeof value === 'number') out[key] = String(value)
652+
}
653+
return out
654+
}
655+
656+
/** Maven built-in project.* / pom.* properties for the leaf coordinates. */
657+
function builtinProjectProperties(
658+
groupId: string,
659+
artifactId: string,
660+
version: string,
661+
): Record<string, string> {
662+
return {
663+
'project.groupId': groupId,
664+
'project.artifactId': artifactId,
665+
'project.version': version,
666+
'pom.groupId': groupId,
667+
'pom.artifactId': artifactId,
668+
'pom.version': version,
669+
groupId,
670+
artifactId,
671+
version,
672+
}
673+
}
674+
675+
const MAX_INTERPOLATION_DEPTH = 10
676+
677+
/**
678+
* Best-effort Maven property interpolation of ${...} placeholders. Resolves
679+
* recursively (a property value may itself reference another) up to a depth cap
680+
* to guard against cycles. Placeholders with no matching property (e.g. defined
681+
* in a profile/settings, or method calls like ${x.substring(8)}) are left as-is
682+
* so the SCM normaliser rejects them.
683+
*/
684+
export function interpolateProperties(value: string, props: Record<string, string>): string {
685+
let current = value
686+
for (let i = 0; i < MAX_INTERPOLATION_DEPTH && current.includes('${'); i++) {
687+
const next = current.replace(/\$\{([^{}]+)\}/g, (match, key) => {
688+
const resolved = props[(key as string).trim()]
689+
return resolved !== undefined ? resolved : match
690+
})
691+
if (next === current) break
692+
current = next
693+
}
694+
return current
695+
}
696+
615697
function extractParent(
616698
pom: PomData,
617699
): { groupId: string; artifactId: string; version: string } | null {
@@ -634,5 +716,6 @@ function emptyFields(hops: number): ResolvedFields {
634716
developers: [],
635717
contributors: [],
636718
hops,
719+
properties: {},
637720
}
638721
}

services/apps/packages_worker/src/maven/runMavenEnrichmentLoop.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
MavenPackageToSync,
33
QueryExecutor,
4+
listMavenCriticalPackagesById,
45
listMavenPackagesToSync,
56
logAuditFieldChange,
67
replacePackageMaintainers,
@@ -497,3 +498,63 @@ async function runPhase(qx: QueryExecutor, config: MavenConfig, isCritical: bool
497498
export async function runMavenCriticalBackfill(qx: QueryExecutor, config: MavenConfig, isShuttingDown: () => boolean): Promise<BatchResult> {
498499
return runPhase(qx, config, true, isShuttingDown)
499500
}
501+
502+
/**
503+
* Force full-refresh backfill: re-runs POM extraction over EVERY critical Maven
504+
* row, ignoring the staleness window. Unlike runMavenCriticalBackfill (which
505+
* drains listMavenPackagesToSync by predicate and cannot terminate with
506+
* refreshDays=0 — the freshly-synced top rows re-qualify every batch), this pages
507+
* strictly by `id > afterId`, so each row is processed exactly once and the scan
508+
* always terminates. Every page runs with forceFullExtraction=true.
509+
*
510+
* Trade-off vs. the normal path: rows are visited in id order, not
511+
* dependent_count order, so if interrupted the most-depended-on packages are not
512+
* guaranteed to be done first. Restart re-scans from id 0 (idempotent upserts).
513+
*/
514+
// prettier-ignore
515+
export async function runMavenCriticalForceBackfill(qx: QueryExecutor, config: MavenConfig, isShuttingDown: () => boolean): Promise<BatchResult> {
516+
const total: BatchResult = { processed: 0, skipped: 0, error: 0, unchanged: 0 }
517+
const startedAt = Date.now()
518+
// Cursor kept as a string: id is a Postgres bigint, and Number() coercion would
519+
// silently lose precision above 2^53, corrupting the cursor and skipping rows.
520+
let afterId = '0'
521+
let batchNum = 0
522+
523+
log.info('Force full-refresh started (all critical rows, keyset by id, ignoring refreshDays)')
524+
525+
while (!isShuttingDown()) {
526+
const page = await listMavenCriticalPackagesById(qx, { afterId, limit: config.batchSize })
527+
if (page.length === 0) break
528+
529+
// Capture the cursor before processPackages reorders the page in place (it
530+
// clusters by namespace for the parent-POM cache). Rows come back id-ordered,
531+
// so the max id is the last element — kept as a string to preserve bigint precision.
532+
afterId = page[page.length - 1].id
533+
534+
const result = await processPackages(qx, config, page, true, true)
535+
batchNum++
536+
total.processed += result.processed
537+
total.skipped += result.skipped
538+
total.error += result.error
539+
total.unchanged += result.unchanged
540+
541+
log.info(
542+
{
543+
batch: batchNum,
544+
afterId,
545+
totalProcessed: total.processed,
546+
totalSkipped: total.skipped,
547+
totalUnchanged: total.unchanged,
548+
totalErrors: total.error,
549+
elapsedSec: Math.round((Date.now() - startedAt) / 1000),
550+
},
551+
'Force batch done',
552+
)
553+
}
554+
555+
log.info(
556+
{ ...total, durationSec: Math.round((Date.now() - startedAt) / 1000) },
557+
'Force full-refresh complete',
558+
)
559+
return total
560+
}

services/libs/data-access-layer/src/osspckgs/packages.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,41 @@ export async function listMavenPackagesToSync(
118118
)
119119
}
120120

121+
/**
122+
* Keyset-paginated scan of every critical Maven package, independent of the
123+
* staleness window. Unlike `listMavenPackagesToSync` — which drains by predicate
124+
* (a row leaves the set once it is fresh) and therefore cannot terminate with
125+
* refreshDays=0 — this pages strictly by `id > afterId`, so each row is visited
126+
* exactly once and the scan always terminates. Used by the `--force` full-refresh
127+
* backfill to re-run POM extraction over the entire critical set regardless of
128+
* last_synced_at / ingestion_source.
129+
*/
130+
export async function listMavenCriticalPackagesById(
131+
qx: QueryExecutor,
132+
options: { afterId: string; limit: number },
133+
): Promise<MavenPackageToSync[]> {
134+
return qx.select(
135+
`
136+
SELECT
137+
p.id,
138+
p.purl,
139+
p.namespace,
140+
p.name,
141+
p.dependent_count AS "dependentPackagesCount",
142+
p.dependent_repos_count AS "dependentReposCount",
143+
p.latest_version AS "latestVersion"
144+
FROM packages p
145+
WHERE p.ecosystem = 'maven'
146+
AND p.is_critical
147+
AND p.namespace IS NOT NULL
148+
AND p.id > $(afterId)::bigint
149+
ORDER BY p.id ASC
150+
LIMIT $(limit)
151+
`,
152+
{ afterId: options.afterId, limit: options.limit },
153+
)
154+
}
155+
121156
// ─── repository_url backfill ──────────────────────────────────────────────────
122157

123158
export type MavenRepoUrlRow = {

0 commit comments

Comments
 (0)