OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / deleted-releases.js

139 lines · javascript ref:
/**
 * DeletedReleases — the replicated "stop pulling this version" set.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * THIS MODULE NEVER DELETES ANYTHING, AND MUST NEVER LEARN HOW.
 *
 * A tombstone is metadata: it changes what a peer PULLS, never what is on
 * disk. Removing release bytes is a manual operator action on each peer.
 * Same rule, same reasoning as services/retired-repos.js — read that first.
 * ─────────────────────────────────────────────────────────────────────────
 *
 * WHY THIS EXISTS (prod incident, 2026-08-29): `pgh deploy gc` removed 55
 * favela-android versions on tabertron. The bytes were the only copy, so
 * stortron's ReleasePuller could never satisfy its fetch and logged, forever:
 *
 *   [release-pull] error: no holder for
 *       favela-android/versions/master-8d988ca/favela-android.apk
 *   → fetched 0, replaced 0, skipped 213, errors 1 — PARTIAL, will retry
 *
 * The active public backend crashed (exit 1) and both daemons flapped.
 *
 * The event already existed — ReleaseManager.deleteVersion appends
 * `op: OP_DELETED` to the ReleaseEventLog — but NOTHING consumed it.
 * repo-mirror.js honours the equivalent repo tombstone (OP_RETIRED) via
 * RetiredRepos; the release path had the emitter and no reader. This is the
 * missing reader.
 *
 * DERIVED, NOT STORED. The ReleaseEventLog is append-only and replayed from
 * seq 0 with no persisted cursor, so this set is a pure fold over it —
 * nothing to migrate, rebuilt identically on every boot. Later ops win by
 * sequence number, so a republish (OP_PUBLISHED) after a delete revives the
 * version and a mistake is reversible.
 *
 * ⚠ ROLLOUT GATE: a peer running older code IGNORES the tombstone and keeps
 * requesting the version. Every peer must run this code BEFORE any operator
 * removes release bytes — exactly the ordering that was violated to cause the
 * incident above.
 */

/** ReleaseEventLog op codes — mirrors services/release-event-log.js. */
export const OP_PUBLISHED = 0
export const OP_PROMOTED = 1
export const OP_UNPINNED = 2
export const OP_DELETED = 3

/** Composite key. A version is only meaningful within its repo. */
function key (repo, version) {
  return repo + '/' + version
}

export class DeletedReleases {
  constructor () {
    // "repo\0version" -> { deleted: boolean, seq: number }
    this._state = new Map()
  }

  /**
   * Fold one ReleaseEventLog entry. Later sequence numbers win, so replaying
   * the log from 0 converges regardless of arrival order.
   *
   * Channel-only ops (OP_PROMOTED / OP_UNPINNED) carry an empty version and
   * are ignored — they say nothing about whether bytes exist.
   *
   * @param {object} entry - decoded ReleaseEventLog entry
   * @param {number} [seq] - the entry's log sequence number
   * @returns {boolean} whether the version is deleted AFTER applying this entry
   */
  applyEntry (entry, seq) {
    if (!entry || !entry.repo || !entry.version) return false
    if (entry.op !== OP_DELETED && entry.op !== OP_PUBLISHED) {
      return this.has(entry.repo, entry.version)
    }

    const k = key(entry.repo, entry.version)
    const prev = this._state.get(k)
    const at = typeof seq === 'number' ? seq : (prev ? prev.seq : 0)

    // An out-of-order replay of an OLDER entry must not clobber newer state.
    if (prev && typeof seq === 'number' && seq < prev.seq) return prev.deleted

    const deleted = entry.op === OP_DELETED
    this._state.set(k, { deleted, seq: at })
    return deleted
  }

  /** @returns {boolean} true if this version is currently tombstoned */
  has (repo, version) {
    const e = this._state.get(key(repo, version))
    return !!(e && e.deleted)
  }

  /** @returns {Array<{repo,version}>} currently-tombstoned versions, sorted */
  list () {
    const out = []
    for (const [k, e] of this._state) {
      if (!e.deleted) continue
      const i = k.indexOf('/')
      out.push({ repo: k.slice(0, i), version: k.slice(i + 1) })
    }
    return out.sort((a, b) => (a.repo + a.version).localeCompare(b.repo + b.version))
  }

  get size () {
    return this.list().length
  }

  /**
   * A predicate suitable for handing to planPull(). Bound so it can be passed
   * as a bare function.
   */
  get isDeleted () {
    return (repo, version) => this.has(repo, version)
  }

  /** Drop all state. Used by tests and by a full re-fold. */
  clear () {
    this._state.clear()
  }
}

/**
 * Fold an entire ReleaseEventLog into a DeletedReleases set.
 *
 * Reads to the current end of the log and returns.
 *
 * @param {object} log - a ReleaseEventLog (needs .entries())
 * @param {DeletedReleases} [set]
 * @returns {Promise<DeletedReleases>}
 */
export async function foldDeleted (log, set = new DeletedReleases()) {
  if (!log || typeof log.entries !== 'function') return set
  let seq = 0
  for await (const entry of log.entries({ live: false, start: 0 })) {
    set.applyEntry(entry, seq)
    seq++
  }
  return set
}