Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
141 changes: 119 additions & 22 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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 git_version_1 = __nccwpck_require__(3142);
// 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;
Expand All @@ -85,6 +86,12 @@ const MIRROR_VERSION = 'v1';
// window is kept generous so slow-but-healthy syncs still complete.
const REFRESH_TIMEOUT_SECS = 120; // 2 minutes, single attempt
const GC_TIMEOUT_SECS = 120; // 2 minutes
// Each pack in the mirror must hold at least this many times the objects of
// the next-smaller pack; `repack --geometric` rolls up only the packs that
// violate this, leaving the large base pack untouched.
const GEOMETRIC_REPACK_FACTOR = 2;
// `git repack --geometric` needs 2.32+.
const MinimumGeometricRepackGitVersion = new git_version_1.GitVersion('2.32');
const FLUSH_TIMEOUT_SECS = 10; // 10 seconds for durability flush
const UMOUNT_TIMEOUT_SECS = 10; // 10 seconds for unmount
const UMOUNT_MAX_RETRIES = 3; // Number of unmount retry attempts
Expand Down Expand Up @@ -1192,47 +1199,111 @@ function hasCommitGraph(mirrorPath) {
fs.existsSync(path.join(mirrorPath, 'objects', 'info', 'commit-graphs', 'commit-graph-chain')));
}
/**
* Run lightweight garbage collection on the mirror.
* Uses --auto to only run GC when git determines it's needed (based on loose object count).
* This avoids expensive full repacks on every run while still keeping the repo tidy over time.
* Whether the installed git supports `repack --geometric`.
*/
function supportsGeometricRepack() {
return __awaiter(this, void 0, void 0, function* () {
try {
const result = yield exec.getExecOutput('git', ['version'], {
silent: true,
ignoreReturnCode: true
});
const match = result.stdout.match(/\d+\.\d+(\.\d+)?/);
if (result.exitCode !== 0 || !match) {
return false;
}
return new git_version_1.GitVersion(match[0]).checkMinimum(MinimumGeometricRepackGitVersion);
}
catch (_a) {
return false;
}
});
}
/**
* Log the mirror's pack and loose object counts so maintenance cost can be
* correlated with object store shape. Best effort.
*/
function logMirrorObjectCounts(mirrorPath) {
return __awaiter(this, void 0, void 0, function* () {
try {
const result = yield exec.getExecOutput('git', ['-C', mirrorPath, 'count-objects', '-v'], { silent: true, ignoreReturnCode: true });
if (result.exitCode !== 0) {
return;
}
const counts = new Map();
for (const line of result.stdout.split('\n')) {
const [key, value] = line.split(':');
if (key && value !== undefined) {
counts.set(key.trim(), value.trim());
}
}
core.info(`[git-mirror] Object store: packs=${counts.get('packs')}, size-pack=${counts.get('size-pack')}KiB, loose=${counts.get('count')}, size-loose=${counts.get('size')}KiB`);
}
catch (_a) {
// Diagnostic only.
}
});
}
/**
* Keep the mirror's object store tidy after a sync.
*
* Every sync fetch lands its objects in a new pack, so the pack count grows
* with every committed sync. `git gc --auto` answers that by rewriting
* every pack in the repository once gc.autoPackLimit is crossed - on a
* large monorepo that means repacking a multi-GB base pack, which does not
* fit in the timeout, and a timed-out GC is never committed, so every
* subsequent job on the mirror attempts the same full repack again. A
* geometric repack instead rolls up only the small packs (and loose
* objects) needed to keep pack sizes in a geometric progression: the base
* pack is left alone and the work per run stays proportional to what recent
* syncs added, and is cheap when the packs already satisfy the
* progression. Refs are packed afterwards, as gc would, so loose refs
* written by sync fetches do not pile up.
*
* Falls back to `gc --auto` on git versions without `--geometric`.
*/
function runMirrorGC(mirrorPath_1) {
return __awaiter(this, arguments, void 0, function* (mirrorPath, timeoutSecs = GC_TIMEOUT_SECS) {
core.info(`[git-mirror] Running auto garbage collection (timeout: ${timeoutSecs}s)`);
try {
// --auto: only run if thresholds exceeded (default: 6700 loose objects or 50 packs)
// This is much faster than a full gc when not needed
// gc.autoDetach=false: prevent git from forking a background daemon for GC.
// Without this, the parent `git gc --auto` returns immediately while the
// daemonized child keeps running with cwd and mmap'd pack files on the
// mirror mount, causing the subsequent `umount` to fail with EBUSY.
const result = yield exec.getExecOutput('timeout', [
String(timeoutSecs),
'git',
'-c',
'gc.autoDetach=false',
yield logMirrorObjectCounts(mirrorPath);
const geometric = yield supportsGeometricRepack();
const mode = geometric ? 'geometric repack' : 'auto garbage collection';
core.info(`[git-mirror] Running ${mode} (timeout: ${timeoutSecs}s)`);
// gc.autoDetach=false: prevent git from forking a background daemon for GC.
// Without this, the parent `git gc --auto` returns immediately while the
// daemonized child keeps running with cwd and mmap'd pack files on the
// mirror mount, causing the subsequent `umount` to fail with EBUSY.
const gitArgs = geometric
? [
'-C',
mirrorPath,
'gc',
'--auto'
], { ignoreReturnCode: true });
'repack',
'-d',
`--geometric=${GEOMETRIC_REPACK_FACTOR}`
]
: ['-c', 'gc.autoDetach=false', '-C', mirrorPath, 'gc', '--auto'];
try {
const start = Date.now();
const result = yield exec.getExecOutput('timeout', [String(timeoutSecs), 'git', ...gitArgs], { ignoreReturnCode: true });
if (result.exitCode === TIMEOUT_EXIT_CODE) {
core.warning(`[git-mirror] GC timed out after ${timeoutSecs}s`);
return {
success: false,
timedOut: true,
error: `git gc timed out after ${timeoutSecs}s`
error: `git ${mode} timed out after ${timeoutSecs}s`
};
}
if (result.exitCode !== 0) {
core.warning(`[git-mirror] GC failed with exit code ${result.exitCode}`);
return {
success: false,
timedOut: false,
error: `git gc failed with exit code ${result.exitCode}`
error: `git ${mode} failed with exit code ${result.exitCode}`
};
}
core.debug('[git-mirror] Completed git gc --auto');
core.info(`[git-mirror] Completed ${mode} in ${Date.now() - start}ms`);
if (geometric) {
yield packMirrorRefs(mirrorPath, timeoutSecs);
}
return { success: true, timedOut: false };
}
catch (error) {
Expand All @@ -1242,6 +1313,32 @@ function runMirrorGC(mirrorPath_1) {
}
});
}
/**
* Pack the mirror's loose refs. Loose refs take precedence over packed-refs
* and both hold the same values, so an interrupted run leaves the refs
* intact; failure is non-fatal.
*/
function packMirrorRefs(mirrorPath, timeoutSecs) {
return __awaiter(this, void 0, void 0, function* () {
try {
const result = yield exec.getExecOutput('timeout', [
String(timeoutSecs),
'git',
'-C',
mirrorPath,
'pack-refs',
'--all',
'--prune'
], { silent: true, ignoreReturnCode: true });
if (result.exitCode !== 0) {
core.warning(`[git-mirror] pack-refs failed with exit code ${result.exitCode}`);
}
}
catch (error) {
core.warning(`[git-mirror] pack-refs failed: ${error}`);
}
});
}
/**
* Get the block device path for a mount point.
* Tries findmnt first, then falls back to parsing mount output.
Expand Down
158 changes: 133 additions & 25 deletions src/blacksmith-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {GitVersion} from './git-version'

// Without a deadline, a black-holed dial stalls the checkout until the OS
// gives up on the TCP handshake.
Expand All @@ -24,6 +25,12 @@ const MIRROR_VERSION = 'v1'
// window is kept generous so slow-but-healthy syncs still complete.
const REFRESH_TIMEOUT_SECS = 120 // 2 minutes, single attempt
const GC_TIMEOUT_SECS = 120 // 2 minutes
// Each pack in the mirror must hold at least this many times the objects of
// the next-smaller pack; `repack --geometric` rolls up only the packs that
// violate this, leaving the large base pack untouched.
const GEOMETRIC_REPACK_FACTOR = 2
// `git repack --geometric` needs 2.32+.
const MinimumGeometricRepackGitVersion = new GitVersion('2.32')
const FLUSH_TIMEOUT_SECS = 10 // 10 seconds for durability flush
const UMOUNT_TIMEOUT_SECS = 10 // 10 seconds for unmount
const UMOUNT_MAX_RETRIES = 3 // Number of unmount retry attempts
Expand Down Expand Up @@ -1414,56 +1421,124 @@ export function hasCommitGraph(mirrorPath: string): boolean {
}

/**
* Run lightweight garbage collection on the mirror.
* Uses --auto to only run GC when git determines it's needed (based on loose object count).
* This avoids expensive full repacks on every run while still keeping the repo tidy over time.
* Whether the installed git supports `repack --geometric`.
*/
async function supportsGeometricRepack(): Promise<boolean> {
try {
const result = await exec.getExecOutput('git', ['version'], {
silent: true,
ignoreReturnCode: true
})
const match = result.stdout.match(/\d+\.\d+(\.\d+)?/)
if (result.exitCode !== 0 || !match) {
return false
}
return new GitVersion(match[0]).checkMinimum(
MinimumGeometricRepackGitVersion
)
} catch {
return false
}
}

/**
* Log the mirror's pack and loose object counts so maintenance cost can be
* correlated with object store shape. Best effort.
*/
async function logMirrorObjectCounts(mirrorPath: string): Promise<void> {
try {
const result = await exec.getExecOutput(
'git',
['-C', mirrorPath, 'count-objects', '-v'],
{silent: true, ignoreReturnCode: true}
)
if (result.exitCode !== 0) {
return
}
const counts = new Map<string, string>()
for (const line of result.stdout.split('\n')) {
const [key, value] = line.split(':')
if (key && value !== undefined) {
counts.set(key.trim(), value.trim())
}
}
core.info(
`[git-mirror] Object store: packs=${counts.get('packs')}, size-pack=${counts.get('size-pack')}KiB, loose=${counts.get('count')}, size-loose=${counts.get('size')}KiB`
)
} catch {
// Diagnostic only.
}
}

/**
* Keep the mirror's object store tidy after a sync.
*
* Every sync fetch lands its objects in a new pack, so the pack count grows
* with every committed sync. `git gc --auto` answers that by rewriting
* every pack in the repository once gc.autoPackLimit is crossed - on a
* large monorepo that means repacking a multi-GB base pack, which does not
* fit in the timeout, and a timed-out GC is never committed, so every
* subsequent job on the mirror attempts the same full repack again. A
* geometric repack instead rolls up only the small packs (and loose
* objects) needed to keep pack sizes in a geometric progression: the base
* pack is left alone and the work per run stays proportional to what recent
* syncs added, and is cheap when the packs already satisfy the
* progression. Refs are packed afterwards, as gc would, so loose refs
* written by sync fetches do not pile up.
*
* Falls back to `gc --auto` on git versions without `--geometric`.
*/
async function runMirrorGC(
mirrorPath: string,
timeoutSecs: number = GC_TIMEOUT_SECS
): Promise<OperationResult> {
core.info(
`[git-mirror] Running auto garbage collection (timeout: ${timeoutSecs}s)`
)
await logMirrorObjectCounts(mirrorPath)
const geometric = await supportsGeometricRepack()
const mode = geometric ? 'geometric repack' : 'auto garbage collection'
core.info(`[git-mirror] Running ${mode} (timeout: ${timeoutSecs}s)`)

// gc.autoDetach=false: prevent git from forking a background daemon for GC.
// Without this, the parent `git gc --auto` returns immediately while the
// daemonized child keeps running with cwd and mmap'd pack files on the
// mirror mount, causing the subsequent `umount` to fail with EBUSY.
const gitArgs = geometric
? [
'-C',
mirrorPath,
'repack',
'-d',
`--geometric=${GEOMETRIC_REPACK_FACTOR}`
]
: ['-c', 'gc.autoDetach=false', '-C', mirrorPath, 'gc', '--auto']

try {
// --auto: only run if thresholds exceeded (default: 6700 loose objects or 50 packs)
// This is much faster than a full gc when not needed
// gc.autoDetach=false: prevent git from forking a background daemon for GC.
// Without this, the parent `git gc --auto` returns immediately while the
// daemonized child keeps running with cwd and mmap'd pack files on the
// mirror mount, causing the subsequent `umount` to fail with EBUSY.
const start = Date.now()
const result = await exec.getExecOutput(
'timeout',
[
String(timeoutSecs),
'git',
'-c',
'gc.autoDetach=false',
'-C',
mirrorPath,
'gc',
'--auto'
],
[String(timeoutSecs), 'git', ...gitArgs],
{ignoreReturnCode: true}
)
if (result.exitCode === TIMEOUT_EXIT_CODE) {
core.warning(`[git-mirror] GC timed out after ${timeoutSecs}s`)
return {
success: false,
timedOut: true,
error: `git gc timed out after ${timeoutSecs}s`
error: `git ${mode} timed out after ${timeoutSecs}s`
}
}
if (result.exitCode !== 0) {
core.warning(`[git-mirror] GC failed with exit code ${result.exitCode}`)
return {
success: false,
timedOut: false,
error: `git gc failed with exit code ${result.exitCode}`
error: `git ${mode} failed with exit code ${result.exitCode}`
}
}
core.debug('[git-mirror] Completed git gc --auto')
core.info(`[git-mirror] Completed ${mode} in ${Date.now() - start}ms`)

if (geometric) {
await packMirrorRefs(mirrorPath, timeoutSecs)
}
return {success: true, timedOut: false}
} catch (error) {
const msg = (error as Error).message || String(error)
Expand All @@ -1472,6 +1547,39 @@ async function runMirrorGC(
}
}

/**
* Pack the mirror's loose refs. Loose refs take precedence over packed-refs
* and both hold the same values, so an interrupted run leaves the refs
* intact; failure is non-fatal.
*/
async function packMirrorRefs(
mirrorPath: string,
timeoutSecs: number
): Promise<void> {
try {
const result = await exec.getExecOutput(
'timeout',
[
String(timeoutSecs),
'git',
'-C',
mirrorPath,
'pack-refs',
'--all',
'--prune'
],
{silent: true, ignoreReturnCode: true}
)
if (result.exitCode !== 0) {
core.warning(
`[git-mirror] pack-refs failed with exit code ${result.exitCode}`
)
}
} catch (error) {
core.warning(`[git-mirror] pack-refs failed: ${error}`)
}
}

/**
* Get the block device path for a mount point.
* Tries findmnt first, then falls back to parsing mount output.
Expand Down
Loading