mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-09-14 04:45:07 +00:00
Merge #b6704561: feat(storage): retire migration backups family by fami…
feat(storage): retire migration backups family by family nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqstvuz9v8ewz6hfkpwssajjl9sjsxk50y39tlq30x75f9f8qy6wx8g94xt55 PR-Author: DanConwayDev's Agent nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0 CoverNote: Version 3 automatically migrates complete per-owner and `/prs/` repositories into thin views backed by one local object family per repository identifier. This PR retires each legacy family backup as soon as that family is proven healthy, so migration overhead is bounded by the family in flight instead of retaining a second full copy of the Git data indefinitely. ## Retirement safety - Every view must be correctly wired to its family and match the active migration journal snapshot. - The family must contain every Git-readable backup object, all packs must be indexed and pass `git verify-pack`, and the normal steady-state family integrity inspection must be healthy. This last check verifies readable/hash-correct objects, refs, alternates, packs, and shallow state before retirement. - A durable `retired` journal state is fsynced before deletion. Backup-directory removals and parent pruning are fsynced before the journal is removed, so restart recovery cannot lose track of a resurrected directory entry. - Unindexed packs are quarantined by SHA-256 content beneath `.grasp/migration/unindexed-packs/`. Identical retries converge; different payloads cannot overwrite one another even when their original filenames match. - Any active-migration gate error fails closed. A family that is structurally migrated but unhealthy keeps its backup, starts the service, logs an `ERROR`, and enters the ordinary asynchronous integrity-repair path. ## Shallow compatibility and repair A server-side depth-one fallback existed only in untagged development revisions from 2026-01-05 (`623cae5`) through its 2026-01-12 fix (`f25eea8`); no tagged v1 or v2 release shipped it. It ran only when pending state or PR objects had not arrived through the normal push path and the server fetched them from another listed Git server. V3 preserves any resulting `shallow` marker on the thin view, so shallow clones and the current tree remain as available as before migration, and keeps the legacy backup. The normal integrity worker automatically requests the full closure from accepted clone URLs. Successful repair removes the marker immediately and the next launch retires the backup; failed repair keeps both marker and backup and logs the affected identifier. No separate operator step is required for upgrade. ## Upgrade boundary The v2-to-v3 Git-data migration is one-way. V2 does not coordinate writes through shared families and must not run against migrated thin views. Rollback means restoring the pre-upgrade Git and relay-data snapshot together, not only replacing the binary. Installations already migrated by the earlier candidate are supported: manually removed verified backups leave completed journals that are compacted safely, while remaining backups are rechecked and retired family by family. Unverifiable stale backups are retained with a warning. GC and unreachable-object pruning remain deliberately excluded to preserve delete-state rollback material. Optional S3 storage is also separate from this local-storage PR. ## Validation Regressions cover corrupt loose family objects, repeated same-name unindexed packs with different content, durable interrupted retirement, active and stale journal states, manually removed backups, real depth-one migration, automatic closure recovery, marker retention on failed repair, and full client cloning after repair. `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and `git diff --check` all pass on Rust 1.96.
This commit is contained in:
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Version 3 performs an automatic, one-way migration of `NGIT_GIT_DATA_PATH`
|
||||
from complete per-owner and `/prs/` repositories to thin views backed by
|
||||
shared identifier families. Version 2 does not coordinate reads and writes
|
||||
through this layout and must not be run against migrated Git data. Rollback
|
||||
requires restoring the Git and relay-data snapshot taken before the v3
|
||||
launch, not only downgrading the binary.
|
||||
- Added `trusted_proxy_cidrs` to the public `Config` struct. Rust consumers
|
||||
that construct `Config` with a struct literal must provide it.
|
||||
- Added `base_path` to the public `Config` struct. Rust consumers that
|
||||
@@ -72,6 +78,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Retire identifier-family migration backups family by family. Once every
|
||||
view of a family is installed, each backup is verified (family contains
|
||||
every Git-readable backup object, thin views match their journal snapshot,
|
||||
family packs are indexed and valid), and the ordinary family integrity
|
||||
report must be healthy before deletion. Unhealthy families retain their
|
||||
backups for automatic repair. Healthy backups are deleted before the next
|
||||
family migrates, bounding peak migration disk overhead to the family in
|
||||
flight. Unindexed legacy packs are quarantined by content under
|
||||
`.grasp/migration/unindexed-packs/`, unverifiable backups are retained with
|
||||
a warning, backup-directory removals are fsynced before their journals are
|
||||
removed, and completed installations whose backups were already removed
|
||||
manually start unchanged.
|
||||
- Reconcile server-side shallow repositories left by untagged development
|
||||
builds between 2026-01-05 (`623cae5`) and 2026-01-12 (`f25eea8`). No tagged
|
||||
v1 or v2 release shipped the depth-one fallback fetch. The affected
|
||||
non-happy path fetched missing state or PR data from another listed Git
|
||||
server when the objects had not arrived by push. V3 preserves existing
|
||||
shallow clone behavior and the legacy backup, automatically requests the
|
||||
complete closure from accepted clone sources, and logs any family that
|
||||
remains shallow without making it less available.
|
||||
- Scope bare log levels to ngit-grasp while keeping dependencies at warnings;
|
||||
explicit tracing filter expressions remain unchanged.
|
||||
- Keep per-event discovery and validation details at debug, retain aggregate
|
||||
|
||||
@@ -249,6 +249,42 @@ view whose alternate is the identifier family. Therefore:
|
||||
Mirroring a PR into an owner view becomes a ref update after an object
|
||||
availability check. It no longer copies the object graph.
|
||||
|
||||
## V2 to v3 migration boundary
|
||||
|
||||
V3 changes the on-disk meaning of every served Git repository. Owner and
|
||||
`/prs/` paths become thin views whose objects and write serialization belong to
|
||||
the identifier family under `.grasp`. Migration happens automatically on the
|
||||
first v3 launch, but the result is not safe for v2: v2 has no family lock,
|
||||
retained-root, or shared-object write model. A software rollback therefore
|
||||
requires restoring the Git and relay-data snapshot taken before v3; changing
|
||||
only the binary is not supported.
|
||||
|
||||
One pre-release development interval can leave server-side shallow
|
||||
repositories for v3 to reconcile. State Git-data fallback was introduced by
|
||||
commit `623cae5` on 2026-01-05 with `git fetch --depth=1`, carried into the
|
||||
rewritten purgatory sync path, and changed to a full fetch by commit `f25eea8`
|
||||
on 2026-01-12. The first tagged release was v1.0.0 on 2026-02-26 and already
|
||||
contained the fix, so no tagged v1 or v2 release shipped the shallow-fetch
|
||||
behavior. Only operators who deployed an untagged source revision from that
|
||||
seven-day interval can have repositories created by this bug.
|
||||
|
||||
The affected path was the non-happy-path Git-data fallback. It ran only when a
|
||||
pending state or PR event named OIDs which had not arrived through an ordinary
|
||||
push, then fetched those OIDs into an existing local repository from another
|
||||
announcement or PR clone URL. A repository whose required data arrived through
|
||||
the normal push path did not invoke this fallback.
|
||||
|
||||
V3 treats a legacy `shallow` marker as compatibility state rather than a reason
|
||||
to delete or disable the repository. Migration preserves the marker on the thin
|
||||
view so its shallow clones and current tree continue to work, and retains the
|
||||
legacy backup. After the listener starts, the ordinary integrity worker
|
||||
requests the complete closure from other accepted clone servers. On success it
|
||||
removes the marker; the next v3 launch retires the now-redundant backup. On
|
||||
failure the existing shallow clone and current tree remain available, the
|
||||
backup remains, and an `ERROR` log identifies the family with a non-zero
|
||||
`shallow_views` count. The operator does not need to take a separate action for
|
||||
the v3 upgrade.
|
||||
|
||||
## Startup migration
|
||||
|
||||
Migration runs after configuration validation and before purgatory restoration,
|
||||
@@ -275,10 +311,54 @@ For each legacy bare repository:
|
||||
|
||||
On restart, the journal determines whether to resume copying, finish a rename,
|
||||
or restore the legacy directory. Every state transition is safe to repeat.
|
||||
The original repository backup remains until the entire storage-version
|
||||
migration has been verified. The first implementation does not delete those
|
||||
backups automatically; an operator-visible later cleanup process can do so
|
||||
after an appropriate rollback window.
|
||||
|
||||
Backups are retired family by family rather than accumulating until the whole
|
||||
migration finishes. After the last view of a family is installed, each of its
|
||||
backups must pass a retirement gate: the family contains every Git-readable
|
||||
object of the backup, every affected view is a correctly wired thin view whose
|
||||
refs and `HEAD` match its journal snapshot, and every family pack is indexed
|
||||
and passes `git verify-pack`. The ordinary family integrity inspection must
|
||||
also report the family and all of its views healthy. This final check catches
|
||||
corrupt loose objects, missing history, broken ref targets, invalid alternates,
|
||||
and shallow views. An unhealthy family keeps its backups while the non-blocking
|
||||
integrity worker attempts repair after startup. Only a healthy family writes a
|
||||
durable `retired` journal state, deletes the backup, and removes the journal
|
||||
before the next family is migrated. Peak migration overhead is therefore
|
||||
bounded by the family currently in flight except for the small set of families
|
||||
still awaiting repair.
|
||||
|
||||
Backup deletion is itself durable: every directory entry removed while
|
||||
deleting and pruning a backup is fsynced before its journal is removed. A
|
||||
power loss therefore leaves either a discoverable `retired` journal that
|
||||
finishes deletion or no backup entry to rediscover.
|
||||
|
||||
Packs without an index are Git-invisible, so the superset proof cannot vouch
|
||||
for them; they are moved into content-addressed directories beneath
|
||||
`.grasp/migration/unindexed-packs/` before their backup is deleted. Repeated
|
||||
migrations preserve different payloads even when their original pack filenames
|
||||
match, while an identical payload converges on the same quarantine path.
|
||||
|
||||
A server-side `shallow` marker is a compatibility boundary, not valid final
|
||||
family storage. Migration copies it onto the installed thin view so the view
|
||||
keeps serving exactly what the legacy repository served, and excludes its
|
||||
backup from retirement until the family holds the complete reachable closure
|
||||
of the backup's refs. Closure recovery is the ordinary integrity repair: the
|
||||
family reports truncated parents as missing objects and marked views as
|
||||
`shallow_views`, repair fetches the closure from accepted clone sources, and
|
||||
once complete removes the marker. The backup retires on the next launch. An
|
||||
unrecoverable closure retains both marker and backup and keeps the family
|
||||
reported as unresolved; it never blocks startup because recovery depends on
|
||||
remote servers. Client-requested shallow clones and fetches remain supported
|
||||
throughout.
|
||||
|
||||
Retirement failures during an active migration are fail-closed and keep the
|
||||
backup. Journals from a migration that already committed are handled
|
||||
leniently on later launches: their ref snapshots are stale once the server
|
||||
has served traffic, so the gate skips snapshot equality, and an unverifiable
|
||||
backup (for example one whose migrated view was later deleted by repository
|
||||
lifecycle) is retained with a warning as operator-managed rollback material.
|
||||
Installations whose backups were verified and deleted manually simply have
|
||||
their completed journals removed.
|
||||
|
||||
The global `storage-version` advances only after every eligible repository is
|
||||
complete. A server must fail startup on an unrepairable mismatch rather than
|
||||
@@ -324,8 +404,9 @@ on remote servers.
|
||||
This is also the migration repair path. Migration remains a deterministic,
|
||||
offline conversion that preserves every Git-readable object and the exact
|
||||
legacy refs. Once those paths are thin family views, the ordinary family pass
|
||||
can heal pre-existing missing objects. Unindexed legacy packs remain in the
|
||||
migration backup; there is no separate legacy repair subsystem.
|
||||
can heal pre-existing missing objects. Unindexed legacy packs are preserved
|
||||
under `.grasp/migration/unindexed-packs/` when their backup is retired; there
|
||||
is no separate legacy repair subsystem.
|
||||
|
||||
Operators can queue the same identifier-scoped check in the live process:
|
||||
|
||||
|
||||
@@ -24,16 +24,17 @@ How-to guides are **recipes** that show you how to solve specific problems or ac
|
||||
|
||||
## Available How-To Guides
|
||||
|
||||
### [Upgrade Git family storage](upgrade-git-family-storage.md)
|
||||
**Problem:** Deduplicate existing repository objects during a server upgrade
|
||||
### [Upgrade from v2 to v3 Git family storage](upgrade-git-family-storage.md)
|
||||
|
||||
**Problem:** Perform the one-way identifier-family storage migration safely
|
||||
**Difficulty:** Advanced
|
||||
|
||||
**You'll learn:**
|
||||
- Prepare capacity and a release rollback point
|
||||
- Run the automatic crash-safe launch migration
|
||||
- Verify owner and `/prs/` repository views
|
||||
- Check or repair one identifier family on demand
|
||||
- Recover safely from an interrupted launch
|
||||
|
||||
- Prepare capacity and a snapshot-based rollback point
|
||||
- Run the automatic crash-safe v3 launch migration
|
||||
- Interpret migration and integrity summary logs
|
||||
- Restore v2 safely when a release rollback is required
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,106 +1,64 @@
|
||||
# Upgrade a server to identifier-family Git storage
|
||||
# Upgrade from v2 to v3 Git family storage
|
||||
|
||||
This procedure upgrades existing owner repositories and `/prs/` repositories
|
||||
to ref-only views backed by one local object family per repository identifier.
|
||||
It requires no new configuration and keeps all durable Git storage local.
|
||||
Version 3 replaces complete per-owner and `/prs/` bare repositories with thin
|
||||
views backed by one local object family per repository identifier. The upgrade
|
||||
requires no new configuration and runs automatically before the server accepts
|
||||
traffic.
|
||||
|
||||
The upgrader runs automatically on every relay launch after configuration has
|
||||
been validated and before purgatory restoration, background sync, or HTTP
|
||||
request handling. A failed migration stops startup; restarting resumes from its
|
||||
fsynced journal.
|
||||
This is a one-way Git-data migration. A v2 binary does not understand the
|
||||
family locking and write model and must not be run against a migrated
|
||||
`NGIT_GIT_DATA_PATH`. Rolling back means restoring a complete pre-upgrade
|
||||
snapshot, not only installing the older binary.
|
||||
|
||||
After runtime database initialization, a non-blocking integrity pass checks
|
||||
the resulting identifier families and views. It attempts to fetch missing
|
||||
objects from clone URLs in accepted repository announcements and emits an
|
||||
`ERROR` log for any family that remains unhealthy. Network repair never holds
|
||||
up the listening service or changes whether the structural migration commits.
|
||||
|
||||
## Before deploying
|
||||
## Before upgrading
|
||||
|
||||
1. Stop writes to the relay and take a filesystem snapshot of both the Git and
|
||||
relay data directories. An older binary cannot serve the new thin views, so
|
||||
this external snapshot is the clean release-level rollback boundary.
|
||||
2. Check free space on `NGIT_GIT_DATA_PATH`. During the first launch the server
|
||||
retains every original repository and builds a verified family union. Plan
|
||||
for at least the current owner and `/prs/` repository footprint again, plus
|
||||
room for the largest identifier family. Deduplication reduces the final
|
||||
family size but should not be assumed for preflight capacity planning.
|
||||
3. Keep Git and relay data on durable storage.
|
||||
4. Do not configure Git GC for this rollout. Unreachable objects and migration
|
||||
backups intentionally preserve delete-state rollback material.
|
||||
relay data directories. Keep it for the rollback window.
|
||||
2. Check free space on `NGIT_GIT_DATA_PATH`. Plan for the current footprint
|
||||
plus roughly one extra copy of the largest identifier family: every owner
|
||||
and `/prs/` repository sharing one identifier. The migration retires each
|
||||
healthy family's legacy repositories before processing the next family.
|
||||
3. Keep Git and relay data on durable storage. Do not enable Git garbage
|
||||
collection for this rollout; retained objects preserve recovery from
|
||||
deletion-state mistakes.
|
||||
|
||||
## Perform the upgrade
|
||||
## Upgrade
|
||||
|
||||
Deploy the new release with the existing configuration and start the relay.
|
||||
For every identifier, launch migration inventories all objects from all
|
||||
matching owner and `/prs/` paths, including unreachable objects, verifies the
|
||||
union, then atomically replaces each repository with a thin view. Original
|
||||
repositories move to:
|
||||
Deploy v3 with the existing configuration and start the relay. The launch
|
||||
migration is journaled, crash-safe, and safe to resume by restarting the same
|
||||
v3 release. It:
|
||||
|
||||
```text
|
||||
<git-data>/.grasp/migration/backups/
|
||||
```
|
||||
1. Builds and verifies one identifier family at a time, including unreachable
|
||||
objects retained for rollback.
|
||||
2. Replaces each owner and `/prs/` repository with a thin view that preserves
|
||||
its refs, `HEAD`, configuration, and observable Git behavior.
|
||||
3. Deletes a legacy backup only after its object superset, view wiring, packs,
|
||||
and ordinary family integrity report all verify.
|
||||
4. Keeps any unhealthy family's backup while the running server automatically
|
||||
attempts repair from accepted clone URLs.
|
||||
|
||||
Progress and restart state live under `.grasp/migration/journal/`. The global
|
||||
`.grasp/storage-version` marker is written only after every eligible view has
|
||||
been reconciled and verified. Do not edit these files while the service is
|
||||
running.
|
||||
Progress is stored below `.grasp/migration/`; the completed layout is marked by
|
||||
`.grasp/storage-version`. Do not edit these files while the service is running.
|
||||
|
||||
Confirm the service reaches its normal listening state and exercise a clone,
|
||||
fetch, and push for both a normal repository and a `/prs/` route. Retain the
|
||||
external snapshot and `.grasp/migration/backups/` for the rollback window. The
|
||||
server never deletes those backups automatically.
|
||||
|
||||
Watch for the terminal startup-pass summary:
|
||||
Confirm that the service reaches its normal listening state, then check the
|
||||
terminal integrity summary:
|
||||
|
||||
```text
|
||||
Git identifier-family integrity startup pass completed
|
||||
```
|
||||
|
||||
An `unresolved` or `failed` count above zero is accompanied by an `ERROR` log
|
||||
for each affected identifier. This reports pre-existing missing data without
|
||||
putting startup into a network-dependent restart loop.
|
||||
An `unresolved` or `failed` count above zero has a corresponding `ERROR` naming
|
||||
the identifier. The server has already attempted automatic repair. A retained
|
||||
backup protects an unhealthy family's legacy data, and a legacy shallow view
|
||||
continues serving at its pre-upgrade level rather than being made less usable.
|
||||
|
||||
## Check or repair one identifier on demand
|
||||
## Roll back
|
||||
|
||||
Queue a read-only check for every object format and owner/`/prs/` view sharing
|
||||
an identifier:
|
||||
Stop v3 and restore the pre-upgrade Git and relay-data snapshot together before
|
||||
starting v2. Do not point v2 at thin family views, even if ordinary clones
|
||||
appear to work: v2 does not coordinate writes through the shared family or
|
||||
preserve its durability invariants.
|
||||
|
||||
```console
|
||||
ngit-grasp integrity-check \
|
||||
--git-data-path /var/lib/ngit-grasp/git \
|
||||
--identifier example
|
||||
```
|
||||
|
||||
Add `--repair` to repair alternate wiring and try accepted clone servers for
|
||||
missing OIDs:
|
||||
|
||||
```console
|
||||
ngit-grasp integrity-check \
|
||||
--git-data-path /var/lib/ngit-grasp/git \
|
||||
--identifier example \
|
||||
--repair
|
||||
```
|
||||
|
||||
The command queues a durable request for the running relay rather than opening
|
||||
or mutating a family from a second process. The worker normally consumes it
|
||||
within five seconds and writes the result to the service log. A request queued
|
||||
while the relay is stopped is processed after its next startup integrity pass.
|
||||
Repeated requests for the same identifier and mode safely coalesce.
|
||||
|
||||
## Failure and rollback
|
||||
|
||||
- A migration failure is fail-closed. Fix the reported filesystem or Git error
|
||||
and restart; the journal resumes the safe transition.
|
||||
- A post-migration integrity repair failure is fail-open because the damage
|
||||
predates conversion or arose after it. Inspect the identifier's `ERROR` log,
|
||||
repair or update its listed clone sources, and queue `integrity-check
|
||||
--repair` again.
|
||||
- To roll the software release back, stop the service and restore the complete
|
||||
pre-upgrade Git and relay-data snapshot together. Do not point an older
|
||||
binary at migrated thin views.
|
||||
|
||||
There is deliberately no automatic cleanup step. Backup retirement and
|
||||
unreachable-object pruning remain deferred until rollback and delete-state
|
||||
retention have an explicit policy. S3 adoption is a separate optional rollout
|
||||
and is not required for this local storage model.
|
||||
The storage model, automatic shallow-history compatibility repair, and detailed
|
||||
retirement guarantees are described in
|
||||
[Identifier-family Git object storage](../explanation/git-family-object-storage.md#v2-to-v3-migration-boundary).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! checks use the same report.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -94,6 +95,10 @@ pub struct FamilyIntegrityReport {
|
||||
pub missing_oids: BTreeSet<String>,
|
||||
pub missing_ref_targets: Vec<MissingRefTarget>,
|
||||
pub invalid_alternates: Vec<PathBuf>,
|
||||
/// Views still carrying a server-side `shallow` marker. Shallow
|
||||
/// repositories are not valid family storage; the marker is removed by
|
||||
/// repair once the family holds the complete closure of the view's refs.
|
||||
pub shallow_views: Vec<PathBuf>,
|
||||
pub pack_errors: Vec<String>,
|
||||
pub fsck_diagnostics: Vec<String>,
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl FamilyIntegrityReport {
|
||||
self.missing_oids.is_empty()
|
||||
&& self.missing_ref_targets.is_empty()
|
||||
&& self.invalid_alternates.is_empty()
|
||||
&& self.shallow_views.is_empty()
|
||||
&& self.pack_errors.is_empty()
|
||||
&& self.fsck_diagnostics.is_empty()
|
||||
}
|
||||
@@ -318,6 +324,7 @@ async fn run_one_family<S: FamilyRepairSource + ?Sized>(
|
||||
missing_sample,
|
||||
missing_ref_targets = outcome.final_report.missing_ref_targets.len(),
|
||||
invalid_alternates = outcome.final_report.invalid_alternates.len(),
|
||||
shallow_views = outcome.final_report.shallow_views.len(),
|
||||
pack_errors = outcome.final_report.pack_errors.len(),
|
||||
fsck_diagnostics = outcome.final_report.fsck_diagnostics.len(),
|
||||
diagnostic,
|
||||
@@ -425,6 +432,33 @@ pub async fn check_and_repair_family<S: FamilyRepairSource + ?Sized>(
|
||||
storage.advertise_base_tip(key, &source_ref, &target.oid)?;
|
||||
}
|
||||
}
|
||||
// A server-side shallow marker stops being a truncation boundary the
|
||||
// moment the family holds the full closure of the view's refs. Until
|
||||
// then it stays in place so the view keeps serving what it always did.
|
||||
for view in &initial.shallow_views {
|
||||
match family_contains_closure(&storage.family_repo_path(key), view) {
|
||||
Ok(()) => {
|
||||
let marker = view.join("shallow");
|
||||
match std::fs::remove_file(&marker) {
|
||||
Ok(()) => info!(
|
||||
view = %view.display(),
|
||||
"Removed server-side shallow marker after family closure recovery"
|
||||
),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
warn!(view = %view.display(), %error, "Remove shallow marker");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
view = %view.display(),
|
||||
%error,
|
||||
"Keeping server-side shallow marker until the family closure is complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let final_report = inspect_family(storage, key)?;
|
||||
Ok(FamilyRepairOutcome {
|
||||
initial,
|
||||
@@ -515,12 +549,16 @@ pub fn inspect_family(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Fami
|
||||
let mut missing_oids = BTreeSet::new();
|
||||
let mut missing_ref_targets = Vec::new();
|
||||
let mut invalid_alternates = Vec::new();
|
||||
let mut shallow_views = Vec::new();
|
||||
let mut refs_checked = 0;
|
||||
|
||||
for view in &views {
|
||||
if !storage.is_thin_view(key, view) {
|
||||
invalid_alternates.push(view.clone());
|
||||
}
|
||||
if view.join("shallow").is_file() {
|
||||
shallow_views.push(view.clone());
|
||||
}
|
||||
for (reference, oid) in list_refs(view)? {
|
||||
refs_checked += 1;
|
||||
if !oid_exists(&family, &oid)? {
|
||||
@@ -545,11 +583,74 @@ pub fn inspect_family(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Fami
|
||||
missing_oids,
|
||||
missing_ref_targets,
|
||||
invalid_alternates,
|
||||
shallow_views,
|
||||
pack_errors,
|
||||
fsck_diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify the family contains the complete reachable closure of every ref in
|
||||
/// `refs_source` whose target object the family already has.
|
||||
///
|
||||
/// Tips absent from the family altogether are pre-existing missing objects
|
||||
/// and are reported separately; they cannot make a closure walk start.
|
||||
pub(crate) fn family_contains_closure(family: &Path, refs_source: &Path) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(objectname) %(objecttype)"])
|
||||
.current_dir(refs_source)
|
||||
.output()
|
||||
.with_context(|| format!("list refs in {}", refs_source.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"list refs in {}: {}",
|
||||
refs_source.display(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let mut tips = BTreeSet::new();
|
||||
for line in String::from_utf8(output.stdout)?.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(oid), Some(object_type)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
// A ref directly naming a blob has no further closure to walk.
|
||||
if matches!(object_type, "commit" | "tag" | "tree") && oid_exists(family, oid)? {
|
||||
tips.insert(oid.to_owned());
|
||||
}
|
||||
}
|
||||
if tips.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.args(["rev-list", "--objects", "--stdin"])
|
||||
.current_dir(family)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("walk closure in {}", family.display()))?;
|
||||
{
|
||||
let stdin = child.stdin.as_mut().context("git rev-list stdin")?;
|
||||
for tip in &tips {
|
||||
writeln!(stdin, "{tip}")?;
|
||||
}
|
||||
}
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
let diagnostic: String = String::from_utf8_lossy(&output.stderr)
|
||||
.trim()
|
||||
.chars()
|
||||
.take(2048)
|
||||
.collect();
|
||||
return Err(anyhow!(
|
||||
"family {} is missing part of the closure reachable from {}: {diagnostic}",
|
||||
family.display(),
|
||||
refs_source.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn discover_views(storage: &LocalGitStorage, key: &FamilyKey) -> Result<Vec<PathBuf>> {
|
||||
let mut views = Vec::new();
|
||||
let entries = match std::fs::read_dir(storage.git_data_path()) {
|
||||
@@ -886,6 +987,82 @@ mod tests {
|
||||
assert!(!report.is_healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_a_view_with_a_shallow_marker() {
|
||||
let (_temp, storage, key, view, commit) = fixture();
|
||||
fs::write(view.join("shallow"), format!("{commit}\n")).unwrap();
|
||||
|
||||
let report = inspect_family(&storage, &key).unwrap();
|
||||
|
||||
assert_eq!(report.shallow_views, vec![view]);
|
||||
assert!(!report.is_healthy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_removes_shallow_marker_after_closure_recovery() {
|
||||
let (temp, storage, key, view, commit) = fixture();
|
||||
let family = storage.family_repo_path(&key);
|
||||
let child = write_commit(&family, Some(&commit));
|
||||
git(&view, &["update-ref", "refs/heads/main", &child]);
|
||||
storage.retain_tip(&key, "child", &child).unwrap();
|
||||
let source_repo = temp.path().join("source.git");
|
||||
git(
|
||||
temp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"--bare",
|
||||
family.to_str().unwrap(),
|
||||
source_repo.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
git(
|
||||
&source_repo,
|
||||
&["update-ref", "refs/heads/recovery", &commit],
|
||||
);
|
||||
// Truncate the family below the child commit and mark the view as a
|
||||
// legacy server-side shallow repository.
|
||||
let object = family.join("objects").join(&commit[..2]).join(&commit[2..]);
|
||||
fs::remove_file(object).unwrap();
|
||||
fs::write(view.join("shallow"), format!("{child}\n")).unwrap();
|
||||
let before = inspect_family(&storage, &key).unwrap();
|
||||
assert!(!before.is_healthy());
|
||||
assert_eq!(before.shallow_views, vec![view.clone()]);
|
||||
let source = LocalRepairSource {
|
||||
url: source_repo.to_string_lossy().into_owned(),
|
||||
family_objects: storage.family_objects_path(&key),
|
||||
};
|
||||
|
||||
let outcome = check_and_repair_family(&storage, &key, &source, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.repaired(), "{outcome:#?}");
|
||||
assert!(!view.join("shallow").exists());
|
||||
assert!(oid_exists(&family, &commit).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_keeps_shallow_marker_while_closure_is_missing() {
|
||||
let (_temp, storage, key, view, commit) = fixture();
|
||||
let family = storage.family_repo_path(&key);
|
||||
let child = write_commit(&family, Some(&commit));
|
||||
git(&view, &["update-ref", "refs/heads/main", &child]);
|
||||
let object = family.join("objects").join(&commit[..2]).join(&commit[2..]);
|
||||
fs::remove_file(object).unwrap();
|
||||
fs::write(view.join("shallow"), format!("{child}\n")).unwrap();
|
||||
let source = LocalRepairSource {
|
||||
url: String::new(),
|
||||
family_objects: storage.family_objects_path(&key),
|
||||
};
|
||||
|
||||
let outcome = check_and_repair_family(&storage, &key, &source, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!outcome.final_report.is_healthy());
|
||||
assert!(view.join("shallow").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_an_unindexed_family_pack() {
|
||||
let (_temp, storage, key, _view, _commit) = fixture();
|
||||
|
||||
+980
-47
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -126,17 +126,25 @@ impl RelayServer {
|
||||
info!("Database backend: {}", config.database_backend);
|
||||
|
||||
// Upgrade Git storage before any runtime component can inspect or
|
||||
// mutate repositories. Migration is resumable and retains original
|
||||
// repositories as operator-managed rollback backups.
|
||||
// mutate repositories. Migration is resumable; each identifier
|
||||
// family's legacy backups are verified and retired before the next
|
||||
// family converts, so peak disk overhead is bounded by the family
|
||||
// currently in flight. Backups that cannot be verified are retained
|
||||
// as operator-managed rollback material.
|
||||
let git_storage = git::storage::LocalGitStorage::new(config.effective_git_data_path());
|
||||
let migration = git::migration::migrate_on_startup(&git_storage)
|
||||
.await
|
||||
.context("upgrade Git repositories to identifier-family storage")?;
|
||||
if !migration.already_current {
|
||||
if !migration.already_current
|
||||
|| migration.retired_backups > 0
|
||||
|| migration.retained_backups > 0
|
||||
{
|
||||
info!(
|
||||
migrated_views = migration.migrated_views,
|
||||
recovered_views = migration.recovered_views,
|
||||
families_built = migration.families_built,
|
||||
retired_backups = migration.retired_backups,
|
||||
retained_backups = migration.retained_backups,
|
||||
"Git identifier-family storage migration completed"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user