Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5b98a71
fix: retry rejected capture batches and self-heal newly-gapped repos …
themarolt Sep 23, 2026
da1a8e5
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
94f3a5a
style: fix formatting (CM-1441)
themarolt Sep 23, 2026
ba9aed1
fix: paginate self-heal gap detection, fix capture retry guard (CM-1441)
themarolt Sep 23, 2026
db835a1
fix: version new workflow branches with patched() for safe rolling de…
themarolt Sep 23, 2026
d640f9a
style: trim patched() comments to 2 lines (CM-1441)
themarolt Sep 23, 2026
d0b911d
style: drop ticket id from patched() marker names (CM-1441)
themarolt Sep 23, 2026
23251cc
fix: count rejected batches as failed on non-patched path, trim stale…
themarolt Sep 23, 2026
5cd44e8
style: drop ticket refs from code comments (CM-1441)
themarolt Sep 23, 2026
2f49736
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
56df80f
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
1557d30
style: trim gap-count comment to 2 lines (CM-1441)
themarolt Sep 23, 2026
a2e1e3b
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
41a373d
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
974953f
fix: don't advance gapHealDone when patched() suppresses the scan (CM…
themarolt Sep 23, 2026
4aea84d
style: run oxfmt on selfHealStarBackfill (CM-1441)
themarolt Sep 23, 2026
d896af1
fix: preserve gapHealAfterUrl cursor when patched() suppresses the sc…
themarolt Sep 23, 2026
4838b15
fix: keep continue/complete decision unaffected by gap heal when unpa…
themarolt Sep 23, 2026
6418bb3
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
bab10a2
fix: restore original Temporal patch ID for rejected-batch retry (CM-…
themarolt Sep 23, 2026
874e849
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
61c8fbf
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
d79e3bc
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
149742b
fix: restore original Temporal patch ID for gap-heal scan (CM-1441)
themarolt Sep 23, 2026
bf70c8c
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
3504534
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
ad62b89
fix: namespace gap-heal batch child-workflow IDs to avoid main-scan c…
themarolt Sep 23, 2026
b61e841
fix: trim comments over 2-line limit (CM-1441)
themarolt Sep 23, 2026
7dfa1f9
Merge remote-tracking branch 'origin/main' into fix/CM-1441-star-snap…
themarolt Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { IS_DEV_ENV, IS_PROD_ENV } from '@crowd/common'
import {
IRepoStarSnapshotGapDays,
countDeadLetteredStarBackfillFailures,
findAllRepoIdsWithStarSnapshotGaps,
findDeadLetteredStarBackfillFailures,
findRepoIdsWithStarSnapshotGaps,
findReposForStarSnapshot,
findStarSnapshotGapDaysForRepos,
getDeadLetterReportCursor,
Expand All @@ -25,8 +25,7 @@ import { IJobDefinition } from '../types'
const LAST_DEAD_LETTER_REPORTED_AT_KEY =
'star-snapshot-health-reporting:last-dead-letter-reported-at'
const SAMPLE_SIZE = 20
// Keeps each gap-check query's IN-list bounded as the eligible repo count grows.
const GAP_CHECK_BATCH_SIZE = 5_000
const GAP_DAYS_BATCH_SIZE = 5_000

const job: IJobDefinition = {
name: 'star-snapshot-health-reporting',
Expand Down Expand Up @@ -55,27 +54,28 @@ const job: IJobDefinition = {

const repoUrlById = new Map(allRepos.map((repo) => [repo.repositoryId, repo.repoUrl]))
const allRepoIds = allRepos.map((repo) => repo.repositoryId)
const gappedRepoIds: string[] = []
for (let i = 0; i < allRepoIds.length; i += GAP_CHECK_BATCH_SIZE) {
const batch = allRepoIds.slice(i, i + GAP_CHECK_BATCH_SIZE)
gappedRepoIds.push(...(await findRepoIdsWithStarSnapshotGaps(qx, batch)))
}
const gappedRepoIds = await findAllRepoIdsWithStarSnapshotGaps(qx, allRepoIds)

const gapDays: IRepoStarSnapshotGapDays[] = []
for (let i = 0; i < gappedRepoIds.length; i += GAP_CHECK_BATCH_SIZE) {
const batch = gappedRepoIds.slice(i, i + GAP_CHECK_BATCH_SIZE)
for (let i = 0; i < gappedRepoIds.length; i += GAP_DAYS_BATCH_SIZE) {
const batch = gappedRepoIds.slice(i, i + GAP_DAYS_BATCH_SIZE)
gapDays.push(...(await findStarSnapshotGapDaysForRepos(qx, batch)))
}
const missingDaysByRepoId = new Map(gapDays.map((gap) => [gap.repositoryId, gap.missingDays]))
const totalMissingDays = gapDays.reduce((sum, gap) => sum + gap.missingDays, 0)
// The two queries above run seconds apart, so a repo can close its gap in between and
// come back with 0 missing days - drop those instead of over-reporting the gap count.
const currentlyGappedRepoIds = gapDays
.filter((gap) => gap.missingDays > 0)
.map((gap) => gap.repositoryId)

const sections: SlackMessageSection[] = [
{
title: 'Star Snapshot Health Summary',
text: [
`🪦 New repos GitHub gave up retrying (3 failures in a row, excl. repo-gone/IP-allowlist): *${newlyDeadLettered.length}*`,
`📉 Total repos GitHub gave up retrying: *${totalDeadLettered}*`,
`📅 Repos with a snapshot gap right now: *${gappedRepoIds.length}*`,
`📅 Repos with a snapshot gap right now: *${currentlyGappedRepoIds.length}*`,
`📆 Total missing snapshot-days across those repos: *${totalMissingDays}*`,
].join('\n'),
},
Expand All @@ -93,8 +93,8 @@ const job: IJobDefinition = {
})
}

if (gappedRepoIds.length > 0) {
const sortedByMissingDays = [...gappedRepoIds].sort(
if (currentlyGappedRepoIds.length > 0) {
const sortedByMissingDays = [...currentlyGappedRepoIds].sort(
(a, b) => (missingDaysByRepoId.get(b) ?? 0) - (missingDaysByRepoId.get(a) ?? 0),
)
const shown = sortedByMissingDays.slice(0, SAMPLE_SIZE)
Expand All @@ -104,13 +104,13 @@ const job: IJobDefinition = {
return `• \`${url}\` - missing ${days} day${days === 1 ? '' : 's'}`
})
sections.push({
title: `Snapshot Gaps (top ${shown.length} of ${gappedRepoIds.length}, most days missing first)`,
title: `Snapshot Gaps (top ${shown.length} of ${currentlyGappedRepoIds.length}, most days missing first)`,
text: lines.join('\n'),
})
}

const persona =
newlyDeadLettered.length > 0 || gappedRepoIds.length > 0
newlyDeadLettered.length > 0 || currentlyGappedRepoIds.length > 0
? SlackPersona.WARNING_PROPAGATOR
: SlackPersona.INFO_NOTIFIER

Expand All @@ -128,7 +128,7 @@ const job: IJobDefinition = {
}

ctx.log.info(
`Star snapshot health report sent: newlyDeadLettered=${newlyDeadLettered.length}, totalDeadLettered=${totalDeadLettered}, gaps=${gappedRepoIds.length}`,
`Star snapshot health report sent: newlyDeadLettered=${newlyDeadLettered.length}, totalDeadLettered=${totalDeadLettered}, gaps=${currentlyGappedRepoIds.length}`,
)
},
}
Expand Down
2 changes: 2 additions & 0 deletions services/apps/star_snapshot_worker/src/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {
backfillRepoStarHistory,
fetchAndSaveStarSnapshotBatch,
findReposForStarSnapshot,
findReposNeedingGapHeal,
findReposNeedingStarBackfill,
} from './activities/index'

export {
backfillRepoStarHistory,
fetchAndSaveStarSnapshotBatch,
findReposForStarSnapshot,
findReposNeedingGapHeal,
findReposNeedingStarBackfill,
}
29 changes: 29 additions & 0 deletions services/apps/star_snapshot_worker/src/activities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ApplicationFailure } from '@temporalio/client'

import { getGithubInstallationToken } from '@crowd/common_services'
import {
findAllRepoIdsWithStarSnapshotGaps,
findCompletedReposEligibleForGapHeal,
findReposForStarSnapshot as findReposForStarSnapshotQx,
findReposNeedingStarBackfill as findReposNeedingStarBackfillQx,
recordStarBackfillFailure,
Expand Down Expand Up @@ -395,6 +397,33 @@ export async function findReposNeedingStarBackfill(
return findReposNeedingStarBackfillQx(qx, limit, afterUrl)
}

export interface IGapHealPage {
gappedRepos: IRepoForStarSnapshot[]
pageSize: number
lastUrl?: string
}

// A completed repo can still pick up a fresh gap (e.g. a dropped capture batch) - this
// finds those so selfHealStarBackfill re-sweeps them too, paginated like the backfill scan.
export async function findReposNeedingGapHeal(
limit: number,
afterUrl?: string,
): Promise<IGapHealPage> {
const qx = pgpQx(svc.postgres.reader.connection())
const page = await findCompletedReposEligibleForGapHeal(qx, limit, afterUrl)
const gappedIds = new Set(
await findAllRepoIdsWithStarSnapshotGaps(
qx,
page.map((repo) => repo.repositoryId),
),
)
return {
gappedRepos: page.filter((repo) => gappedIds.has(repo.repositoryId)),
pageSize: page.length,
lastUrl: page.length > 0 ? page[page.length - 1].repoUrl : undefined,
}
}

// A leftover claim only costs a repo one skipped run before the TTL clears it - not worth
// forcing a full activity retry (re-fetching the entire stargazer history) over.
async function releaseInflightClaim(cache: RedisCache, repositoryId: string): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { randomUUID } from 'crypto'
import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { dirname } from 'path'

import { findRepoIdsWithStarSnapshotGaps } from '@crowd/data-access-layer'
import {
findAllRepoIdsWithStarSnapshotGaps,
findRepoIdsWithStarSnapshotGaps,
findReposForStarSnapshot,
} from '@crowd/data-access-layer'
import { WRITE_DB_CONFIG, getDbConnection } from '@crowd/data-access-layer/src/database'
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
import { getServiceLogger } from '@crowd/logging'
Expand All @@ -29,6 +33,12 @@ const DEFAULT_RESERVED_CORE_RATE_LIMIT = 2_000
const DEFAULT_CONCURRENCY = 5
const DEFAULT_CHECKPOINT_FILE = '/var/lib/star-snapshot-worker/backfill-checkpoint.json'
const DEFAULT_COMPLETED_REPOS_FILE = '/var/lib/star-snapshot-worker/backfill-completed-repos.json'
// --gapped-only keeps its own progress files so it can't clobber a normal full-sweep run's
// checkpoint/completed-repos state (or vice versa) if the two are ever run against each other.
const DEFAULT_GAPPED_ONLY_CHECKPOINT_FILE =
'/var/lib/star-snapshot-worker/backfill-gapped-only-checkpoint.json'
const DEFAULT_GAPPED_ONLY_COMPLETED_REPOS_FILE =
'/var/lib/star-snapshot-worker/backfill-gapped-only-completed-repos.json'

interface Checkpoint {
afterUrl: string
Expand Down Expand Up @@ -106,11 +116,18 @@ async function clearCompletedRepoIds(path: string): Promise<void> {
const main = async () => {
const dryRun = process.argv.includes('--dry-run')
const fresh = process.argv.includes('--fresh')
// Targets only currently-gapped repos instead of sweeping every eligible one - fixes a
// specific gap on demand without a full backfill pass.
const gappedOnly = process.argv.includes('--gapped-only')
const afterUrlOverride = readFlagValue('--after-url')
const checkpointFile =
process.env.STAR_SNAPSHOT_BACKFILL_CHECKPOINT_FILE ?? DEFAULT_CHECKPOINT_FILE
const completedReposFile =
process.env.STAR_SNAPSHOT_BACKFILL_COMPLETED_REPOS_FILE ?? DEFAULT_COMPLETED_REPOS_FILE
const checkpointFile = gappedOnly
? (process.env.STAR_SNAPSHOT_BACKFILL_GAPPED_ONLY_CHECKPOINT_FILE ??
DEFAULT_GAPPED_ONLY_CHECKPOINT_FILE)
: (process.env.STAR_SNAPSHOT_BACKFILL_CHECKPOINT_FILE ?? DEFAULT_CHECKPOINT_FILE)
const completedReposFile = gappedOnly
? (process.env.STAR_SNAPSHOT_BACKFILL_GAPPED_ONLY_COMPLETED_REPOS_FILE ??
DEFAULT_GAPPED_ONLY_COMPLETED_REPOS_FILE)
: (process.env.STAR_SNAPSHOT_BACKFILL_COMPLETED_REPOS_FILE ?? DEFAULT_COMPLETED_REPOS_FILE)
const reservedCoreRateLimit = readIntEnv(
'STAR_SNAPSHOT_BACKFILL_RESERVED_CORE_RATE_LIMIT',
DEFAULT_RESERVED_CORE_RATE_LIMIT,
Expand All @@ -119,12 +136,14 @@ const main = async () => {
const concurrency = readIntEnv('STAR_SNAPSHOT_BACKFILL_CONCURRENCY', DEFAULT_CONCURRENCY, false)

let afterUrl = afterUrlOverride
let resumedFromCheckpoint = false
if (!afterUrl && fresh) {
await clearCheckpoint(checkpointFile)
} else if (!afterUrl) {
const checkpoint = await readCheckpoint(checkpointFile)
if (checkpoint) {
afterUrl = checkpoint.afterUrl
resumedFromCheckpoint = true
log.info({ checkpoint }, 'resuming star snapshot backfill from checkpoint')
}
}
Expand All @@ -142,6 +161,7 @@ const main = async () => {
{
dryRun,
fresh,
gappedOnly,
afterUrl,
checkpointFile,
completedReposFile,
Expand All @@ -157,9 +177,31 @@ const main = async () => {
await qx.selectOne('SELECT 1')
log.info('Connected to database.')

// A repo can be marked complete yet still have a gap (e.g. an earlier interrupted
// run) - re-check and reprocess it instead of trusting the flag forever.
if (completedRepoIds.size > 0) {
if (gappedOnly && !resumedFromCheckpoint) {
// Everyone not currently gapped is pre-marked "completed" so the scan/skip loop below
// flies through them, touching only the repos actually gapped right now.
const allRepos = await findReposForStarSnapshot(qx)
const gappedRepoIds = new Set(
await findAllRepoIdsWithStarSnapshotGaps(
qx,
allRepos.map((repo) => repo.repositoryId),
),
)
completedRepoIds.clear()
for (const repo of allRepos) {
if (!gappedRepoIds.has(repo.repositoryId)) {
completedRepoIds.add(repo.repositoryId)
}
}
log.info(
{ totalRepos: allRepos.length, gappedRepoCount: gappedRepoIds.size },
'gapped-only backfill targeting currently-gapped repos',
)
}

// A repo can be marked complete yet still have a gap (e.g. an interrupted run) - re-check
// it. Skipped in gapped-only mode, which already re-derives completedRepoIds every run.
if (!gappedOnly && completedRepoIds.size > 0) {
const gappedRepoIds = await findRepoIdsWithStarSnapshotGaps(qx, [...completedRepoIds])
const unmarked = gappedRepoIds.filter((id) => completedRepoIds.delete(id)).length
if (unmarked > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ApplicationFailure,
continueAsNew,
log,
patched,
proxyActivities,
sleep,
workflowInfo,
Expand All @@ -19,6 +20,9 @@ const { findReposForStarSnapshot, fetchAndSaveStarSnapshotBatch } = proxyActivit
const GRAPHQL_BATCH_SIZE = 100
const CONCURRENCY = 5
const PAGE_SIZE = 2_000
// A rejected batch usually means a transient blip - one retry pass after a cooldown
// recovers most of them instead of leaving a permanent gap for that day.
const REJECTED_BATCH_RETRY_DELAY_MS = 30_000

export interface ICaptureStarSnapshotsArgs {
capturedAt?: string
Expand All @@ -39,12 +43,15 @@ export async function captureStarSnapshots(args: ICaptureStarSnapshotsArgs = {})

let succeeded = args.succeededSoFar ?? 0
let failed = args.failedSoFar ?? 0
let rejectedBatches = 0
let rejectedBatches: (typeof batches)[number][] = []

for (let i = 0; i < batches.length; i += CONCURRENCY) {
// Rate-limited batches retry in place via a durable workflow sleep, not a blocking
// activity call - mirrors backfillStarHistoryBatch's handling of the same quota problem.
let window = batches.slice(i, i + CONCURRENCY)
// Runs one window of batches to completion, handling rate-limit backoff in place.
// Returns the batches that were still rejected (activity retries exhausted) when done.
const runWindow = async (
initialWindow: (typeof batches)[number][],
): Promise<(typeof batches)[number][]> => {
let window = initialWindow
const rejected: (typeof batches)[number][] = []

while (window.length > 0) {
const results = await Promise.allSettled(
Expand All @@ -56,8 +63,7 @@ export async function captureStarSnapshots(args: ICaptureStarSnapshotsArgs = {})

for (const [idx, result] of results.entries()) {
if (result.status === 'rejected') {
rejectedBatches++
failed += window[idx].length
rejected.push(window[idx])
log.warn('Failed to capture star snapshot batch', {
repoCount: window[idx].length,
error: (result.reason as Error)?.message ?? result.reason,
Expand Down Expand Up @@ -93,13 +99,37 @@ export async function captureStarSnapshots(args: ICaptureStarSnapshotsArgs = {})
await sleep(waitMs)
}
}

return rejected
}

for (let i = 0; i < batches.length; i += CONCURRENCY) {
rejectedBatches.push(...(await runWindow(batches.slice(i, i + CONCURRENCY))))
}

// patched() keeps an execution already in flight on its old command sequence so a
// mid-deploy replay doesn't hit a nondeterminism error.
if (rejectedBatches.length > 0 && patched('CM-1441-retry-rejected-batches')) {
await sleep(REJECTED_BATCH_RETRY_DELAY_MS)
const stillRejected: (typeof batches)[number][] = []
for (let i = 0; i < rejectedBatches.length; i += CONCURRENCY) {
stillRejected.push(...(await runWindow(rejectedBatches.slice(i, i + CONCURRENCY))))
}
for (const batch of stillRejected) {
failed += batch.length
}
rejectedBatches = stillRejected
} else {
for (const batch of rejectedBatches) {
failed += batch.length
}
}

const total = (args.totalSoFar ?? 0) + repos.length

// A few rejected batches self-heal (next run's diff, or the gap backfill) - only a fully
// wiped-out page signals something systemic (auth/token/outage) worth failing the run over.
if (batches.length > 0 && rejectedBatches === batches.length) {
if (batches.length > 0 && rejectedBatches.length === batches.length) {
// A plain Error only fails the workflow task (infinite replay); ApplicationFailure
// is required to fail the execution so the schedule's retry policy engages.
throw ApplicationFailure.create({
Expand All @@ -108,9 +138,9 @@ export async function captureStarSnapshots(args: ICaptureStarSnapshotsArgs = {})
})
}

if (rejectedBatches > 0) {
if (rejectedBatches.length > 0) {
log.error('star snapshot capture had partial batch failures on this page', {
rejectedBatches,
rejectedBatches: rejectedBatches.length,
totalBatches: batches.length,
succeeded,
failed,
Expand Down
Loading
Loading