OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / ref-reconciler-manager.js

191 lines · javascript ref:
/**
 * RefReconcilerManager — gip-overlay P3e consumer lifecycle.
 *
 * One RefReconciler per repo, lazily started. ensureFor(repo):
 *   - opens the per-repo HyperIssues tracker (the replicated ref + bundle
 *     authority),
 *   - inits the bare repo if it doesn't exist yet (a peer that joined a repo
 *     wants a local materialization),
 *   - wires the overlay-backed ensureObjects (fetch missing objects via the
 *     bundle chain),
 *   - starts a RefReconciler so the bare repo follows the autobase.
 *
 * Called from the join-repo path (a peer materializes what it joined) and the
 * owner publish path (keeps the owner's bare repo consistent with the view —
 * a near no-op there). Idempotent + in-flight-deduped per repo.
 */

import { join } from '#node:path'
import fs from '#node:fs'

import { execFileSync } from '../_compat-exec.js'
import { invalidateRepoActivity } from './repo-activity.js'
import { recordRefActivity } from './repo-ref-activity.js'
import { RefReconciler } from './ref-reconciler.js'
import { makeReconcilingEnsureObjects } from '../git-overlay-fetch.js'
import { ensureExportMarker } from '../repo-access.js'

export class RefReconcilerManager {
  constructor (opts = {}) {
    this._issueRegistry = opts.issueRegistry
    this._overlay = opts.overlay      // HyperOverlayV2
    this._reposDir = opts.reposDir
    // Lazy accessor for the hyper-sync server (created AFTER this manager), so
    // the ensureObjects fallback can punch:// PACK-pull missing git objects.
    this._getSync = typeof opts.getSync === 'function' ? opts.getSync : (() => null)
    // Lazy for the same reason: the retired-set is folded from the RepoEventLog
    // AFTER this manager is constructed.
    this._isRetired = typeof opts.isRetired === 'function' ? opts.isRetired : (() => false)
    this._git = opts.git || execFileSync
    this._log = opts.logger || { warn () {}, info () {} }
    this._recs = new Map()      // repoName -> RefReconciler
    this._starting = new Map()  // repoName -> Promise<RefReconciler>
  }

  get enabled () {
    return !!(this._issueRegistry && this._overlay && this._reposDir)
  }

  async ensureFor (repoName) {
    if (!repoName || !this.enabled) return null

    // TOMBSTONE GATE. This is resurrection path 4 — "residual references:
    // issue-registry-index.json, tracker autobases" in services/retired-repos.js.
    // Opening a tracker here calls getForRepo(), and the block below runs
    // `git init --bare` when the directory is missing, so a retired repo whose
    // tracker is still in the index came straight back on the next boot.
    //
    // Observed on the live fleet: after retiring 16 repos and removing their
    // bytes, a restart re-created exactly the two that had real tracker
    // autobases (nvidia-rtx-godot, pear-git-v2) as empty 124K skeletons —
    // still tombstoned, still listed as retired, and back on disk anyway. The
    // other 14 had no tracker to open and stayed gone.
    //
    // reconcileAllRepoRefs already gates its readdir on isRetired; this path
    // was the one left open.
    if (this._isRetired(repoName)) {
      this._log.info('[ref-reconcile] skipping retired repo ' + repoName)
      return null
    }
    if (this._recs.has(repoName)) return this._recs.get(repoName)
    if (this._starting.has(repoName)) return this._starting.get(repoName)

    const opening = (async () => {
      const tracker = await this._issueRegistry.getForRepo(repoName)
      const repoPath = join(this._reposDir, repoName + '.git')
      if (!fs.existsSync(repoPath)) {
        fs.mkdirSync(this._reposDir, { recursive: true })
        this._git('git', ['init', '--bare', '-q', repoPath], { encoding: 'utf8' })
        // Materializing a repo this peer JOINED. Safe to mark public: an
        // unexported repo is never onboarded, so it never replicates — what we
        // just materialized was already public at the source (#138).
        ensureExportMarker(this._reposDir, repoName)
      }
      const ensureObjects = makeReconcilingEnsureObjects({
        tracker, overlay: this._overlay, repoPath, repoName, getSync: this._getSync, log: this._log
      })
      // Replication-side cache invalidation. A ref that arrives via the
      // autobase view never fires ref-update-observed, which is where
      // invalidateRepoActivity is called on the PUSH path — and materializing
      // a ref does not restamp objects/pack, so the activity cache's mtime key
      // still matches and the repo serves a stale commit count until the
      // backstop TTL. That is exactly what the public backend was doing.
      const rec = new RefReconciler({
        tracker,
        repoPath,
        ensureObjects,
        logger: this._log,
        onChange: () => { invalidateRepoActivity(repoName) },
        // The /repos ordering key. Recorded per pass whether or not the
        // pass changed anything on disk — see repo-ref-activity.js.
        onRefActivity: (ms) => { recordRefActivity(repoName, ms) }
      })
      await rec.start()
      this._recs.set(repoName, rec)
      return rec
    })()
    this._starting.set(repoName, opening)
    try {
      return await opening
    } finally {
      this._starting.delete(repoName)
    }
  }

  /**
   * Self-heal re-attach (#3, Increment 2). After IssueRegistry reopens a repo's
   * tracker under a fresh namespace (because the old one's autobase errored),
   * drop the RefReconciler still bound to the OLD tracker and re-ensure so a
   * fresh one attaches to the new tracker and re-materializes refs from the
   * clean re-synced view. Idempotent.
   */
  async resyncFor (repoName) {
    if (!repoName || !this.enabled) return null
    const old = this._recs.get(repoName)
    if (old) {
      try { await old.stop() } catch {}
      this._recs.delete(repoName)
    }
    this._starting.delete(repoName)
    return this.ensureFor(repoName)
  }

  /**
   * State reconciler — the `repo scan`-style sweep. Walk every known repo,
   * ensure a per-repo RefReconciler exists (which materializes refs + pulls any
   * missing git objects via the overlay/punch ensureObjects), and re-trigger a
   * reconcile pass on already-started ones. Idempotent + cheap when up to date
   * (git fetch is a no-op; the object check short-circuits). Call on boot, on a
   * slow interval, and on demand. Cursor-independent — heals refs-but-no-objects
   * from ANY cause (join-after, failed pull, crash mid-clone). pear-git #3.
   */
  async reconcileAll () {
    if (!this.enabled || !this._issueRegistry) return { scanned: 0 }
    let repos = []
    try { repos = this._issueRegistry.listRepos() || [] } catch { repos = [] }
    let scanned = 0
    let skipped = 0
    for (const repoName of repos) {
      try {
        const existing = this._recs.get(repoName)
        if (existing) {
          // Ask before working. A materialized, quiet repo gains nothing from
          // another listRefs() — the tracker's own `update` event already drives
          // its reconcile — and doing it anyway across every repo is what made
          // the sweep degrade the daemon for ~57s of every 300s (146 repos:
          // 11ms -> ~450ms TTFB, git subprocess count 0 throughout, so the cost
          // is per-tracker autobase work). A reconciler that predates needsPass()
          // is never skipped.
          if (typeof existing.needsPass === 'function' && !existing.needsPass()) {
            skipped++
          } else {
            await existing.reconcile()
            scanned++
          }
        } else {
          await this.ensureFor(repoName) // starts + reconciles once
          scanned++
        }
      } catch (err) {
        this._log.warn('reconcileAll ' + repoName + ': ' + (err && err.message))
      }
      // Yield between repos. Each pass costs a per-repo `base.update()` plus
      // git plumbing; run back-to-back across N repos that saturates the loop
      // for the whole sweep, and every in-flight HTTP request waits it out
      // (measured: 139 repos => ~27s during which the web UI served nothing).
      // This is a background SAFETY NET — per-repo reconcile is already
      // event-driven off each tracker's `update` — so latency here is free,
      // and handing the loop back between repos keeps the daemon responsive.
      await new Promise((resolve) => setImmediate(resolve))
    }
    return { scanned, skipped }
  }

  async closeAll () {
    for (const rec of this._recs.values()) {
      try { await rec.stop() } catch {}
    }
    this._recs.clear()
  }
}