diff --git a/__test__/mirror-cleanup.test.ts b/__test__/mirror-cleanup.test.ts index 4243f58..fd3935c 100644 --- a/__test__/mirror-cleanup.test.ts +++ b/__test__/mirror-cleanup.test.ts @@ -95,7 +95,10 @@ describe('cleanup commit decision', () => { it('runs bounded maintenance instead of gc --auto and commits', async () => { const result = await blacksmithCache.cleanup({...base, mirrorPath}) - expect(result.maintenanceResult).toEqual({success: true, timedOut: false}) + expect(result.maintenanceResult).toMatchObject({ + success: true, + timedOut: false + }) const repack = commands().find(c => c.includes('repack')) expect(repack).toBeDefined() expect(repack).toEqual( @@ -129,7 +132,7 @@ describe('cleanup commit decision', () => { repackExitCode = 128 const result = await blacksmithCache.cleanup({...base, mirrorPath}) - expect(result.maintenanceResult).toEqual({ + expect(result.maintenanceResult).toMatchObject({ success: false, timedOut: false, error: expect.stringContaining('128') @@ -143,7 +146,7 @@ describe('cleanup commit decision', () => { packRefsExitCode = 1 const result = await blacksmithCache.cleanup({...base, mirrorPath}) - expect(result.maintenanceResult).toEqual({ + expect(result.maintenanceResult).toMatchObject({ success: false, timedOut: false, error: expect.stringContaining('pack-refs failed with exit code 1') @@ -157,7 +160,7 @@ describe('cleanup commit decision', () => { packRefsExitCode = 124 const result = await blacksmithCache.cleanup({...base, mirrorPath}) - expect(result.maintenanceResult).toEqual({ + expect(result.maintenanceResult).toMatchObject({ success: false, timedOut: true, error: expect.stringContaining('pack-refs timed out') @@ -174,7 +177,11 @@ describe('cleanup commit decision', () => { mirrorSyncFailed: true }) - expect(result.maintenanceResult).toEqual({success: true, timedOut: false}) + expect(result.maintenanceResult).toEqual({ + success: true, + timedOut: false, + skipped: true + }) expect(commands().some(c => c.includes('repack'))).toBe(false) expect(mockCommitStickyDisk).toHaveBeenCalledWith( expect.objectContaining({shouldCommit: false, vmHydratedGitMirror: false}) diff --git a/__test__/mirror-gc-git.test.ts b/__test__/mirror-gc-git.test.ts new file mode 100644 index 0000000..6d06e49 --- /dev/null +++ b/__test__/mirror-gc-git.test.ts @@ -0,0 +1,213 @@ +/** + * Real-git coverage for post-step maintenance telemetry: a geometric repack + * that finds nothing to roll up is reported as skipped (no maintenance + * row), while one that consolidates packs is a real maintenance result. + */ +jest.mock('@connectrpc/connect', () => ({ + createClient: jest.fn(), + ConnectError: class ConnectError extends Error {}, + Code: {Aborted: 'ABORTED'} +})) + +jest.mock('@connectrpc/connect-node', () => ({ + createGrpcTransport: jest.fn() +})) + +jest.mock( + '@buf/blacksmith_vm-agent.connectrpc_es/stickydisk/v1/stickydisk_connect', + () => ({ + StickyDiskService: {} + }) +) + +jest.mock('../src/container-detector', () => ({ + isRunningInContainer: jest.fn(() => false) +})) + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import {execFileSync} from 'child_process' +import * as blacksmithCache from '../src/blacksmith-cache' +import * as mirrorTelemetry from '../src/mirror-telemetry' + +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@example.com' +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + env: gitEnv + }).trim() +} + +function packCount(repo: string): number { + const packDir = path.join(repo, 'objects', 'pack') + return fs.readdirSync(packDir).filter(f => f.endsWith('.pack')).length +} + +// Writes one pack holding exactly the given objects; returns its basename. +function writePack(repo: string, objects: string): string { + const hash = execFileSync( + 'git', + [ + '-C', + repo, + 'pack-objects', + '-q', + path.join(repo, 'objects', 'pack', 'pack') + ], + {input: objects, encoding: 'utf8', env: gitEnv} + ).trim() + return `pack-${hash}` +} + +// A bare repo whose objects live in exactly two similarly sized packs (one +// per commit) and no loose objects: a geometric repack rolls them into one. +function twoPackMirror(root: string): string { + const work = path.join(root, 'work') + fs.mkdirSync(work) + git(work, 'init', '-q') + fs.writeFileSync(path.join(work, 'a.txt'), 'one\n') + git(work, 'add', 'a.txt') + git(work, 'commit', '-q', '-m', 'one') + fs.writeFileSync(path.join(work, 'b.txt'), 'two\n') + git(work, 'add', 'b.txt') + git(work, 'commit', '-q', '-m', 'two') + + const mirror = path.join(root, 'mirror.git') + git(root, 'clone', '-q', '--mirror', work, mirror) + const keep = new Set([ + writePack(mirror, git(mirror, 'rev-list', '--objects', 'HEAD~1')), + writePack(mirror, git(mirror, 'rev-list', '--objects', 'HEAD~1..HEAD')) + ]) + const packDir = path.join(mirror, 'objects', 'pack') + for (const f of fs.readdirSync(packDir)) { + if (!keep.has(f.replace(/\.(pack|idx|rev)$/, ''))) { + fs.rmSync(path.join(packDir, f)) + } + } + git(mirror, 'prune-packed', '-q') + expect(packCount(mirror)).toBe(2) + return mirror +} + +// A bare repo already consolidated into a single pack with no loose +// objects, so a geometric repack has nothing to do. +function onePackMirror(root: string): string { + const mirror = twoPackMirror(root) + git(mirror, '-c', 'repack.writeBitmaps=false', 'repack', '-a', '-d', '-q') + git(mirror, 'prune-packed', '-q') + expect(packCount(mirror)).toBe(1) + return mirror +} + +describe('mirror maintenance telemetry', () => { + let root: string + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-gc-')) + jest.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}) + jest.restoreAllMocks() + }) + + it('reports a repack that rolled nothing up as skipped', async () => { + const mirror = onePackMirror(root) + const packsBefore = await mirrorTelemetry.packNamesOrNull(mirror) + + const result = await blacksmithCache.cleanup({ + exposeId: 'expose-1', + stickyDiskKey: 'owner-repo', + mirrorPath: mirror, + shouldCommit: true, + vmHydratedGitMirror: false + }) + + expect(await mirrorTelemetry.packNamesOrNull(mirror)).toEqual(packsBefore) + expect(result.maintenanceResult.success).toBe(true) + expect(result.maintenanceResult.skipped).toBe(true) + }) + + it('reports a repack that consolidated packs as a real run', async () => { + const mirror = twoPackMirror(root) + + const result = await blacksmithCache.cleanup({ + exposeId: 'expose-1', + stickyDiskKey: 'owner-repo', + mirrorPath: mirror, + shouldCommit: true, + vmHydratedGitMirror: false + }) + + expect(packCount(mirror)).toBe(1) + expect(result.maintenanceResult.success).toBe(true) + expect(result.maintenanceResult.skipped).toBeUndefined() + expect(result.maintenanceResult.durationMs).toBeGreaterThanOrEqual(0) + expect(result.maintenanceResult.mirrorSizeBytes).toBeGreaterThan(0) + }) + + it('reports no maintenance when the disk is not committed', async () => { + const mirror = twoPackMirror(root) + + const result = await blacksmithCache.cleanup({ + exposeId: 'expose-1', + stickyDiskKey: 'owner-repo', + mirrorPath: mirror, + shouldCommit: false, + vmHydratedGitMirror: false + }) + + expect(packCount(mirror)).toBe(2) + expect(result.maintenanceResult.skipped).toBe(true) + }) +}) + +describe('pack measurement', () => { + let root: string + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-pack-')) + }) + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}) + }) + + it('sums only the files under objects/pack', async () => { + const mirror = twoPackMirror(root) + // Loose objects and refs must not count. + fs.mkdirSync(path.join(mirror, 'objects', 'ab')) + fs.writeFileSync( + path.join(mirror, 'objects', 'ab', 'cdef'), + 'x'.repeat(4096) + ) + const packDir = path.join(mirror, 'objects', 'pack') + const expected = fs + .readdirSync(packDir) + .reduce((sum, f) => sum + fs.statSync(path.join(packDir, f)).size, 0) + + expect(await mirrorTelemetry.packSizeBytesOrNull(mirror)).toBe(expected) + expect(await mirrorTelemetry.packNamesOrNull(mirror)).toHaveLength(2) + }) + + it('measures a repository without packs as empty, not unmeasurable', async () => { + const bare = path.join(root, 'empty.git') + git(root, 'init', '-q', '--bare', 'empty.git') + fs.rmSync(path.join(bare, 'objects', 'pack'), {recursive: true}) + + expect(await mirrorTelemetry.packSizeBytesOrNull(bare)).toBe(0) + expect(await mirrorTelemetry.packNamesOrNull(bare)).toEqual([]) + expect( + await mirrorTelemetry.packSizeBytesOrNull(path.join(root, 'missing')) + ).toBe(0) + }) +}) diff --git a/__test__/mirror-maintenance-git.test.ts b/__test__/mirror-maintenance-git.test.ts index 4475006..d51fd11 100644 --- a/__test__/mirror-maintenance-git.test.ts +++ b/__test__/mirror-maintenance-git.test.ts @@ -190,7 +190,9 @@ describe('runMirrorMaintenance (real git)', () => { timeoutSecs: 60, keepBytes: KEEP_BYTES }) - expect(result).toEqual({success: true, timedOut: false}) + expect(result).toMatchObject({success: true, timedOut: false}) + expect(result.skipped).toBeUndefined() + expect(result.mirrorSizeBytes).toBeGreaterThan(before.size) const packDir = path.join(mirror, 'objects', 'pack') expect( @@ -222,7 +224,11 @@ describe('runMirrorMaintenance (real git)', () => { timeoutSecs: 60, keepBytes: KEEP_BYTES }) - expect(result).toEqual({success: true, timedOut: false}) + expect(result).toMatchObject({ + success: true, + timedOut: false, + skipped: true + }) expect(packs(mirror)).toEqual(first) fsck(mirror) }) @@ -322,7 +328,7 @@ describe('runMirrorMaintenance (real git)', () => { timeoutSecs: 60, keepBytes: KEEP_BYTES }) - expect(result).toEqual({success: true, timedOut: false}) + expect(result).toMatchObject({success: true, timedOut: false}) const after = packSizes(mirror) expect(bytesWritten(before, after)).toBeLessThan(KEEP_BYTES) expect(after.has(basePack)).toBe(true) @@ -423,7 +429,7 @@ describe('runMirrorMaintenance (real git)', () => { keepBytes: KEEP_BYTES, now: 1000 }) - expect(result).toEqual({success: true, timedOut: false}) + expect(result).toMatchObject({success: true, timedOut: false}) expect(fs.readFileSync(stamp, 'utf8').trim()).toBe('1000') expect(packs(mirror)).toContain(pack) expect(hasObject(mirror, blob)).toBe(true) @@ -456,7 +462,7 @@ describe('runMirrorMaintenance (real git)', () => { reclaimIntervalMs: 10_000, now: 20_000 }) - expect(result).toEqual({success: true, timedOut: false}) + expect(result).toMatchObject({success: true, timedOut: false}) const packDir = path.join(mirror, 'objects', 'pack') const remaining = packs(mirror) @@ -491,7 +497,7 @@ describe('runMirrorMaintenance (real git)', () => { reclaimIntervalMs: 10_000, now: 25_000 }) - expect(next).toEqual({success: true, timedOut: false}) + expect(next).toMatchObject({success: true, timedOut: false}) expect(packs(mirror)).toEqual(remaining) expect(fs.readdirSync(packDir).filter(f => f.endsWith('.keep'))).toEqual([ remaining[0].replace(/\.pack$/, '.keep') @@ -594,7 +600,7 @@ PATH="${binDir}:$PATH" exec ${realTimeout} "$@" timeoutSecs: 60, keepBytes: KEEP_BYTES }) - expect(result).toEqual({ + expect(result).toMatchObject({ success: false, timedOut: false, error: expect.stringContaining('pack-refs failed with exit code 3') @@ -615,7 +621,7 @@ PATH="${binDir}:$PATH" exec ${realTimeout} "$@" timeoutSecs: 1, keepBytes: KEEP_BYTES }) - expect(result).toEqual({ + expect(result).toMatchObject({ success: false, timedOut: true, error: expect.stringContaining('pack-refs timed out') @@ -658,7 +664,7 @@ PATH="${binDir}:$PATH" exec ${realTimeout} "$@" reclaimIntervalMs: 10_000, now: 25_000 }) - expect(next).toEqual({success: true, timedOut: false}) + expect(next).toMatchObject({success: true, timedOut: false}) expect(fs.existsSync(keepFile)).toBe(true) expect(packs(mirror)).toContain(basePack) fsck(mirror) @@ -677,7 +683,7 @@ PATH="${binDir}:$PATH" exec ${realTimeout} "$@" reclaimIntervalMs: 10_000, now: 20_000 }) - expect(result).toEqual({ + expect(result).toMatchObject({ success: false, timedOut: false, error: expect.stringContaining('prune failed with exit code 7') diff --git a/__test__/mirror-sync-negotiation-git.test.ts b/__test__/mirror-sync-negotiation-git.test.ts index ac34cb7..e0a5110 100644 --- a/__test__/mirror-sync-negotiation-git.test.ts +++ b/__test__/mirror-sync-negotiation-git.test.ts @@ -173,17 +173,20 @@ describe('mirror sync negotiation with real git', () => { .readdirSync(path.join(mirrorPath, 'objects', 'pack')) .filter(f => f.endsWith('.pack')) + let changedCallbacks = 0 const result = await blacksmithCache.syncMirrorFromRemote( mirrorPath, 'https://example.invalid/owner/repo', 'token', false, - 60 + 60, + () => changedCallbacks++ ) expect(result.success).toBe(true) expect(result.timedOut).toBe(false) expect(result.changed).toBe(true) + expect(changedCallbacks).toBe(1) expect(git(mirrorPath, 'rev-parse', 'refs/heads/feature')).toBe( newFeatureTip ) diff --git a/dist/index.js b/dist/index.js index 18e420e..b22f509 100644 --- a/dist/index.js +++ b/dist/index.js @@ -47,6 +47,7 @@ exports.getGrpcPort = getGrpcPort; exports.isAllowedInsideContainer = isAllowedInsideContainer; exports.shouldUseBlacksmithCache = shouldUseBlacksmithCache; exports.getMirrorPath = getMirrorPath; +exports.stickyDiskKeyFor = stickyDiskKeyFor; exports.setupCache = setupCache; exports.ensureMirror = ensureMirror; exports.diffMirrorRefs = diffMirrorRefs; @@ -79,6 +80,7 @@ const connect_node_1 = __nccwpck_require__(1125); const stickydisk_connect_1 = __nccwpck_require__(2880); const retryHelper = __importStar(__nccwpck_require__(2155)); const container_detector_1 = __nccwpck_require__(6424); +const mirror_telemetry_1 = __nccwpck_require__(7185); // Without a deadline, a black-holed dial stalls the checkout until the OS // gives up on the TCP handshake. const AGENT_RPC_TIMEOUT_MS = 45000; @@ -283,6 +285,10 @@ function maybeFormatDevice(device) { core.debug(`Successfully formatted ${device} with ext4`); }); } +/** Sticky disk key of a repository's git mirror; one mirror per repository. */ +function stickyDiskKeyFor(owner, repo) { + return `${owner}-${repo}`; +} /** * Request a sticky disk from the VM agent, format if needed, and mount it. * Returns CacheInfo with hydrationInProgress=true if another job is hydrating, @@ -291,7 +297,7 @@ function maybeFormatDevice(device) { function setupCache(owner, repo) { return __awaiter(this, void 0, void 0, function* () { const client = createBlacksmithClient(); - const stickyDiskKey = `${owner}-${repo}`; + const stickyDiskKey = stickyDiskKeyFor(owner, repo); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), AGENT_RPC_TIMEOUT_MS); // Rethrow the original error so callers can classify it from the gRPC code. @@ -777,12 +783,23 @@ function mirrorHeadRef(mirrorPath) { }); } function syncMirrorFromRemote(mirrorPath_1, repoUrl_1, authToken_1) { - return __awaiter(this, arguments, void 0, function* (mirrorPath, repoUrl, authToken, verbose = false, timeoutSecs = REFRESH_TIMEOUT_SECS) { + return __awaiter(this, arguments, void 0, function* (mirrorPath, repoUrl, authToken, verbose = false, timeoutSecs = REFRESH_TIMEOUT_SECS, + // Invoked as soon as the mirror has changed, before the post-sync size + // walk, so callers can persist "mirror changed" state ahead of any + // measurement cost that a job cancel could interrupt. + onMirrorChanged) { if (!fs.existsSync(mirrorPath)) { core.debug(`[git-mirror] Mirror does not exist at ${mirrorPath}, skipping sync`); - return { success: true, timedOut: false, changed: false }; + return { success: true, timedOut: false, changed: false, skipped: true }; } core.info(`[git-mirror] Syncing mirror at ${mirrorPath} with remote (budget: ${timeoutSecs}s)`); + // Pack size delta across the sync is the bytes landed — the steady-state + // freshness cost between commits. The fetch runs with fetch.unpackLimit=1 + // so every fetch lands as a pack and loose objects are never walked on + // the sticky disk. Measured before the deadline is set so it never eats + // into the sync budget. + const sizeBefore = yield (0, mirror_telemetry_1.packSizeBytesOrNull)(mirrorPath); + const syncStart = Date.now(); // One deadline bounds the whole sync: ls-remote and every fetch // invocation (including vanished-ref reruns) draw from the same budget, // so the sync's worst case is timeoutSecs regardless of how many @@ -844,7 +861,17 @@ function syncMirrorFromRemote(mirrorPath_1, repoUrl_1, authToken_1) { const purgedRefs = yield purgePullRefs(mirrorPath); if (diff.updatedRefSpecs.length === 0 && diff.deletedRefs.length === 0) { core.info('[git-mirror] Mirror is already up to date with the remote'); - return { success: true, timedOut: false, changed: purgedRefs > 0 }; + if (purgedRefs > 0) { + onMirrorChanged === null || onMirrorChanged === void 0 ? void 0 : onMirrorChanged(); + } + return { + success: true, + timedOut: false, + changed: purgedRefs > 0, + durationMs: Date.now() - syncStart, + bytes: 0, + mirrorSizeBytes: sizeBefore !== null && sizeBefore !== void 0 ? sizeBefore : 0 + }; } // Refs deleted on the remote between ls-remote and the fetch make git // fail with "couldn't find remote ref". Prune those refs and re-run the @@ -958,7 +985,22 @@ function syncMirrorFromRemote(mirrorPath_1, repoUrl_1, authToken_1) { core.info(`[git-mirror] Deleted ${refsToDelete.length} refs removed on the remote`); } core.info('[git-mirror] Mirror sync complete'); - return { success: true, timedOut: false, changed: true }; + // Duration is the sync alone; the size read below is measurement cost. + const durationMs = Date.now() - syncStart; + onMirrorChanged === null || onMirrorChanged === void 0 ? void 0 : onMirrorChanged(); + const sizeAfter = yield (0, mirror_telemetry_1.packSizeBytesOrNull)(mirrorPath); + return { + success: true, + timedOut: false, + changed: true, + durationMs, + // Only report a delta when both reads succeeded — a failed read is 0 + // and would report the whole mirror as fetched. + bytes: sizeBefore !== null && sizeAfter !== null + ? Math.max(0, sizeAfter - sizeBefore) + : 0, + mirrorSizeBytes: sizeAfter !== null && sizeAfter !== void 0 ? sizeAfter : 0 + }; } catch (error) { const msg = error.message || String(error); @@ -969,7 +1011,15 @@ function syncMirrorFromRemote(mirrorPath_1, repoUrl_1, authToken_1) { else { core.warning(`[git-mirror] Mirror sync failed: ${msg}`); } - return { success: false, timedOut, error: msg, changed: false }; + return { + success: false, + timedOut, + error: msg, + changed: false, + durationMs: Date.now() - syncStart, + bytes: 0, + mirrorSizeBytes: sizeBefore !== null && sizeBefore !== void 0 ? sizeBefore : 0 + }; } }); } @@ -1425,6 +1475,9 @@ function hasCommitGraph(mirrorPath) { return (fs.existsSync(path.join(mirrorPath, 'objects', 'info', 'commit-graph')) || fs.existsSync(path.join(mirrorPath, 'objects', 'info', 'commit-graphs', 'commit-graph-chain'))); } +function samePackNames(a, b) { + return a.length === b.length && a.every((name, i) => name === b[i]); +} /** * Choose which packs the geometric repack may rewrite. Every pack at or * above `keepBytes` gets a permanent `.keep` file so that no repack - this @@ -1633,6 +1686,9 @@ function reclaimDue(mirrorPath, now, intervalMs) { * prevent the synced mirror from being committed - otherwise a mirror that * needs more maintenance than the deadline allows is never persisted, and * every subsequent job repeats the same doomed work. + * + * A run that leaves the pack set unchanged is reported as skipped so only + * real roll-ups produce a maintenance row. */ function runMirrorMaintenance(mirrorPath_1) { return __awaiter(this, arguments, void 0, function* (mirrorPath, options = {}) { @@ -1671,10 +1727,21 @@ function runMirrorMaintenance(mirrorPath_1) { repackArgs = ['-d', '-l', '-n', '--geometric=2', '--write-midx']; core.info(`[git-mirror] Running incremental maintenance (timeout: ${budgetSecs}s, ${selection.kept.length} kept pack(s), ${deferred.length} deferred)`); } + // Pack size delta across the repack approximates the bytes reclaimed by + // pack consolidation; loose objects are never walked on the sticky disk. + const sizeBefore = yield (0, mirror_telemetry_1.packSizeBytesOrNull)(mirrorPath); + const packsBefore = yield (0, mirror_telemetry_1.packNamesOrNull)(mirrorPath); const fail = (timedOut, error) => __awaiter(this, void 0, void 0, function* () { core.warning(`[git-mirror] ${timedOut ? `Maintenance timed out after ${budgetSecs}s` : error}; committing the synced mirror without it`); yield removeMaintenanceLeftovers(mirrorPath); - return { success: false, timedOut, error }; + return { + success: false, + timedOut, + error, + durationMs: Date.now() - start, + bytes: 0, + mirrorSizeBytes: sizeBefore !== null && sizeBefore !== void 0 ? sizeBefore : 0 + }; }); try { const result = yield exec.getExecOutput('timeout', [ @@ -1717,8 +1784,35 @@ function runMirrorMaintenance(mirrorPath_1) { if (packRefs.exitCode !== 0) { return yield fail(false, `git pack-refs failed with exit code ${packRefs.exitCode}`); } - core.info(`[git-mirror] ${label} maintenance finished in ${Date.now() - start}ms`); - return { success: true, timedOut: false }; + // Duration is the maintenance alone; the reads below are measurement cost. + const durationMs = Date.now() - start; + core.info(`[git-mirror] ${label} maintenance finished in ${durationMs}ms`); + const packsAfter = yield (0, mirror_telemetry_1.packNamesOrNull)(mirrorPath); + if (packsBefore !== null && + packsAfter !== null && + samePackNames(packsBefore, packsAfter)) { + core.debug('[git-mirror] repack found nothing to roll up'); + return { + success: true, + timedOut: false, + skipped: true, + durationMs, + bytes: 0, + mirrorSizeBytes: sizeBefore !== null && sizeBefore !== void 0 ? sizeBefore : 0 + }; + } + const sizeAfter = yield (0, mirror_telemetry_1.packSizeBytesOrNull)(mirrorPath); + return { + success: true, + timedOut: false, + durationMs, + // Only report a delta when both reads succeeded — a failed read is 0 + // and would report the whole mirror as reclaimed. + bytes: sizeBefore !== null && sizeAfter !== null + ? Math.max(0, sizeBefore - sizeAfter) + : 0, + mirrorSizeBytes: sizeAfter !== null && sizeAfter !== void 0 ? sizeAfter : 0 + }; } catch (error) { const msg = error.message || String(error); @@ -1838,7 +1932,10 @@ function cleanup(options) { // being persisted. let vmHydratedGitMirror = options.vmHydratedGitMirror; const result = { - maintenanceResult: { success: true, timedOut: false } + // skipped until maintenance actually runs, so a mirror that is not + // maintained (no mirrorPath, or not committing) never reports a + // successful zero-measurement maintenance row. + maintenanceResult: { success: true, timedOut: false, skipped: true } }; core.info(`[git-mirror] Starting cleanup: exposeId=${exposeId}, stickyDiskKey=${stickyDiskKey}, shouldCommit=${shouldCommit}, vmHydratedGitMirror=${vmHydratedGitMirror}`); // If the mirror sync failed or timed out, don't commit @@ -3490,12 +3587,50 @@ const refHelper = __importStar(__nccwpck_require__(8601)); const stateHelper = __importStar(__nccwpck_require__(4866)); const urlHelper = __importStar(__nccwpck_require__(9437)); const blacksmithCache = __importStar(__nccwpck_require__(9242)); +const mirrorTelemetry = __importStar(__nccwpck_require__(7185)); const git_command_manager_1 = __nccwpck_require__(738); function getSource(settings) { + return __awaiter(this, void 0, void 0, function* () { + // Structured checkout telemetry, reported to the Blacksmith agent at the + // end of the main step. Fail-soft: measurement or reporting problems only + // degrade the report, never the checkout. + const report = mirrorTelemetry.newCheckoutReport(); + const totalStart = Date.now(); + let hydrationReport = null; + try { + yield getSourceInner(settings, report, r => { + hydrationReport = r; + }); + report.outcome = 'success'; + } + catch (error) { + report.outcome = 'failure'; + // Overwrite any recovered fallback error class: on failure the class must + // describe the error that actually failed the checkout. + report.error_class = mirrorTelemetry.classifyError(error); + throw error; + } + finally { + report.total_ms = Date.now() - totalStart; + if (blacksmithCache.isBlacksmithEnvironment()) { + if (hydrationReport) { + yield mirrorTelemetry.reportHydration(hydrationReport); + } + yield mirrorTelemetry.reportCheckout(report); + } + } + }); +} +function getSourceInner(settings, report, onHydrationReport) { return __awaiter(this, void 0, void 0, function* () { // Repository URL core.info(`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`); const repositoryUrl = urlHelper.getFetchUrl(settings); + report.sticky_disk_key = blacksmithCache.stickyDiskKeyFor(settings.repositoryOwner, settings.repositoryName); + report.shallow = settings.fetchDepth > 0; + report.filter = !!settings.filter || settings.sparseCheckout != null; + report.submodules_enabled = settings.submodules; + report.lfs_enabled = settings.lfs; // Remove conflicting file path if (fsHelper.fileExistsSync(settings.repositoryPath)) { yield io.rmRF(settings.repositoryPath); @@ -3557,12 +3692,15 @@ function getSource(settings) { // the workspace copy its refs from the mirror instead of the network. let mirrorFresh = false; if (blacksmithCache.shouldUseBlacksmithCache()) { + const setupStart = Date.now(); try { core.startGroup('Setting up Blacksmith git mirror cache'); cacheInfo = yield blacksmithCache.setupCache(settings.repositoryOwner, settings.repositoryName); + report.sticky_disk_setup_ms = Date.now() - setupStart; // Check if hydration is in progress - another job is doing the initial git clone --mirror if (cacheInfo.hydrationInProgress) { // Warning already logged by setupCache, just fall back to standard checkout + report.serving_mode = 'fallback-contention'; cacheInfo = null; core.endGroup(); } @@ -3574,23 +3712,61 @@ function getSource(settings) { stateHelper.setBlacksmithCacheMirrorPath(cacheInfo.mirrorPath); stateHelper.setBlacksmithCacheMountPoint(cacheInfo.mountPoint); stateHelper.setBlacksmithCacheCommitEarlyDenyReason(cacheInfo.commitEarlyDenyReason); - const performedHydration = yield blacksmithCache.ensureMirror(cacheInfo.mirrorPath, repositoryUrl, settings.authToken, settings.verbose); + const mirrorExisted = fsHelper.directoryExistsSync(cacheInfo.mirrorPath); + const cloneStart = Date.now(); + let performedHydration = false; + try { + performedHydration = yield blacksmithCache.ensureMirror(cacheInfo.mirrorPath, repositoryUrl, settings.authToken, settings.verbose); + } + catch (error) { + if (!mirrorExisted) { + onHydrationReport({ + sticky_disk_key: cacheInfo.stickyDiskKey, + clone_ms: Date.now() - cloneStart, + clone_bytes: 0, + ref_count: 0, + outcome: 'failure', + error_class: mirrorTelemetry.classifyError(error) + }); + } + throw error; + } + // Persist the mirror state before any measurement walk or + // telemetry POST: a cancel in that window must still leave the + // post step committing the hydration. stateHelper.setBlacksmithCachePerformedHydration(performedHydration); + if (performedHydration) { + stateHelper.setBlacksmithCacheMirrorChanged(true); + report.serving_mode = 'hydrating'; + onHydrationReport({ + sticky_disk_key: cacheInfo.stickyDiskKey, + clone_ms: Date.now() - cloneStart, + clone_bytes: yield mirrorTelemetry.packSizeBytes(cacheInfo.mirrorPath), + ref_count: yield mirrorTelemetry.refCount(cacheInfo.mirrorPath), + outcome: 'success' + }); + } + else { + report.serving_mode = 'mirror'; + } if (performedHydration) { // A freshly-cloned mirror is exactly the remote's current state mirrorFresh = true; - stateHelper.setBlacksmithCacheMirrorChanged(true); } else if (settings.fetchDepth <= 0) { // Bring the mirror's branch/tag refs up to date with the remote // (ls-remote diff + targeted fetch of only the changed refs), so // the workspace can be populated from the mirror with the same // freshness as a direct network fetch. - const syncResult = yield blacksmithCache.syncMirrorFromRemote(cacheInfo.mirrorPath, repositoryUrl, settings.authToken, settings.verbose); + const syncResult = yield blacksmithCache.syncMirrorFromRemote(cacheInfo.mirrorPath, repositoryUrl, settings.authToken, settings.verbose, undefined, () => stateHelper.setBlacksmithCacheMirrorChanged(true)); mirrorFresh = syncResult.success; stateHelper.setBlacksmithCacheMirrorChanged(syncResult.changed); stateHelper.setBlacksmithCacheMirrorSyncFailed(!syncResult.success && !syncResult.timedOut); stateHelper.setBlacksmithCacheMirrorSyncTimedOut(syncResult.timedOut); + if (!syncResult.skipped) { + // Structured refresh row (fire-and-forget; errors swallowed). + yield mirrorTelemetry.reportMaintenance(mirrorTelemetry.maintenanceRunFromResult('refresh', cacheInfo.stickyDiskKey, syncResult)); + } } else { // Shallow checkouts never populate the workspace from mirror @@ -3607,6 +3783,13 @@ function getSource(settings) { catch (error) { core.endGroup(); core.warning(`Blacksmith cache setup failed, using standard checkout: ${error}`); + // Stamp the setup duration only when setupCache itself failed; later + // failures (e.g. the hydration clone) are not sticky-disk setup time. + if (report.sticky_disk_setup_ms === 0) { + report.sticky_disk_setup_ms = Date.now() - setupStart; + } + report.serving_mode = 'fallback-error'; + report.error_class = mirrorTelemetry.classifyError(error); // Don't clear cacheInfo.exposeId/stickyDiskKey from state - they're already saved // so cleanup can still call commitStickyDisk with shouldCommit: false cacheInfo = null; @@ -3615,12 +3798,16 @@ function getSource(settings) { // Initialize the repository if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { core.startGroup('Initializing the repository'); + const initStart = Date.now(); yield git.init(); // Setup alternates to use objects from Blacksmith mirror if available if (cacheInfo) { yield blacksmithCache.writeAlternates(settings.repositoryPath, cacheInfo.mirrorPath); } yield git.remoteAdd('origin', repositoryUrl); + if (cacheInfo) { + report.clone_from_mirror_ms += Date.now() - initStart; + } core.endGroup(); } // Disable automatic garbage collection @@ -3654,6 +3841,11 @@ function getSource(settings) { } // Fetch core.startGroup('Fetching the repository'); + const objectsDir = path.join(settings.repositoryPath, '.git', 'objects'); + const objectsBytesBefore = cacheInfo + ? yield mirrorTelemetry.dirSizeBytesOrNull(objectsDir) + : null; + const fetchStart = Date.now(); const fetchOptions = {}; if (settings.filter) { fetchOptions.filter = settings.filter; @@ -3728,6 +3920,21 @@ function getSource(settings) { const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); yield git.fetch(refSpec, fetchOptions); } + if (cacheInfo) { + // Mirror-served: the fetch pulls only the delta the mirror is missing. + report.delta_fetch_ms = Date.now() - fetchStart; + const objectsBytesAfter = yield mirrorTelemetry.dirSizeBytesOrNull(objectsDir); + report.delta_fetch_bytes = + objectsBytesBefore !== null && objectsBytesAfter !== null + ? Math.max(0, objectsBytesAfter - objectsBytesBefore) + : 0; + report.mirror_size_bytes = yield mirrorTelemetry.packSizeBytes(cacheInfo.mirrorPath); + report.ref_count = yield mirrorTelemetry.refCount(cacheInfo.mirrorPath); + } + else { + // Fallback/bypass: the fetch is the full checkout cost. + report.full_checkout_ms = Date.now() - fetchStart; + } core.endGroup(); // Checkout info core.startGroup('Determining the checkout info'); @@ -3739,7 +3946,9 @@ function getSource(settings) { // For sparse checkouts, let `checkout` fetch the needed objects lazily. if (settings.lfs && !settings.sparseCheckout) { core.startGroup('Fetching LFS objects'); + const lfsStart = Date.now(); yield git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref); + report.lfs_ms = Date.now() - lfsStart; core.endGroup(); } // Sparse checkout @@ -3762,7 +3971,18 @@ function getSource(settings) { } // Checkout core.startGroup('Checking out the ref'); + const checkoutStart = Date.now(); yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); + if (cacheInfo) { + // Materializing the working tree reads objects from the mirror through + // alternates — the second half of the clone-from-mirror cost. + report.clone_from_mirror_ms += Date.now() - checkoutStart; + } + else { + // Fallback/bypass: working-tree materialization is part of the full + // checkout cost, keeping phase totals comparable across serving modes. + report.full_checkout_ms += Date.now() - checkoutStart; + } core.endGroup(); // Dissociate from Blacksmith mirror if requested // This copies all objects from alternates into the local repo so it's independent @@ -3773,6 +3993,7 @@ function getSource(settings) { } // Submodules if (settings.submodules) { + const submodulesStart = Date.now(); // Temporarily override global config core.startGroup('Setting up auth for fetching submodules'); yield authHelper.configureGlobalAuth(); @@ -3789,6 +4010,7 @@ function getSource(settings) { yield authHelper.configureSubmoduleAuth(); core.endGroup(); } + report.submodules_ms = Date.now() - submodulesStart; } // Get commit information const commitInfo = yield git.log1(); @@ -4325,6 +4547,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.reportInternalMetric = reportInternalMetric; +exports.reportStructuredMetric = reportStructuredMetric; const core = __importStar(__nccwpck_require__(2186)); const http = __importStar(__nccwpck_require__(3685)); const METRICS_PORT = process.env.BLACKSMITH_METRICS_HTTP_PORT || ''; @@ -4335,6 +4558,33 @@ const AGENT_IP = process.env.BLACKSMITH_AGENT_ADDR || ''; * Fire-and-forget: errors are logged but never thrown. */ function reportInternalMetric(metricType, value, attributes) { + return __awaiter(this, void 0, void 0, function* () { + yield postInternalMetric(metricType, { + metric_type: metricType, + value, + vm_id: VM_ID, + attributes + }); + }); +} +/** + * Report a structured telemetry payload to the Blacksmith agent. + * Same fail-soft, fire-and-forget contract as reportInternalMetric: the + * payload rides the /internal envelope in a `payload` field alongside the + * metric_type, and every error is swallowed. + */ +function reportStructuredMetric(metricType, structuredPayload) { + return __awaiter(this, void 0, void 0, function* () { + yield postInternalMetric(metricType, { + metric_type: metricType, + value: 0, + vm_id: VM_ID, + attributes: {}, + payload: structuredPayload + }); + }); +} +function postInternalMetric(metricType, body) { return __awaiter(this, void 0, void 0, function* () { if (!METRICS_PORT) { core.debug('[metrics] BLACKSMITH_METRICS_HTTP_PORT not set, skipping metric'); @@ -4344,12 +4594,7 @@ function reportInternalMetric(metricType, value, attributes) { core.debug('[metrics] BLACKSMITH_AGENT_ADDR not set, skipping metric'); return; } - const payload = JSON.stringify({ - metric_type: metricType, - value, - vm_id: VM_ID, - attributes - }); + const payload = JSON.stringify(body); try { yield new Promise((resolve, reject) => { const req = http.request({ @@ -4433,6 +4678,7 @@ const stateHelper = __importStar(__nccwpck_require__(4866)); const blacksmithCache = __importStar(__nccwpck_require__(9242)); const step_checker_1 = __nccwpck_require__(9716); const internal_metrics_1 = __nccwpck_require__(7753); +const mirrorTelemetry = __importStar(__nccwpck_require__(7185)); function run() { return __awaiter(this, void 0, void 0, function* () { var _a; @@ -4475,6 +4721,8 @@ function cleanup() { let mirrorSyncFailed = stateHelper.BlacksmithCacheMirrorSyncFailed; let mirrorSyncTimedOut = stateHelper.BlacksmithCacheMirrorSyncTimedOut; if (exposeId && stickyDiskKey) { + // Sync result kept for the structured refresh maintenance row below. + let deferredSyncResult = null; // For shallow checkouts the checkout step never populates the workspace // from mirror refs, so the mirror sync is deferred here to keep the // checkout step fast. @@ -4485,6 +4733,7 @@ function cleanup() { if (repoUrl && authToken) { core.startGroup('Syncing Blacksmith git mirror'); const syncResult = yield blacksmithCache.syncMirrorFromRemote(mirrorPath, repoUrl, authToken, stateHelper.BlacksmithCacheVerbose); + deferredSyncResult = syncResult; mirrorChanged = syncResult.changed; mirrorSyncFailed = !syncResult.success && !syncResult.timedOut; mirrorSyncTimedOut = syncResult.timedOut; @@ -4575,6 +4824,18 @@ function cleanup() { }); } } + // Structured maintenance rows (fire-and-forget; errors swallowed inside). + if (deferredSyncResult && !deferredSyncResult.skipped) { + yield mirrorTelemetry.reportMaintenance(mirrorTelemetry.maintenanceRunFromResult('refresh', stickyDiskKey, deferredSyncResult)); + } + // The bounded repack is reported under the `gc` operation: it is the + // pack-consolidation step of the mirror lifecycle, whatever git verb + // performs it. + if (cleanupResult && + mirrorPath && + !cleanupResult.maintenanceResult.skipped) { + yield mirrorTelemetry.reportMaintenance(mirrorTelemetry.maintenanceRunFromResult('gc', stickyDiskKey, cleanupResult.maintenanceResult)); + } } }); } @@ -4588,6 +4849,243 @@ else { } +/***/ }), + +/***/ 7185: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.maintenanceRunFromResult = maintenanceRunFromResult; +exports.newCheckoutReport = newCheckoutReport; +exports.dirSizeBytesOrNull = dirSizeBytesOrNull; +exports.packSizeBytesOrNull = packSizeBytesOrNull; +exports.packSizeBytes = packSizeBytes; +exports.packNamesOrNull = packNamesOrNull; +exports.refCount = refCount; +exports.classifyError = classifyError; +exports.reportCheckout = reportCheckout; +exports.reportHydration = reportHydration; +exports.reportMaintenance = reportMaintenance; +const core = __importStar(__nccwpck_require__(2186)); +const exec = __importStar(__nccwpck_require__(1514)); +const fs = __importStar(__nccwpck_require__(7147)); +const path = __importStar(__nccwpck_require__(1017)); +const internal_metrics_1 = __nccwpck_require__(7753); +/** + * Build a maintenance row from an operation result. Measurement fields are + * best-effort and default to 0 when the operation didn't record them. + */ +function maintenanceRunFromResult(op, stickyDiskKey, result) { + var _a, _b, _c; + const run = { + op, + sticky_disk_key: stickyDiskKey, + duration_ms: (_a = result.durationMs) !== null && _a !== void 0 ? _a : 0, + bytes: (_b = result.bytes) !== null && _b !== void 0 ? _b : 0, + outcome: result.success + ? 'success' + : result.timedOut + ? 'timeout' + : 'failure', + mirror_size_bytes: (_c = result.mirrorSizeBytes) !== null && _c !== void 0 ? _c : 0 + }; + if (!result.success && result.error) { + run.error_class = result.timedOut ? 'timeout' : 'error'; + } + return run; +} +function newCheckoutReport() { + return { + serving_mode: 'bypass', + outcome: 'failure', + sticky_disk_key: '', + sticky_disk_setup_ms: 0, + clone_from_mirror_ms: 0, + delta_fetch_ms: 0, + delta_fetch_bytes: 0, + full_checkout_ms: 0, + submodules_ms: 0, + lfs_ms: 0, + total_ms: 0, + mirror_size_bytes: 0, + ref_count: 0, + shallow: false, + filter: false, + submodules_enabled: false, + lfs_enabled: false + }; +} +// Hard cap on the `du` walk so a wedged or slow disk can never stall the +// job on a measurement — the walk is telemetry, not work. +const DIR_SIZE_TIMEOUT_SECS = 15; +/** + * Directory size in bytes via `du -sb` (time-capped). Meant for the + * runner-local workspace object store, which holds only the delta objects; + * mirror measurements on the sticky disk use the pack helpers instead. + * Returns null on any failure so callers can tell "unmeasurable" apart from + * a real size — a missing byte count degrades the report, never the job. + */ +function dirSizeBytesOrNull(dir) { + return __awaiter(this, void 0, void 0, function* () { + try { + const result = yield exec.getExecOutput('timeout', [String(DIR_SIZE_TIMEOUT_SECS), 'du', '-sb', dir], { + ignoreReturnCode: true, + silent: true + }); + if (result.exitCode !== 0) { + return null; + } + const size = parseInt(result.stdout.trim().split(/\s+/)[0], 10); + return isNaN(size) || size < 0 ? null : size; + } + catch (_a) { + return null; + } + }); +} +/** + * Bytes held in `/objects/pack`. Packs are a flat handful of large + * files, so this is one readdir plus a stat per pack — never a walk over + * loose objects or refs, which on a freshly mounted block device is a + * random read per inode. Returns null when unmeasurable; a repository + * without a pack directory measures 0. + */ +function packSizeBytesOrNull(gitDir) { + return __awaiter(this, void 0, void 0, function* () { + const packDir = path.join(gitDir, 'objects', 'pack'); + try { + const entries = yield fs.promises.readdir(packDir, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const stat = yield fs.promises.stat(path.join(packDir, entry.name)); + total += stat.size; + } + return total; + } + catch (error) { + return error.code === 'ENOENT' ? 0 : null; + } + }); +} +/** Pack bytes of a git repository, or 0 when they cannot be measured. */ +function packSizeBytes(gitDir) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + return (_a = (yield packSizeBytesOrNull(gitDir))) !== null && _a !== void 0 ? _a : 0; + }); +} +/** + * Names of the pack files under `/objects/pack`, sorted, or null + * when unmeasurable. Any gc that did work rewrites this set (pack names + * are content hashes), so comparing two snapshots detects a no-op + * `gc --auto` without touching loose objects. + */ +function packNamesOrNull(gitDir) { + return __awaiter(this, void 0, void 0, function* () { + try { + const entries = yield fs.promises.readdir(path.join(gitDir, 'objects', 'pack')); + return entries.filter(name => name.endsWith('.pack')).sort(); + } + catch (error) { + return error.code === 'ENOENT' ? [] : null; + } + }); +} +/** Ref count of a git repository. Returns 0 on any failure. */ +function refCount(gitDir) { + return __awaiter(this, void 0, void 0, function* () { + try { + const result = yield exec.getExecOutput('git', ['-C', gitDir, 'show-ref'], { + ignoreReturnCode: true, + silent: true + }); + if (result.exitCode !== 0) { + return 0; + } + const out = result.stdout.trim(); + return out === '' ? 0 : out.split('\n').length; + } + catch (_a) { + return 0; + } + }); +} +/** Short closed-ish error class from an unknown error; never customer data. */ +function classifyError(error) { + if (error instanceof Error && error.name && error.name !== 'Error') { + return error.name; + } + return 'error'; +} +function reportCheckout(report) { + return __awaiter(this, void 0, void 0, function* () { + try { + yield (0, internal_metrics_1.reportStructuredMetric)('git_mirror_checkout_report', report); + } + catch (error) { + core.debug(`[git-mirror] checkout report failed: ${error.message}`); + } + }); +} +function reportHydration(report) { + return __awaiter(this, void 0, void 0, function* () { + try { + yield (0, internal_metrics_1.reportStructuredMetric)('git_mirror_hydration_report', report); + } + catch (error) { + core.debug(`[git-mirror] hydration report failed: ${error.message}`); + } + }); +} +function reportMaintenance(run) { + return __awaiter(this, void 0, void 0, function* () { + try { + yield (0, internal_metrics_1.reportStructuredMetric)('git_mirror_maintenance_run', run); + } + catch (error) { + core.debug(`[git-mirror] maintenance report failed: ${error.message}`); + } + }); +} + + /***/ }), /***/ 8601: diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index baf402f..fcaa55e 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -8,6 +8,7 @@ import {createGrpcTransport} from '@connectrpc/connect-node' import {StickyDiskService} from '@buf/blacksmith_vm-agent.connectrpc_es/stickydisk/v1/stickydisk_connect' import * as retryHelper from './retry-helper' import {isRunningInContainer} from './container-detector' +import {packNamesOrNull, packSizeBytesOrNull} from './mirror-telemetry' // Without a deadline, a black-holed dial stalls the checkout until the OS // gives up on the TCP handshake. @@ -71,6 +72,13 @@ export interface OperationResult { success: boolean timedOut: boolean error?: string + // The operation did no work (no mirror to refresh, or `gc --auto` found + // nothing to collect); no telemetry row applies. + skipped?: boolean + // Structured-telemetry detail (best-effort; 0 when measurement failed). + durationMs?: number + bytes?: number + mirrorSizeBytes?: number } /** @@ -282,6 +290,11 @@ async function maybeFormatDevice(device: string): Promise { core.debug(`Successfully formatted ${device} with ext4`) } +/** Sticky disk key of a repository's git mirror; one mirror per repository. */ +export function stickyDiskKeyFor(owner: string, repo: string): string { + return `${owner}-${repo}` +} + /** * Request a sticky disk from the VM agent, format if needed, and mount it. * Returns CacheInfo with hydrationInProgress=true if another job is hydrating, @@ -292,7 +305,7 @@ export async function setupCache( repo: string ): Promise { const client = createBlacksmithClient() - const stickyDiskKey = `${owner}-${repo}` + const stickyDiskKey = stickyDiskKeyFor(owner, repo) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), AGENT_RPC_TIMEOUT_MS) @@ -891,19 +904,31 @@ export async function syncMirrorFromRemote( repoUrl: string, authToken: string, verbose: boolean = false, - timeoutSecs: number = REFRESH_TIMEOUT_SECS + timeoutSecs: number = REFRESH_TIMEOUT_SECS, + // Invoked as soon as the mirror has changed, before the post-sync size + // walk, so callers can persist "mirror changed" state ahead of any + // measurement cost that a job cancel could interrupt. + onMirrorChanged?: () => void ): Promise { if (!fs.existsSync(mirrorPath)) { core.debug( `[git-mirror] Mirror does not exist at ${mirrorPath}, skipping sync` ) - return {success: true, timedOut: false, changed: false} + return {success: true, timedOut: false, changed: false, skipped: true} } core.info( `[git-mirror] Syncing mirror at ${mirrorPath} with remote (budget: ${timeoutSecs}s)` ) + // Pack size delta across the sync is the bytes landed — the steady-state + // freshness cost between commits. The fetch runs with fetch.unpackLimit=1 + // so every fetch lands as a pack and loose objects are never walked on + // the sticky disk. Measured before the deadline is set so it never eats + // into the sync budget. + const sizeBefore = await packSizeBytesOrNull(mirrorPath) + const syncStart = Date.now() + // One deadline bounds the whole sync: ls-remote and every fetch // invocation (including vanished-ref reruns) draw from the same budget, // so the sync's worst case is timeoutSecs regardless of how many @@ -983,7 +1008,17 @@ export async function syncMirrorFromRemote( if (diff.updatedRefSpecs.length === 0 && diff.deletedRefs.length === 0) { core.info('[git-mirror] Mirror is already up to date with the remote') - return {success: true, timedOut: false, changed: purgedRefs > 0} + if (purgedRefs > 0) { + onMirrorChanged?.() + } + return { + success: true, + timedOut: false, + changed: purgedRefs > 0, + durationMs: Date.now() - syncStart, + bytes: 0, + mirrorSizeBytes: sizeBefore ?? 0 + } } // Refs deleted on the remote between ls-remote and the fetch make git @@ -1134,7 +1169,23 @@ export async function syncMirrorFromRemote( } core.info('[git-mirror] Mirror sync complete') - return {success: true, timedOut: false, changed: true} + // Duration is the sync alone; the size read below is measurement cost. + const durationMs = Date.now() - syncStart + onMirrorChanged?.() + const sizeAfter = await packSizeBytesOrNull(mirrorPath) + return { + success: true, + timedOut: false, + changed: true, + durationMs, + // Only report a delta when both reads succeeded — a failed read is 0 + // and would report the whole mirror as fetched. + bytes: + sizeBefore !== null && sizeAfter !== null + ? Math.max(0, sizeAfter - sizeBefore) + : 0, + mirrorSizeBytes: sizeAfter ?? 0 + } } catch (error) { const msg = (error as Error).message || String(error) const timedOut = msg.includes('timed out') @@ -1143,7 +1194,15 @@ export async function syncMirrorFromRemote( } else { core.warning(`[git-mirror] Mirror sync failed: ${msg}`) } - return {success: false, timedOut, error: msg, changed: false} + return { + success: false, + timedOut, + error: msg, + changed: false, + durationMs: Date.now() - syncStart, + bytes: 0, + mirrorSizeBytes: sizeBefore ?? 0 + } } } @@ -1711,6 +1770,10 @@ export interface KeepSelection { deferred: string[] } +function samePackNames(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((name, i) => name === b[i]) +} + /** * Choose which packs the geometric repack may rewrite. Every pack at or * above `keepBytes` gets a permanent `.keep` file so that no repack - this @@ -1934,6 +1997,9 @@ export interface MaintenanceOptions { * prevent the synced mirror from being committed - otherwise a mirror that * needs more maintenance than the deadline allows is never persisted, and * every subsequent job repeats the same doomed work. + * + * A run that leaves the pack set unchanged is reported as skipped so only + * real roll-ups produce a maintenance row. */ export async function runMirrorMaintenance( mirrorPath: string, @@ -1983,6 +2049,11 @@ export async function runMirrorMaintenance( ) } + // Pack size delta across the repack approximates the bytes reclaimed by + // pack consolidation; loose objects are never walked on the sticky disk. + const sizeBefore = await packSizeBytesOrNull(mirrorPath) + const packsBefore = await packNamesOrNull(mirrorPath) + const fail = async ( timedOut: boolean, error: string @@ -1993,7 +2064,14 @@ export async function runMirrorMaintenance( }; committing the synced mirror without it` ) await removeMaintenanceLeftovers(mirrorPath) - return {success: false, timedOut, error} + return { + success: false, + timedOut, + error, + durationMs: Date.now() - start, + bytes: 0, + mirrorSizeBytes: sizeBefore ?? 0 + } } try { @@ -2061,10 +2139,38 @@ export async function runMirrorMaintenance( ) } - core.info( - `[git-mirror] ${label} maintenance finished in ${Date.now() - start}ms` - ) - return {success: true, timedOut: false} + // Duration is the maintenance alone; the reads below are measurement cost. + const durationMs = Date.now() - start + core.info(`[git-mirror] ${label} maintenance finished in ${durationMs}ms`) + const packsAfter = await packNamesOrNull(mirrorPath) + if ( + packsBefore !== null && + packsAfter !== null && + samePackNames(packsBefore, packsAfter) + ) { + core.debug('[git-mirror] repack found nothing to roll up') + return { + success: true, + timedOut: false, + skipped: true, + durationMs, + bytes: 0, + mirrorSizeBytes: sizeBefore ?? 0 + } + } + const sizeAfter = await packSizeBytesOrNull(mirrorPath) + return { + success: true, + timedOut: false, + durationMs, + // Only report a delta when both reads succeeded — a failed read is 0 + // and would report the whole mirror as reclaimed. + bytes: + sizeBefore !== null && sizeAfter !== null + ? Math.max(0, sizeBefore - sizeAfter) + : 0, + mirrorSizeBytes: sizeAfter ?? 0 + } } catch (error) { const msg = (error as Error).message || String(error) return await fail(false, msg) @@ -2231,7 +2337,10 @@ export async function cleanup(options: CleanupOptions): Promise { let vmHydratedGitMirror = options.vmHydratedGitMirror const result: CleanupResult = { - maintenanceResult: {success: true, timedOut: false} + // skipped until maintenance actually runs, so a mirror that is not + // maintained (no mirrorPath, or not committing) never reports a + // successful zero-measurement maintenance row. + maintenanceResult: {success: true, timedOut: false, skipped: true} } core.info( diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index a4b69b0..42a4dde 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -10,6 +10,7 @@ import * as refHelper from './ref-helper' import * as stateHelper from './state-helper' import * as urlHelper from './url-helper' import * as blacksmithCache from './blacksmith-cache' +import * as mirrorTelemetry from './mirror-telemetry' import { FetchOptions, MinimumGitAlternateRefsCommandVersion, @@ -19,11 +20,52 @@ import { import {IGitSourceSettings} from './git-source-settings' export async function getSource(settings: IGitSourceSettings): Promise { + // Structured checkout telemetry, reported to the Blacksmith agent at the + // end of the main step. Fail-soft: measurement or reporting problems only + // degrade the report, never the checkout. + const report = mirrorTelemetry.newCheckoutReport() + const totalStart = Date.now() + let hydrationReport: mirrorTelemetry.HydrationReport | null = null + try { + await getSourceInner(settings, report, r => { + hydrationReport = r + }) + report.outcome = 'success' + } catch (error) { + report.outcome = 'failure' + // Overwrite any recovered fallback error class: on failure the class must + // describe the error that actually failed the checkout. + report.error_class = mirrorTelemetry.classifyError(error) + throw error + } finally { + report.total_ms = Date.now() - totalStart + if (blacksmithCache.isBlacksmithEnvironment()) { + if (hydrationReport) { + await mirrorTelemetry.reportHydration(hydrationReport) + } + await mirrorTelemetry.reportCheckout(report) + } + } +} + +async function getSourceInner( + settings: IGitSourceSettings, + report: mirrorTelemetry.CheckoutReport, + onHydrationReport: (r: mirrorTelemetry.HydrationReport) => void +): Promise { // Repository URL core.info( `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` ) const repositoryUrl = urlHelper.getFetchUrl(settings) + report.sticky_disk_key = blacksmithCache.stickyDiskKeyFor( + settings.repositoryOwner, + settings.repositoryName + ) + report.shallow = settings.fetchDepth > 0 + report.filter = !!settings.filter || settings.sparseCheckout != null + report.submodules_enabled = settings.submodules + report.lfs_enabled = settings.lfs // Remove conflicting file path if (fsHelper.fileExistsSync(settings.repositoryPath)) { @@ -119,16 +161,19 @@ export async function getSource(settings: IGitSourceSettings): Promise { // the workspace copy its refs from the mirror instead of the network. let mirrorFresh = false if (blacksmithCache.shouldUseBlacksmithCache()) { + const setupStart = Date.now() try { core.startGroup('Setting up Blacksmith git mirror cache') cacheInfo = await blacksmithCache.setupCache( settings.repositoryOwner, settings.repositoryName ) + report.sticky_disk_setup_ms = Date.now() - setupStart // Check if hydration is in progress - another job is doing the initial git clone --mirror if (cacheInfo.hydrationInProgress) { // Warning already logged by setupCache, just fall back to standard checkout + report.serving_mode = 'fallback-contention' cacheInfo = null core.endGroup() } else { @@ -142,18 +187,54 @@ export async function getSource(settings: IGitSourceSettings): Promise { cacheInfo.commitEarlyDenyReason ) - const performedHydration = await blacksmithCache.ensureMirror( - cacheInfo.mirrorPath, - repositoryUrl, - settings.authToken, - settings.verbose + const mirrorExisted = fsHelper.directoryExistsSync( + cacheInfo.mirrorPath ) + const cloneStart = Date.now() + let performedHydration = false + try { + performedHydration = await blacksmithCache.ensureMirror( + cacheInfo.mirrorPath, + repositoryUrl, + settings.authToken, + settings.verbose + ) + } catch (error) { + if (!mirrorExisted) { + onHydrationReport({ + sticky_disk_key: cacheInfo.stickyDiskKey, + clone_ms: Date.now() - cloneStart, + clone_bytes: 0, + ref_count: 0, + outcome: 'failure', + error_class: mirrorTelemetry.classifyError(error) + }) + } + throw error + } + // Persist the mirror state before any measurement walk or + // telemetry POST: a cancel in that window must still leave the + // post step committing the hydration. stateHelper.setBlacksmithCachePerformedHydration(performedHydration) + if (performedHydration) { + stateHelper.setBlacksmithCacheMirrorChanged(true) + report.serving_mode = 'hydrating' + onHydrationReport({ + sticky_disk_key: cacheInfo.stickyDiskKey, + clone_ms: Date.now() - cloneStart, + clone_bytes: await mirrorTelemetry.packSizeBytes( + cacheInfo.mirrorPath + ), + ref_count: await mirrorTelemetry.refCount(cacheInfo.mirrorPath), + outcome: 'success' + }) + } else { + report.serving_mode = 'mirror' + } if (performedHydration) { // A freshly-cloned mirror is exactly the remote's current state mirrorFresh = true - stateHelper.setBlacksmithCacheMirrorChanged(true) } else if (settings.fetchDepth <= 0) { // Bring the mirror's branch/tag refs up to date with the remote // (ls-remote diff + targeted fetch of only the changed refs), so @@ -163,7 +244,9 @@ export async function getSource(settings: IGitSourceSettings): Promise { cacheInfo.mirrorPath, repositoryUrl, settings.authToken, - settings.verbose + settings.verbose, + undefined, + () => stateHelper.setBlacksmithCacheMirrorChanged(true) ) mirrorFresh = syncResult.success stateHelper.setBlacksmithCacheMirrorChanged(syncResult.changed) @@ -173,6 +256,16 @@ export async function getSource(settings: IGitSourceSettings): Promise { stateHelper.setBlacksmithCacheMirrorSyncTimedOut( syncResult.timedOut ) + if (!syncResult.skipped) { + // Structured refresh row (fire-and-forget; errors swallowed). + await mirrorTelemetry.reportMaintenance( + mirrorTelemetry.maintenanceRunFromResult( + 'refresh', + cacheInfo.stickyDiskKey, + syncResult + ) + ) + } } else { // Shallow checkouts never populate the workspace from mirror // refs, so the checkout step doesn't need a fresh mirror. Defer @@ -189,6 +282,13 @@ export async function getSource(settings: IGitSourceSettings): Promise { core.warning( `Blacksmith cache setup failed, using standard checkout: ${error}` ) + // Stamp the setup duration only when setupCache itself failed; later + // failures (e.g. the hydration clone) are not sticky-disk setup time. + if (report.sticky_disk_setup_ms === 0) { + report.sticky_disk_setup_ms = Date.now() - setupStart + } + report.serving_mode = 'fallback-error' + report.error_class = mirrorTelemetry.classifyError(error) // Don't clear cacheInfo.exposeId/stickyDiskKey from state - they're already saved // so cleanup can still call commitStickyDisk with shouldCommit: false cacheInfo = null @@ -200,6 +300,7 @@ export async function getSource(settings: IGitSourceSettings): Promise { !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) ) { core.startGroup('Initializing the repository') + const initStart = Date.now() await git.init() // Setup alternates to use objects from Blacksmith mirror if available if (cacheInfo) { @@ -209,6 +310,9 @@ export async function getSource(settings: IGitSourceSettings): Promise { ) } await git.remoteAdd('origin', repositoryUrl) + if (cacheInfo) { + report.clone_from_mirror_ms += Date.now() - initStart + } core.endGroup() } @@ -253,6 +357,11 @@ export async function getSource(settings: IGitSourceSettings): Promise { // Fetch core.startGroup('Fetching the repository') + const objectsDir = path.join(settings.repositoryPath, '.git', 'objects') + const objectsBytesBefore = cacheInfo + ? await mirrorTelemetry.dirSizeBytesOrNull(objectsDir) + : null + const fetchStart = Date.now() const fetchOptions: FetchOptions = {} if (settings.filter) { @@ -353,6 +462,23 @@ export async function getSource(settings: IGitSourceSettings): Promise { const refSpec = refHelper.getRefSpec(settings.ref, settings.commit) await git.fetch(refSpec, fetchOptions) } + if (cacheInfo) { + // Mirror-served: the fetch pulls only the delta the mirror is missing. + report.delta_fetch_ms = Date.now() - fetchStart + const objectsBytesAfter = + await mirrorTelemetry.dirSizeBytesOrNull(objectsDir) + report.delta_fetch_bytes = + objectsBytesBefore !== null && objectsBytesAfter !== null + ? Math.max(0, objectsBytesAfter - objectsBytesBefore) + : 0 + report.mirror_size_bytes = await mirrorTelemetry.packSizeBytes( + cacheInfo.mirrorPath + ) + report.ref_count = await mirrorTelemetry.refCount(cacheInfo.mirrorPath) + } else { + // Fallback/bypass: the fetch is the full checkout cost. + report.full_checkout_ms = Date.now() - fetchStart + } core.endGroup() // Checkout info @@ -370,7 +496,9 @@ export async function getSource(settings: IGitSourceSettings): Promise { // For sparse checkouts, let `checkout` fetch the needed objects lazily. if (settings.lfs && !settings.sparseCheckout) { core.startGroup('Fetching LFS objects') + const lfsStart = Date.now() await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref) + report.lfs_ms = Date.now() - lfsStart core.endGroup() } @@ -393,7 +521,17 @@ export async function getSource(settings: IGitSourceSettings): Promise { // Checkout core.startGroup('Checking out the ref') + const checkoutStart = Date.now() await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) + if (cacheInfo) { + // Materializing the working tree reads objects from the mirror through + // alternates — the second half of the clone-from-mirror cost. + report.clone_from_mirror_ms += Date.now() - checkoutStart + } else { + // Fallback/bypass: working-tree materialization is part of the full + // checkout cost, keeping phase totals comparable across serving modes. + report.full_checkout_ms += Date.now() - checkoutStart + } core.endGroup() // Dissociate from Blacksmith mirror if requested @@ -409,6 +547,7 @@ export async function getSource(settings: IGitSourceSettings): Promise { // Submodules if (settings.submodules) { + const submodulesStart = Date.now() // Temporarily override global config core.startGroup('Setting up auth for fetching submodules') await authHelper.configureGlobalAuth() @@ -430,6 +569,7 @@ export async function getSource(settings: IGitSourceSettings): Promise { await authHelper.configureSubmoduleAuth() core.endGroup() } + report.submodules_ms = Date.now() - submodulesStart } // Get commit information diff --git a/src/internal-metrics.ts b/src/internal-metrics.ts index 8336149..5229bb8 100644 --- a/src/internal-metrics.ts +++ b/src/internal-metrics.ts @@ -13,6 +13,37 @@ export async function reportInternalMetric( metricType: string, value: number, attributes: Record +): Promise { + await postInternalMetric(metricType, { + metric_type: metricType, + value, + vm_id: VM_ID, + attributes + }) +} + +/** + * Report a structured telemetry payload to the Blacksmith agent. + * Same fail-soft, fire-and-forget contract as reportInternalMetric: the + * payload rides the /internal envelope in a `payload` field alongside the + * metric_type, and every error is swallowed. + */ +export async function reportStructuredMetric( + metricType: string, + structuredPayload: unknown +): Promise { + await postInternalMetric(metricType, { + metric_type: metricType, + value: 0, + vm_id: VM_ID, + attributes: {}, + payload: structuredPayload + }) +} + +async function postInternalMetric( + metricType: string, + body: Record ): Promise { if (!METRICS_PORT) { core.debug( @@ -25,12 +56,7 @@ export async function reportInternalMetric( return } - const payload = JSON.stringify({ - metric_type: metricType, - value, - vm_id: VM_ID, - attributes - }) + const payload = JSON.stringify(body) try { await new Promise((resolve, reject) => { diff --git a/src/main.ts b/src/main.ts index 596e440..48d7f21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import * as stateHelper from './state-helper' import * as blacksmithCache from './blacksmith-cache' import {checkPreviousStepFailures} from './step-checker' import {reportInternalMetric} from './internal-metrics' +import * as mirrorTelemetry from './mirror-telemetry' async function run(): Promise { try { @@ -50,6 +51,8 @@ async function cleanup(): Promise { let mirrorSyncFailed = stateHelper.BlacksmithCacheMirrorSyncFailed let mirrorSyncTimedOut = stateHelper.BlacksmithCacheMirrorSyncTimedOut if (exposeId && stickyDiskKey) { + // Sync result kept for the structured refresh maintenance row below. + let deferredSyncResult: blacksmithCache.MirrorSyncResult | null = null // For shallow checkouts the checkout step never populates the workspace // from mirror refs, so the mirror sync is deferred here to keep the // checkout step fast. @@ -65,6 +68,7 @@ async function cleanup(): Promise { authToken, stateHelper.BlacksmithCacheVerbose ) + deferredSyncResult = syncResult mirrorChanged = syncResult.changed mirrorSyncFailed = !syncResult.success && !syncResult.timedOut mirrorSyncTimedOut = syncResult.timedOut @@ -172,6 +176,33 @@ async function cleanup(): Promise { }) } } + + // Structured maintenance rows (fire-and-forget; errors swallowed inside). + if (deferredSyncResult && !deferredSyncResult.skipped) { + await mirrorTelemetry.reportMaintenance( + mirrorTelemetry.maintenanceRunFromResult( + 'refresh', + stickyDiskKey, + deferredSyncResult + ) + ) + } + // The bounded repack is reported under the `gc` operation: it is the + // pack-consolidation step of the mirror lifecycle, whatever git verb + // performs it. + if ( + cleanupResult && + mirrorPath && + !cleanupResult.maintenanceResult.skipped + ) { + await mirrorTelemetry.reportMaintenance( + mirrorTelemetry.maintenanceRunFromResult( + 'gc', + stickyDiskKey, + cleanupResult.maintenanceResult + ) + ) + } } } diff --git a/src/mirror-telemetry.ts b/src/mirror-telemetry.ts new file mode 100644 index 0000000..a95155d --- /dev/null +++ b/src/mirror-telemetry.ts @@ -0,0 +1,266 @@ +import * as core from '@actions/core' +import * as exec from '@actions/exec' +import * as fs from 'fs' +import * as path from 'path' +import {reportStructuredMetric} from './internal-metrics' + +/** + * Structured git-mirror telemetry reported to the Blacksmith agent over the + * existing /internal channel. Payloads stay dumb: raw measurements plus + * closed-enum outcomes; all identity stamping and derivation happen + * agent-side. Everything here inherits the fail-soft contract — + * fire-and-forget with a hard timeout, every error swallowed — so telemetry + * can never fail a customer job. + */ + +export type ServingMode = + | 'mirror' + | 'hydrating' + | 'fallback-contention' + | 'fallback-error' + | 'bypass' + +export type Outcome = 'success' | 'failure' | 'timeout' + +/** + * Field names match the fa agent's internalmetric.CheckoutReport JSON tags. + * sticky_disk_key names the mirror the checkout targeted even when no disk + * was exposed (fallback/bypass), so rows stay attributable per repository. + */ +export interface CheckoutReport { + serving_mode: ServingMode + outcome: Outcome + error_class?: string + sticky_disk_key: string + + sticky_disk_setup_ms: number + clone_from_mirror_ms: number + delta_fetch_ms: number + delta_fetch_bytes: number + full_checkout_ms: number + submodules_ms: number + lfs_ms: number + total_ms: number + + mirror_size_bytes: number + ref_count: number + + shallow: boolean + filter: boolean + submodules_enabled: boolean + lfs_enabled: boolean +} + +/** Field names match the fa agent's internalmetric.HydrationReport JSON tags. */ +export interface HydrationReport { + sticky_disk_key: string + clone_ms: number + clone_bytes: number + ref_count: number + outcome: Outcome + error_class?: string +} + +/** Field names match the fa agent's internalmetric.MaintenanceRun JSON tags. */ +export interface MaintenanceRun { + op: 'refresh' | 'gc' | 'fsck' + sticky_disk_key: string + duration_ms: number + bytes: number + outcome: Outcome + error_class?: string + mirror_size_bytes: number +} + +/** + * Build a maintenance row from an operation result. Measurement fields are + * best-effort and default to 0 when the operation didn't record them. + */ +export function maintenanceRunFromResult( + op: MaintenanceRun['op'], + stickyDiskKey: string, + result: { + success: boolean + timedOut: boolean + error?: string + durationMs?: number + bytes?: number + mirrorSizeBytes?: number + } +): MaintenanceRun { + const run: MaintenanceRun = { + op, + sticky_disk_key: stickyDiskKey, + duration_ms: result.durationMs ?? 0, + bytes: result.bytes ?? 0, + outcome: result.success + ? 'success' + : result.timedOut + ? 'timeout' + : 'failure', + mirror_size_bytes: result.mirrorSizeBytes ?? 0 + } + if (!result.success && result.error) { + run.error_class = result.timedOut ? 'timeout' : 'error' + } + return run +} + +export function newCheckoutReport(): CheckoutReport { + return { + serving_mode: 'bypass', + outcome: 'failure', + sticky_disk_key: '', + sticky_disk_setup_ms: 0, + clone_from_mirror_ms: 0, + delta_fetch_ms: 0, + delta_fetch_bytes: 0, + full_checkout_ms: 0, + submodules_ms: 0, + lfs_ms: 0, + total_ms: 0, + mirror_size_bytes: 0, + ref_count: 0, + shallow: false, + filter: false, + submodules_enabled: false, + lfs_enabled: false + } +} + +// Hard cap on the `du` walk so a wedged or slow disk can never stall the +// job on a measurement — the walk is telemetry, not work. +const DIR_SIZE_TIMEOUT_SECS = 15 + +/** + * Directory size in bytes via `du -sb` (time-capped). Meant for the + * runner-local workspace object store, which holds only the delta objects; + * mirror measurements on the sticky disk use the pack helpers instead. + * Returns null on any failure so callers can tell "unmeasurable" apart from + * a real size — a missing byte count degrades the report, never the job. + */ +export async function dirSizeBytesOrNull(dir: string): Promise { + try { + const result = await exec.getExecOutput( + 'timeout', + [String(DIR_SIZE_TIMEOUT_SECS), 'du', '-sb', dir], + { + ignoreReturnCode: true, + silent: true + } + ) + if (result.exitCode !== 0) { + return null + } + const size = parseInt(result.stdout.trim().split(/\s+/)[0], 10) + return isNaN(size) || size < 0 ? null : size + } catch { + return null + } +} + +/** + * Bytes held in `/objects/pack`. Packs are a flat handful of large + * files, so this is one readdir plus a stat per pack — never a walk over + * loose objects or refs, which on a freshly mounted block device is a + * random read per inode. Returns null when unmeasurable; a repository + * without a pack directory measures 0. + */ +export async function packSizeBytesOrNull( + gitDir: string +): Promise { + const packDir = path.join(gitDir, 'objects', 'pack') + try { + const entries = await fs.promises.readdir(packDir, {withFileTypes: true}) + let total = 0 + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + const stat = await fs.promises.stat(path.join(packDir, entry.name)) + total += stat.size + } + return total + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 0 : null + } +} + +/** Pack bytes of a git repository, or 0 when they cannot be measured. */ +export async function packSizeBytes(gitDir: string): Promise { + return (await packSizeBytesOrNull(gitDir)) ?? 0 +} + +/** + * Names of the pack files under `/objects/pack`, sorted, or null + * when unmeasurable. Any gc that did work rewrites this set (pack names + * are content hashes), so comparing two snapshots detects a no-op + * `gc --auto` without touching loose objects. + */ +export async function packNamesOrNull( + gitDir: string +): Promise { + try { + const entries = await fs.promises.readdir( + path.join(gitDir, 'objects', 'pack') + ) + return entries.filter(name => name.endsWith('.pack')).sort() + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null + } +} + +/** Ref count of a git repository. Returns 0 on any failure. */ +export async function refCount(gitDir: string): Promise { + try { + const result = await exec.getExecOutput('git', ['-C', gitDir, 'show-ref'], { + ignoreReturnCode: true, + silent: true + }) + if (result.exitCode !== 0) { + return 0 + } + const out = result.stdout.trim() + return out === '' ? 0 : out.split('\n').length + } catch { + return 0 + } +} + +/** Short closed-ish error class from an unknown error; never customer data. */ +export function classifyError(error: unknown): string { + if (error instanceof Error && error.name && error.name !== 'Error') { + return error.name + } + return 'error' +} + +export async function reportCheckout(report: CheckoutReport): Promise { + try { + await reportStructuredMetric('git_mirror_checkout_report', report) + } catch (error) { + core.debug( + `[git-mirror] checkout report failed: ${(error as Error).message}` + ) + } +} + +export async function reportHydration(report: HydrationReport): Promise { + try { + await reportStructuredMetric('git_mirror_hydration_report', report) + } catch (error) { + core.debug( + `[git-mirror] hydration report failed: ${(error as Error).message}` + ) + } +} + +export async function reportMaintenance(run: MaintenanceRun): Promise { + try { + await reportStructuredMetric('git_mirror_maintenance_run', run) + } catch (error) { + core.debug( + `[git-mirror] maintenance report failed: ${(error as Error).message}` + ) + } +}