OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / repo-ref-activity.js

180 lines · javascript ref:
/**
 * Replicated last-activity signal for a repo — the ordering key for /repos.
 *
 * ## Why not the filesystem
 *
 * The landing page used to order on hyper-sync's `lastUpdated`, the newest
 * mtime among HEAD, packed-refs and refs/** on the LOCAL disk. That is a
 * property of the machine, not of the repo, so the two backends behind
 * `7rnx.net/git` could not agree on the front page's order by construction.
 * Measured 2026-09-05: identical 135-repo sets on both, and all 135 with a
 * different `lastUpdated` — 67 apart by more than an hour, 61 by more than a
 * day. Three separate ways it drifts:
 *
 *   1. CLONE TIME. A repo nobody has pushed to since import carries the time
 *      that host first fetched it. One bulk-import cohort reads 2026-06-03 on
 *      tabertron and 2026-04-21 on stortron.
 *   2. STALENESS. `sync.repos` is a cache, and a mirror's pulls write inside
 *      an existing `<repo>.git` where the non-recursive repos-dir watcher
 *      cannot see them. See services/sync.js `attachRepoRefresh`.
 *   3. THE DAEMON ITSELF. The 5-minute reconcileAll sweep force-writes refs
 *      from the tracker view onto disk. Materializing a ref bumps its mtime,
 *      so a repo nothing happened to jumps to the top — ifsrenderer-linux went
 *      from rank 20 to rank 1 on stortron with refs/heads/linux-port stamped
 *      at the exact second of the sweep.
 *
 * ## What replaces it
 *
 * The per-repo tracker's ref-view already carries a `timestamp` per ref
 * (`listRefs()` -> `{ ref, hash, updatedBy, timestamp }`), stamped by the
 * writer that ACCEPTED the push and replicated verbatim. Every peer holding
 * the tracker derives the same number, so every backend renders the same
 * order. It also keeps the property the mtime was chosen for: force-pushing
 * an old commit is recent ACTIVITY, and unlike a git committer date it is
 * stamped by the daemon rather than supplied by the committer.
 *
 * ## Where the values come from
 *
 * RefReconciler calls `listRefs()` on every pass anyway — event-driven off the
 * tracker's `update`, plus the sweep — so recording `max(timestamp)` costs no
 * extra autobase work. The manager wires the hook; nothing here talks to an
 * autobase.
 *
 * Coverage is a boot walk: pear-git.js `ensureFor`s every `*.git` in the repos
 * dir at startup and `RefReconciler.start()` reconciles immediately, so every
 * repo with a mounted tracker has a value shortly after boot. A repo with no
 * tracker never gets one and orders on its mtime as before.
 *
 * Storage: <storage>/repo-ref-activity.json
 *   { version: 1, repos: { <name>: <unix ms> } }
 *
 * Persisted for one reason: without it the first render after every restart
 * falls back to mtimes — the exact disagreement this fixes — because the boot
 * walk has not finished before the first visitor arrives. Losing the file is
 * harmless; the next reconcile pass rewrites every entry.
 */

import { readFile, writeFile, rename } from '#node:fs/promises'
import { join } from '#node:path'

const activity = new Map() // repo name -> unix ms

let persistPath = null
let loaded = false
let dirty = false
let saveTimer = null

/**
 * Point the index at a storage dir and load persisted entries. Call once at
 * daemon boot before serving. Idempotent.
 *
 * @param {string} storageDir - daemon storage dir (opts.storage)
 */
export async function configureRefActivity (storageDir) {
  if (!storageDir) return
  persistPath = join(storageDir, 'repo-ref-activity.json')
  if (loaded) return
  loaded = true
  try {
    const obj = JSON.parse(await readFile(persistPath, 'utf8'))
    if (obj && obj.repos && typeof obj.repos === 'object') {
      for (const [name, ms] of Object.entries(obj.repos)) {
        if (typeof ms === 'number' && ms > 0) activity.set(name, ms)
      }
    }
  } catch {
    // No file yet (first boot), unreadable, or corrupt. Starting empty costs
    // one boot's worth of mtime ordering, not correctness.
  }
}

/**
 * Record the newest ref timestamp seen for a repo.
 *
 * MONOTONIC. A later pass can legitimately see an OLDER max than an earlier
 * one — deleting the most recently pushed ref drops that entry out of the
 * view — and letting the value fall back would re-shuffle the page for a repo
 * whose activity plainly did not move backwards in time.
 *
 * @param {string} name - repo name
 * @param {number} ms - unix ms, the max `timestamp` across the repo's refs
 * @returns {boolean} whether the stored value advanced
 */
export function recordRefActivity (name, ms) {
  if (!name || typeof ms !== 'number' || !(ms > 0)) return false
  const prev = activity.get(name)
  if (typeof prev === 'number' && prev >= ms) return false
  activity.set(name, ms)
  dirty = true
  schedulePersist()
  return true
}

/**
 * @param {string} name - repo name
 * @returns {number|null} unix ms, or null when this daemon has no value
 */
export function getRefActivity (name) {
  if (!name) return null
  const ms = activity.get(name)
  return typeof ms === 'number' ? ms : null
}

/**
 * Annotate repo records with `lastRefUpdate` where one is known.
 *
 * A repo with no value is returned UNCHANGED rather than carrying an explicit
 * null, so `repoRecency()` can tell "no replicated signal, use the mtime"
 * apart from "replicated signal of zero".
 *
 * @param {Array<object>} repos - records from sync.status()
 * @returns {Array<object>}
 */
export function mergeRefActivity (repos) {
  if (!Array.isArray(repos)) return []
  return repos.map((r) => {
    const ms = r && r.name ? getRefActivity(r.name) : null
    return ms === null ? r : { ...r, lastRefUpdate: ms }
  })
}

/** Force any pending persist immediately (graceful shutdown + tests). */
export async function flushRefActivity () {
  if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
  await persistNow()
}

/**
 * Drop all process-local state so configureRefActivity() re-reads the file —
 * i.e. model a daemon restart within one process. Test seam only; it does NOT
 * touch the file. (A test cannot get a fresh copy of a module under Bare,
 * which resolves by realpath and ignores an import query — same reason
 * repo-activity.js carries resetRepoActivityCacheForTests.)
 */
export function resetRefActivityForTests () {
  activity.clear()
  persistPath = null
  loaded = false
  dirty = false
  if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
}

function schedulePersist () {
  if (!persistPath || saveTimer) return
  saveTimer = setTimeout(() => { saveTimer = null; persistNow().catch(() => {}) }, 1000)
  if (saveTimer && typeof saveTimer.unref === 'function') saveTimer.unref()
}

async function persistNow () {
  if (!persistPath || !dirty) return
  dirty = false
  const repos = Object.fromEntries(activity)
  const tmp = persistPath + '.tmp'
  try {
    await writeFile(tmp, JSON.stringify({ version: 1, repos }))
    await rename(tmp, persistPath)
  } catch {
    dirty = true // best-effort; retry on the next change
  }
}