From fbee2d9f3b6075e5f6f06a67f26dac5c315bbfb5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 16 Sep 2026 11:01:31 -0700 Subject: [PATCH] feat: git locks doctor, a read-only invariant check of the store One finding line per broken invariant as it is found, then one doctor line with the store, the reading basis (refs and records in the one snapshot, and the clock), the checks run, the count and the verdict. Invariants: every job record decodes and names its own job; every path a record lists has a path ref at that record; every path ref points at a record some job ref points at, and that record lists the path; every child's parent exists, is live and has the same holder, and no parent chain cycles; every semaphore has meta and gen, its records decode, and its live slots fit its capacity. Exit 0 healthy, 1 with findings, 2 when the store cannot be read, which is never reported healthy. Paths are hashed in one hash-object process, so the process count does not grow with the store. Nothing is repaired. Version 0.5.0. Also: field_v and json_paths_v no longer declare locals a caller could name (a local named rest swallowed the caller's rest, the same trap CONTRIBUTING already records for write_blob). Closes #19 --- CHANGELOG.md | 6 + README.md | 3 + bin/git-locks | 330 +++++++++++++++++++++++++++++++++-- lib/000-prelude.sh | 7 +- lib/010-json.sh | 18 +- lib/050-the-snapshot.sh | 12 +- lib/175-doctor.sh | 289 ++++++++++++++++++++++++++++++ lib/990-main.sh | 2 +- schema/git-locks.schema.json | 123 +++++++++++++ test/test.sh | 169 ++++++++++++++++++ 10 files changed, 924 insertions(+), 35 deletions(-) create mode 100644 lib/175-doctor.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad9b70..af8a789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are recorded here. The format follows Keep a ## [Unreleased] +## [0.5.0] - 2026-09-16 + +### Added + +- `git locks doctor`: a read-only invariant check of the store (#19). One `finding` line per broken invariant as it is found, then one `doctor` line with the store, the reading basis (refs and records in the one snapshot it read, and the clock), the checks run, the count and the verdict. The invariants: every job record decodes and names its own job; every path a record lists has a path ref pointing at that record; every path ref points at a record some job ref points at, and that record lists the path; every child's parent exists, is live and has the same holder, and no parent chain cycles; every semaphore has meta and gen, its records decode, and its live slots fit its capacity. Exit 0 healthy, 1 with findings, 2 when the store cannot be read, which is never reported healthy. Paths are hashed in one `hash-object` process, so the process count does not grow with the store. Nothing is repaired: diagnosis is the whole command. + ## [0.4.0] - 2026-09-16 ### Changed diff --git a/README.md b/README.md index b583123..15e568c 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,8 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat **What a failed read is.** An error, never a free path. If `for-each-ref` or `cat-file` fails, or an object does not parse, the command exits 2 with `{"event":"error","reason":"store-read"}` and reports nothing as free or held. +**What the invariants are, and how to see them hold.** `git locks doctor` reads one snapshot and checks it, writing nothing: every job record decodes and names its own job; every path a record lists has a path ref pointing at that record; every path ref points at a record some job ref points at, and that record lists the path; every child's parent exists, is live and has the same holder, and no parent chain cycles; every semaphore has its meta and gen refs, its records decode, and its live slots fit its capacity. Each broken invariant is one `finding` line as it is found, and the last line states the basis it was checked against, the refs and records of that one snapshot and the clock, so a clean report says what was clean. An unreadable store is an error, never healthy. Repair is not a mode of this command; when a finding needs a hand, the fix is a `release`, a `sweep`, or an explicit `update-ref` on the store by someone who has read the finding. + **What the tests are.** A contract with bounded conformance evidence, not a proof. The race tests show one winner among twenty racers and three among twenty on capacity three, in those runs. The interleaving that let a child survive its parent's release is forced deterministically with `GIT_LOCKS_PAUSE_BEFORE_COMMIT`, a test-only gate that makes a transaction wait for a file before committing, and the invariant is asserted on the resulting store. ## How it was built, including the missteps worth keeping @@ -398,6 +400,7 @@ Output is JSON Lines on every command; there is no text mode. | `with --job --holder [--ttl ] [--wait ] [--parent ] ... -- ...` | claim, run the command, release; `--wait` retries once a second until the paths are free or the wait runs out | the command's own stdout; git-locks' `claimed`, `released` and refusals go to **stderr** | the command's exit status; 1 if never acquired; 130/143 on INT/TERM after releasing | | `version` | tool name and version | one object | 0 | | `schema` | the JSON Schema every line above conforms to | the schema document | 0 | +| `doctor` | read-only invariant check of the store; nothing is repaired | one `finding` object per broken invariant as it is found, then one `doctor` object with the basis (refs, records, clock), the checks run and the verdict | 0 healthy, 1 with findings, 2 if the store cannot be read | | `sem create --capacity ` | a semaphore with n slots | one `created` object | 0, 1 if it exists | | `sem acquire --job --holder [--ttl ] [--wait ]` | take a slot; re-acquiring refreshes the job's own slot; `--wait` retries once a second | one `acquired` object with `live` and `capacity` | 0, 1 when full | | `sem release --job ` | give the slot back | one `released` or `nothing` object | 0 | diff --git a/bin/git-locks b/bin/git-locks index f6f034b..5f50fd9 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -60,7 +60,7 @@ DEFAULT_TTL=14400 SCHEMA='git-locks/1' SEM_SCHEMA='git-locks-sem/1' SLOT_SCHEMA='git-locks-slot/1' -VERSION='0.4.0' +VERSION='0.5.0' RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times NOW_CACHED='' # the clock, read once per invocation by now() @@ -79,6 +79,7 @@ usage: git locks claim --job --holder [--ttl ] [--parent git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete + git locks doctor git locks version git locks help | schema @@ -104,6 +105,9 @@ with claim, run the command, release the acquisition it made (also on failur sem capacity, not exclusivity: up to jobs hold a named semaphore at once; a slot expires like a lock; acquire is one transaction with a compare-and-swap on the semaphore's generation, so racers beyond capacity fail and exactly win +doctor read-only invariant check of the store: one finding line per problem, then a doctor line with the + basis (refs and records read, the clock) and the verdict; exit 1 on findings, 2 when the store + cannot be read (an unreadable store is never healthy). Diagnosis only: nothing is repaired schema print the JSON Schema every output line conforms to output: JSON Lines, always: one object per result on stdout, written as each result is known; @@ -150,6 +154,7 @@ sub_usage_text() { ttl) printf 'usage: git locks ttl --job \n' ;; extend) printf 'usage: git locks extend --job --ttl \n' ;; with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ...\n' ;; + doctor) printf 'usage: git locks doctor\n' ;; sem) printf 'usage: git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete \n' ;; *) usage_text ;; esac @@ -192,18 +197,18 @@ json_paths() { # VAR: set VAR to a JSON array of the lines on stdin printf -v "$1" '[%s]' "${items[*]}" } -json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork - local text="$2" line items=() one IFS - while [[ -n "${text}" ]]; do - line="${text%%$'\n'*}" - if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi - if [[ -n "${line}" ]]; then - json_str one "${line}" - items+=("${one}") +json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork (locals underscored so none can shadow VAR) + local _jp_text="$2" _jp_line _jp_items=() _jp_one IFS + while [[ -n "${_jp_text}" ]]; do + _jp_line="${_jp_text%%$'\n'*}" + if [[ "${_jp_line}" == "${_jp_text}" ]]; then _jp_text=''; else _jp_text="${_jp_text#*$'\n'}"; fi + if [[ -n "${_jp_line}" ]]; then + json_str _jp_one "${_jp_line}" + _jp_items+=("${_jp_one}") fi done IFS=',' - printf -v "$1" '[%s]' "${items[*]}" + printf -v "$1" '[%s]' "${_jp_items[*]}" } json_jobs() { # VAR job... -> JSON array of job ids @@ -440,16 +445,16 @@ ref_oid() { # ref -> oid or empty field_v() { # VAR oid key: set VAR to the value of `key:` in the record's header (before paths:), empty when absent; no fork, so the parse memoises in this shell ensure_snapshot parse_record "$2" - local fields="${R_FIELDS[$2]:-}" rest - if [[ "${fields}" == "$3"$'\x1f'* ]]; then - rest="${fields#"$3"$'\x1f'}" - elif [[ "${fields}" == *$'\x1e'"$3"$'\x1f'* ]]; then - rest="${fields#*$'\x1e'"$3"$'\x1f'}" + local _fv_fields="${R_FIELDS[$2]:-}" _fv_rest # underscored: a local named like the caller's VAR would swallow the printf -v + if [[ "${_fv_fields}" == "$3"$'\x1f'* ]]; then + _fv_rest="${_fv_fields#"$3"$'\x1f'}" + elif [[ "${_fv_fields}" == *$'\x1e'"$3"$'\x1f'* ]]; then + _fv_rest="${_fv_fields#*$'\x1e'"$3"$'\x1f'}" else printf -v "$1" '' return 0 fi - printf -v "$1" '%s' "${rest%%$'\x1e'*}" + printf -v "$1" '%s' "${_fv_rest%%$'\x1e'*}" } field() { # oid key -> the value on stdout; inside $(…) the parse happens in the subshell, so hot paths use field_v @@ -1817,11 +1822,300 @@ cmd_sem() { *) usage ;; esac } +# ---------------------------------------------------------------- doctor +# +# A read-only invariant check over one snapshot. Every finding is one line +# as it is found; the last line states the basis (how many refs and records +# were read, at what clock) and the verdict. A store that cannot be read is +# a store-read error, exit 2, never "healthy". Diagnosis only: nothing here +# writes, and repair, if it ever exists, is a separate explicit command. + +DOC_FINDINGS=0 +DOC_CHECKS='"record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"' + +finding() { # check subject detail -> one finding line on stdout + local _j1 _j2 _j3 + json_str _j1 "$1" + json_str _j2 "$2" + json_str _j3 "$3" + printf '{"event":"finding","check":%s,"subject":%s,"detail":%s}\n' "${_j1}" "${_j2}" "${_j3}" + DOC_FINDINGS=$((DOC_FINDINGS + 1)) +} + +is_int() { [[ "$1" =~ ^[0-9]+$ ]]; } + +doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: each path becomes a file, hash-object hashes them all + local dir p i=0 files=() todo=() out h rc + for p in "$@"; do + [[ -n "${PATH_HASH[${p}]+x}" ]] && continue + in_list "${p}" "${todo[@]}" && continue + todo+=("${p}") + done + ((${#todo[@]} > 0)) || return 0 + dir="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-doctor.XXXXXX")" || fail 'cannot create a temporary directory for hashing' 2 + for p in "${todo[@]}"; do + printf '%s' "${p}" >"${dir}/${i}" + files+=("${dir}/${i}") + i=$((i + 1)) + done + out="$(g hash-object --no-filters "${files[@]}" 2>&1)" # --no-filters: the same bytes path_ref hashes from stdin + rc=$? + rm -rf "${dir}" + ((rc == 0)) || store_error "hash-object exited ${rc}: ${out}" + i=0 + while IFS= read -r h; do + [[ -z "${h}" ]] && continue + valid_oid "${h}" || store_error "hash-object line does not parse: ${h}" + PATH_HASH["${todo[${i}]}"]="${h}" + i=$((i + 1)) + done <<<"${out}" + ((i == ${#todo[@]})) || store_error "hash-object returned ${i} hashes for ${#todo[@]} paths" +} + +doctor_lock_record() { # subject oid -> 0 when the record decodes as a lock record, else findings and 1 + local schema job holder claimed expires acq paths bad=0 + field_v schema "$2" schema + if [[ "${schema}" != "${SCHEMA}" ]]; then + finding record-decodes "$1" "record ${2} has schema '${schema}', not ${SCHEMA}" + return 1 + fi + field_v job "$2" job + valid_job "${job}" || { + finding record-decodes "$1" "record ${2} has no valid job id" + bad=1 + } + field_v holder "$2" holder + [[ -n "${holder}" ]] || { + finding record-decodes "$1" "record ${2} has no holder" + bad=1 + } + field_v claimed "$2" claimed + is_int "${claimed}" || { + finding record-decodes "$1" "record ${2} has no numeric claimed" + bad=1 + } + field_v expires "$2" expires + is_int "${expires}" || { + finding record-decodes "$1" "record ${2} has no numeric expires" + bad=1 + } + field_v acq "$2" acquisition + [[ -n "${acq}" ]] || { + finding record-decodes "$1" "record ${2} has no acquisition id" + bad=1 + } + record_paths_v paths "$2" + [[ -n "${paths}" ]] || { + finding record-decodes "$1" "record ${2} lists no paths" + bad=1 + } + return "${bad}" +} + +doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a slot of that semaphore for that job + local schema v bad=0 + field_v schema "$2" schema + if [[ "${schema}" != "${SLOT_SCHEMA}" ]]; then + finding sem-record "$1" "slot record ${2} has schema '${schema}', not ${SLOT_SCHEMA}" + return 1 + fi + field_v v "$2" semaphore + [[ "${v}" == "$3" ]] || { + finding sem-record "$1" "slot record ${2} names semaphore '${v}'" + bad=1 + } + field_v v "$2" job + [[ "${v}" == "$4" ]] || { + finding sem-record "$1" "slot record ${2} names job '${v}'" + bad=1 + } + field_v v "$2" holder + [[ -n "${v}" ]] || { + finding sem-record "$1" "slot record ${2} has no holder" + bad=1 + } + field_v v "$2" claimed + is_int "${v}" || { + finding sem-record "$1" "slot record ${2} has no numeric claimed" + bad=1 + } + field_v v "$2" expires + is_int "${v}" || { + finding sem-record "$1" "slot record ${2} has no numeric expires" + bad=1 + } + return "${bad}" +} + +cmd_doctor() { + (($# == 0)) || usage + ensure_snapshot + local rows ref oid job name rest at refs_n=0 recs_n="${#BLOB[@]}" + local -A JOB_OID=() OID_JOBS=() PATHREF_OID=() EXPECTED_PATHREF=() JOB_OK=() + local -A SEM_META=() SEM_GEN=() SEM_SLOT_OIDS=() SEM_SLOT_JOBS=() SEM_NAMES=() + local jobs=() sems=() pathrefs=() all_paths=() p paths + now_v at + rows="$(refs_under "${NS}/")" # sorted, so findings come in a stable order + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + refs_n=$((refs_n + 1)) + case "${ref}" in + "${NS}"/jobs/*) + job="${ref#"${NS}"/jobs/}" + JOB_OID["${job}"]="${oid}" + OID_JOBS["${oid}"]+="${job} " + jobs+=("${job}") + ;; + "${NS}"/paths/*) + PATHREF_OID["${ref}"]="${oid}" + pathrefs+=("${ref}") + ;; + "${NS}"/sem/*) + rest="${ref#"${NS}"/sem/}" + name="${rest%%/*}" + rest="${rest#*/}" + if [[ -z "${SEM_NAMES[${name}]+x}" ]]; then + SEM_NAMES["${name}"]=1 + sems+=("${name}") + fi + case "${rest}" in + meta) SEM_META["${name}"]="${oid}" ;; + gen) SEM_GEN["${name}"]="${oid}" ;; + slots/*) + SEM_SLOT_OIDS["${name}"]+="${oid} " + SEM_SLOT_JOBS["${name}"]+="${rest#slots/} " + ;; + *) finding unknown-ref "${ref}" 'a semaphore ref that is not meta, gen or a slot' ;; + esac + ;; + *) finding unknown-ref "${ref}" 'a ref in the namespace that is not a job, path or semaphore ref' ;; + esac + done <<<"${rows}" + + # Job records decode and name their own job; collect every path they list. + for job in "${jobs[@]}"; do + oid="${JOB_OID[${job}]}" + doctor_lock_record "${job}" "${oid}" || continue + JOB_OK["${job}"]=1 + field_v rest "${oid}" job + [[ "${rest}" == "${job}" ]] || finding job-ref-name "${job}" "the job ref points at a record for job '${rest}'" + record_paths_v paths "${oid}" + while [[ -n "${paths}" ]]; do + p="${paths%%$'\n'*}" + if [[ "${p}" == "${paths}" ]]; then paths=''; else paths="${paths#*$'\n'}"; fi + [[ -n "${p}" ]] && all_paths+=("${p}") + done + done + doctor_hash_paths "${all_paths[@]}" + + # Every listed path has a path ref pointing at this record; every path ref is listed by the record it points at. + local have key + for job in "${jobs[@]}"; do + [[ -n "${JOB_OK[${job}]+x}" ]] || continue + oid="${JOB_OID[${job}]}" + record_paths_v paths "${oid}" + while [[ -n "${paths}" ]]; do + p="${paths%%$'\n'*}" + if [[ "${p}" == "${paths}" ]]; then paths=''; else paths="${paths#*$'\n'}"; fi + [[ -n "${p}" ]] || continue + ref="${NS}/paths/${PATH_HASH[${p}]}" + have="${PATHREF_OID[${ref}]:-}" + if [[ -z "${have}" ]]; then + finding path-ref-missing "${job}" "no path ref for '${p}': a claim on it would not see this lock" + elif [[ "${have}" != "${oid}" ]]; then + finding path-ref-elsewhere "${job}" "the path ref for '${p}' points at record ${have} (job ${OID_JOBS[${have}]:-of no job ref})" + fi + EXPECTED_PATHREF["${ref}|${oid}"]=1 # this record lists a path hashing to this ref + done + done + for ref in "${pathrefs[@]}"; do # in ref order, from the sorted rows + oid="${PATHREF_OID[${ref}]}" + if [[ -z "${OID_JOBS[${oid}]+x}" ]]; then + finding path-ref-orphan "${ref}" "points at record ${oid}, which no job ref points at: the path reads as held by nothing a release can name" + elif key="${ref}|${oid}" && [[ -z "${EXPECTED_PATHREF[${key}]+x}" ]]; then + finding path-ref-stray "${ref}" "points at record ${oid} (job ${OID_JOBS[${oid}]% }) which lists no path hashing to this ref" + fi + done + + # Families: the parent exists, is live, has the same holder, and the chain has no cycle. + local parent pexp pholder holder cur steps + for job in "${jobs[@]}"; do + [[ -n "${JOB_OK[${job}]+x}" ]] || continue + oid="${JOB_OID[${job}]}" + field_v parent "${oid}" parent + [[ -n "${parent}" ]] || continue + if [[ -z "${JOB_OID[${parent}]+x}" ]]; then + finding parent-missing "${job}" "names parent '${parent}', which has no job ref: a child cannot outlive its parent" + continue + fi + field_v pexp "${JOB_OID[${parent}]}" expires + ((${pexp:-0} > at)) || finding parent-expired "${job}" "parent '${parent}' expired at ${pexp:-0}; sweep removes both" + field_v holder "${oid}" holder + field_v pholder "${JOB_OID[${parent}]}" holder + [[ "${holder}" == "${pholder}" ]] || finding parent-holder "${job}" "held by '${holder}' but parent '${parent}' is held by '${pholder}'" + cur="${parent}" + steps=0 + while [[ -n "${cur}" && -n "${JOB_OID[${cur}]+x}" ]] && ((steps <= ${#jobs[@]})); do + if [[ "${cur}" == "${job}" ]]; then + finding family-cycle "${job}" "its parent chain returns to itself" + break + fi + field_v cur "${JOB_OID[${cur}]}" parent + steps=$((steps + 1)) + done + done + + # Semaphores: meta and gen exist, records decode, live slots fit the capacity. + local cap live sjob soid slot_oids slot_jobs exp + for name in "${sems[@]}"; do + if [[ -z "${SEM_META[${name}]+x}" ]]; then + finding sem-meta "${name}" 'no meta ref: the semaphore has no capacity' + cap='' + else + field_v rest "${SEM_META[${name}]}" schema + if [[ "${rest}" != "${SEM_SCHEMA}" ]]; then + finding sem-record "${name}" "meta record ${SEM_META[${name}]} has schema '${rest}', not ${SEM_SCHEMA}" + cap='' + else + field_v cap "${SEM_META[${name}]}" capacity + if ! is_int "${cap}" || ((cap < 1)); then + finding sem-record "${name}" "meta record ${SEM_META[${name}]} has capacity '${cap}'" + cap='' + fi + field_v rest "${SEM_META[${name}]}" semaphore + [[ "${rest}" == "${name}" ]] || finding sem-record "${name}" "meta record names semaphore '${rest}'" + fi + fi + [[ -n "${SEM_GEN[${name}]+x}" ]] || finding sem-gen "${name}" 'no gen ref: acquire and release cannot compare-and-swap' + live=0 + slot_oids="${SEM_SLOT_OIDS[${name}]:-}" + slot_jobs="${SEM_SLOT_JOBS[${name}]:-}" + while [[ -n "${slot_oids}" ]]; do + soid="${slot_oids%% *}" + slot_oids="${slot_oids#* }" + sjob="${slot_jobs%% *}" + slot_jobs="${slot_jobs#* }" + doctor_slot_record "${name}/${sjob}" "${soid}" "${name}" "${sjob}" || continue + field_v exp "${soid}" expires + ((exp > at)) && live=$((live + 1)) + done + if [[ -n "${cap}" ]] && ((live > cap)); then + finding sem-capacity "${name}" "${live} live slots over a capacity of ${cap}" + fi + done + + local _j1 healthy=true + ((DOC_FINDINGS == 0)) || healthy=false + json_str _j1 "${STORE}" + printf '{"event":"doctor","store":%s,"basis":{"refs":%s,"records":%s,"now":%s},"checks":[%s],"findings":%s,"healthy":%s}\n' \ + "${_j1}" "${refs_n}" "${recs_n}" "${at}" "${DOC_CHECKS}" "${DOC_FINDINGS}" "${healthy}" + ((DOC_FINDINGS == 0)) +} # ---------------------------------------------------------------- schema, store, main cmd_schema() { # the public output schema, one JSON line; the pretty form is schema/git-locks.schema.json in the repository (($# == 0)) || usage cat <<'EOF' -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://raw.githubusercontent.com/git-stunts/locks/main/schema/git-locks.schema.json","title":"git-locks output","description":"Every line git-locks writes, on stdout or stderr, is one JSON object matching exactly one of the definitions below. Lines are emitted as each result is known (JSON Lines); nothing is buffered into an array. There is no plain-text mode: help is a usage object, errors are error objects, and `git locks schema` prints this document as one line. The one exception: a command wrapped by `with` owns stdout, and git-locks reports around it on stderr.","oneOf":[{"$ref":"#/$defs/check_line"},{"$ref":"#/$defs/list_line"},{"$ref":"#/$defs/claim_line"},{"$ref":"#/$defs/refusal_line"},{"$ref":"#/$defs/release_line"},{"$ref":"#/$defs/sweep_line"},{"$ref":"#/$defs/store_line"},{"$ref":"#/$defs/version_line"},{"$ref":"#/$defs/ttl_line"},{"$ref":"#/$defs/extend_line"},{"$ref":"#/$defs/missing_line"},{"$ref":"#/$defs/sem_line"},{"$ref":"#/$defs/sem_event_line"},{"$ref":"#/$defs/error_line"},{"$ref":"#/$defs/usage_line"}],"$defs":{"job":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*$","description":"The job id, as given to --job. Names the lock: refs/locks/jobs/."},"holder":{"type":"string","minLength":1,"description":"Whoever claimed, as given to --holder. Free text, one line."},"path":{"type":"string","minLength":1,"description":"A repo-relative path, normalised: no leading ./, never absolute, never containing a .. component or a newline."},"epoch":{"type":"integer","minimum":0,"description":"Seconds since the Unix epoch, UTC."},"check_line":{"type":"object","description":"One line per path from `git locks check`, in argument order, as each is examined.","required":["path","state"],"properties":{"path":{"$ref":"#/$defs/path"},"state":{"enum":["free","held","expired"]},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false,"if":{"properties":{"state":{"const":"free"}}},"then":{"required":["path","state"],"not":{"anyOf":[{"required":["holder"]},{"required":["job"]},{"required":["expires"]},{"required":["remaining"]}]}},"else":{"required":["path","state","holder","job","expires","remaining"]}},"list_line":{"type":"object","description":"One line per lock from `git locks list`, live or expired, in ref order; also the single line of `git locks show --job `.","required":["job","holder","state","claimed","expires","remaining","paths","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"state":{"enum":["live","expired"]},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"uniqueItems":true},"remaining":{"$ref":"#/$defs/remaining"},"parent":{"$ref":"#/$defs/job","description":"Present when the lock is a child: it is released or swept with this job."},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"claim_line":{"type":"object","description":"The single stdout line of a successful `git locks claim`; one per record for `git locks batch`.","required":["event","job","holder","claimed","expires","paths","record","acquisition"],"properties":{"event":{"const":"claimed"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"minItems":1,"uniqueItems":true},"parent":{"$ref":"#/$defs/job"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"refusal_line":{"type":"object","description":"On stderr, one line per problem when a claim or batch is refused (exit 1): a held path naming its holder; a parent that is missing, expired or another holder's; a path named by two records of one batch; or a transaction failure with git's message. For semaphores: capacity (full), exists (create), live (delete with live slots).","oneOf":[{"required":["event","path","holder","job","expires"],"properties":{"event":{"const":"refused"},"path":{"$ref":"#/$defs/path"},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},{"required":["event","reason","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"transaction"},"detail":{"type":"string"}},"additionalProperties":false},{"required":["event","reason","job","parent","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"parent"},"job":{"$ref":"#/$defs/job"},"parent":{"$ref":"#/$defs/job"},"detail":{"enum":["missing","expired","holder"],"description":"Why the parent cannot be used: no such lock, it has expired, or it belongs to another holder."}},"additionalProperties":false},{"required":["event","reason","path"],"properties":{"event":{"const":"refused"},"reason":{"const":"duplicate"},"path":{"$ref":"#/$defs/path"}},"additionalProperties":false},{"required":["event","reason","semaphore","capacity","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"capacity"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false},{"required":["event","reason","semaphore"],"properties":{"event":{"const":"refused"},"reason":{"const":"exists"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","reason","semaphore","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"live"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false}]},"release_line":{"type":"object","description":"One stdout line per --job of `git locks release`: what was released (with any descendants), or that there was nothing to release.","oneOf":[{"required":["event","job","paths"],"properties":{"event":{"const":"released"},"job":{"$ref":"#/$defs/job"},"paths":{"type":"integer","minimum":0,"description":"How many path refs were deleted, descendants included."},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs released in the same transaction, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job"],"properties":{"event":{"const":"nothing"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded","description":"Present when --acquisition or --record named an acquisition the job no longer holds: nothing was released."}},"additionalProperties":false}]},"sweep_line":{"type":"object","description":"One stdout line per expired lock `git locks sweep` deleted; on stderr, one per lock it could not delete because it changed underneath.","oneOf":[{"required":["event","job","holder","expires"],"properties":{"event":{"const":"swept"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"expires":{"$ref":"#/$defs/epoch"},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs swept with this expired parent, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job","reason"],"properties":{"event":{"const":"skipped"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"changed underneath"}},"additionalProperties":false}]},"store_line":{"type":"object","description":"The single line of `git locks store`: the absolute path of the store this repository resolves to.","required":["store"],"properties":{"store":{"type":"string","minLength":1}},"additionalProperties":false},"version_line":{"type":"object","description":"The single line of `git locks version`.","required":["name","version"],"properties":{"name":{"const":"git-locks"},"version":{"type":"string","pattern":"^[0-9]+\\.[0-9]+\\.[0-9]+$"}},"additionalProperties":false},"remaining":{"type":"integer","minimum":0,"description":"Seconds until the lock expires, 0 once it has."},"ttl_line":{"type":"object","description":"The single line of `git locks ttl --job `.","required":["job","expires","remaining"],"properties":{"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false},"extend_line":{"type":"object","description":"The single line of `git locks extend --job --ttl `: the new expiry.","required":["event","job","expires"],"properties":{"event":{"const":"extended"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"missing_line":{"type":"object","description":"On stderr, from show, ttl or extend, when no lock exists for the job (exit 1).","required":["event","job"],"properties":{"event":{"const":"missing"},"job":{"$ref":"#/$defs/job"}},"additionalProperties":false},"sem_line":{"type":"object","description":"One line per semaphore from `git locks sem list`, and the single line of `git locks sem show `: capacity, live count, and the live slots.","required":["semaphore","capacity","live","slots"],"properties":{"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"slots":{"type":"array","items":{"type":"object","required":["job","holder","claimed","expires","remaining","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false}}},"additionalProperties":false},"sem_event_line":{"type":"object","description":"Semaphore lifecycle lines: created, acquired (with the live count after), released (likewise), nothing (the job held no slot), deleted.","oneOf":[{"required":["event","semaphore","capacity"],"properties":{"event":{"const":"created"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job","holder","claimed","expires","live","capacity","record","acquisition"],"properties":{"event":{"const":"acquired"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},{"required":["event","semaphore","job","live","capacity"],"properties":{"event":{"const":"released"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job"],"properties":{"event":{"const":"nothing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded"}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"deleted"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"missing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false}]},"record":{"type":"string","pattern":"^[0-9a-f]{40}([0-9a-f]{24})?$","description":"The object id of this acquisition's record: the identity a later release or renewal names."},"error_line":{"type":"object","description":"On stderr: a usage failure (exit 2), a store that could not be read (exit 2; nothing is reported free or held), or a failed operation (exit 1).","required":["event","reason","detail"],"properties":{"event":{"const":"error"},"reason":{"enum":["usage","store-read","failed"]},"detail":{"type":"string"}},"additionalProperties":false},"usage_line":{"type":"object","description":"`git locks help` on stdout (exit 0), ` --help`, or a usage error on stderr (exit 2): the usage text as one string.","required":["event","usage"],"properties":{"event":{"const":"usage"},"usage":{"type":"string"}},"additionalProperties":false},"acquisition":{"type":"string","minLength":1,"description":"The identity of one acquisition. Minted by a claim (and by a re-claim, which is a new acquisition), kept by extend and by a child admission's rewrite of the parent record, so a caller can release the acquisition it made even after renewals. Distinct from record, the oid of the current version of its record."}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://raw.githubusercontent.com/git-stunts/locks/main/schema/git-locks.schema.json","title":"git-locks output","description":"Every line git-locks writes, on stdout or stderr, is one JSON object matching exactly one of the definitions below. Lines are emitted as each result is known (JSON Lines); nothing is buffered into an array. There is no plain-text mode: help is a usage object, errors are error objects, and `git locks schema` prints this document as one line. The one exception: a command wrapped by `with` owns stdout, and git-locks reports around it on stderr.","oneOf":[{"$ref":"#/$defs/check_line"},{"$ref":"#/$defs/list_line"},{"$ref":"#/$defs/claim_line"},{"$ref":"#/$defs/refusal_line"},{"$ref":"#/$defs/release_line"},{"$ref":"#/$defs/sweep_line"},{"$ref":"#/$defs/store_line"},{"$ref":"#/$defs/version_line"},{"$ref":"#/$defs/ttl_line"},{"$ref":"#/$defs/extend_line"},{"$ref":"#/$defs/missing_line"},{"$ref":"#/$defs/sem_line"},{"$ref":"#/$defs/sem_event_line"},{"$ref":"#/$defs/finding_line"},{"$ref":"#/$defs/doctor_line"},{"$ref":"#/$defs/error_line"},{"$ref":"#/$defs/usage_line"}],"$defs":{"job":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*$","description":"The job id, as given to --job. Names the lock: refs/locks/jobs/."},"holder":{"type":"string","minLength":1,"description":"Whoever claimed, as given to --holder. Free text, one line."},"path":{"type":"string","minLength":1,"description":"A repo-relative path, normalised: no leading ./, never absolute, never containing a .. component or a newline."},"epoch":{"type":"integer","minimum":0,"description":"Seconds since the Unix epoch, UTC."},"check_line":{"type":"object","description":"One line per path from `git locks check`, in argument order, as each is examined.","required":["path","state"],"properties":{"path":{"$ref":"#/$defs/path"},"state":{"enum":["free","held","expired"]},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false,"if":{"properties":{"state":{"const":"free"}}},"then":{"required":["path","state"],"not":{"anyOf":[{"required":["holder"]},{"required":["job"]},{"required":["expires"]},{"required":["remaining"]}]}},"else":{"required":["path","state","holder","job","expires","remaining"]}},"list_line":{"type":"object","description":"One line per lock from `git locks list`, live or expired, in ref order; also the single line of `git locks show --job `.","required":["job","holder","state","claimed","expires","remaining","paths","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"state":{"enum":["live","expired"]},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"uniqueItems":true},"remaining":{"$ref":"#/$defs/remaining"},"parent":{"$ref":"#/$defs/job","description":"Present when the lock is a child: it is released or swept with this job."},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"claim_line":{"type":"object","description":"The single stdout line of a successful `git locks claim`; one per record for `git locks batch`.","required":["event","job","holder","claimed","expires","paths","record","acquisition"],"properties":{"event":{"const":"claimed"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"minItems":1,"uniqueItems":true},"parent":{"$ref":"#/$defs/job"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"refusal_line":{"type":"object","description":"On stderr, one line per problem when a claim or batch is refused (exit 1): a held path naming its holder; a parent that is missing, expired or another holder's; a path named by two records of one batch; or a transaction failure with git's message. For semaphores: capacity (full), exists (create), live (delete with live slots).","oneOf":[{"required":["event","path","holder","job","expires"],"properties":{"event":{"const":"refused"},"path":{"$ref":"#/$defs/path"},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},{"required":["event","reason","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"transaction"},"detail":{"type":"string"}},"additionalProperties":false},{"required":["event","reason","job","parent","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"parent"},"job":{"$ref":"#/$defs/job"},"parent":{"$ref":"#/$defs/job"},"detail":{"enum":["missing","expired","holder"],"description":"Why the parent cannot be used: no such lock, it has expired, or it belongs to another holder."}},"additionalProperties":false},{"required":["event","reason","path"],"properties":{"event":{"const":"refused"},"reason":{"const":"duplicate"},"path":{"$ref":"#/$defs/path"}},"additionalProperties":false},{"required":["event","reason","semaphore","capacity","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"capacity"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false},{"required":["event","reason","semaphore"],"properties":{"event":{"const":"refused"},"reason":{"const":"exists"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","reason","semaphore","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"live"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false}]},"release_line":{"type":"object","description":"One stdout line per --job of `git locks release`: what was released (with any descendants), or that there was nothing to release.","oneOf":[{"required":["event","job","paths"],"properties":{"event":{"const":"released"},"job":{"$ref":"#/$defs/job"},"paths":{"type":"integer","minimum":0,"description":"How many path refs were deleted, descendants included."},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs released in the same transaction, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job"],"properties":{"event":{"const":"nothing"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded","description":"Present when --acquisition or --record named an acquisition the job no longer holds: nothing was released."}},"additionalProperties":false}]},"sweep_line":{"type":"object","description":"One stdout line per expired lock `git locks sweep` deleted; on stderr, one per lock it could not delete because it changed underneath.","oneOf":[{"required":["event","job","holder","expires"],"properties":{"event":{"const":"swept"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"expires":{"$ref":"#/$defs/epoch"},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs swept with this expired parent, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job","reason"],"properties":{"event":{"const":"skipped"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"changed underneath"}},"additionalProperties":false}]},"store_line":{"type":"object","description":"The single line of `git locks store`: the absolute path of the store this repository resolves to.","required":["store"],"properties":{"store":{"type":"string","minLength":1}},"additionalProperties":false},"version_line":{"type":"object","description":"The single line of `git locks version`.","required":["name","version"],"properties":{"name":{"const":"git-locks"},"version":{"type":"string","pattern":"^[0-9]+\\.[0-9]+\\.[0-9]+$"}},"additionalProperties":false},"remaining":{"type":"integer","minimum":0,"description":"Seconds until the lock expires, 0 once it has."},"ttl_line":{"type":"object","description":"The single line of `git locks ttl --job `.","required":["job","expires","remaining"],"properties":{"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false},"extend_line":{"type":"object","description":"The single line of `git locks extend --job --ttl `: the new expiry.","required":["event","job","expires"],"properties":{"event":{"const":"extended"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"missing_line":{"type":"object","description":"On stderr, from show, ttl or extend, when no lock exists for the job (exit 1).","required":["event","job"],"properties":{"event":{"const":"missing"},"job":{"$ref":"#/$defs/job"}},"additionalProperties":false},"sem_line":{"type":"object","description":"One line per semaphore from `git locks sem list`, and the single line of `git locks sem show `: capacity, live count, and the live slots.","required":["semaphore","capacity","live","slots"],"properties":{"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"slots":{"type":"array","items":{"type":"object","required":["job","holder","claimed","expires","remaining","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false}}},"additionalProperties":false},"sem_event_line":{"type":"object","description":"Semaphore lifecycle lines: created, acquired (with the live count after), released (likewise), nothing (the job held no slot), deleted.","oneOf":[{"required":["event","semaphore","capacity"],"properties":{"event":{"const":"created"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job","holder","claimed","expires","live","capacity","record","acquisition"],"properties":{"event":{"const":"acquired"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},{"required":["event","semaphore","job","live","capacity"],"properties":{"event":{"const":"released"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job"],"properties":{"event":{"const":"nothing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded"}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"deleted"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"missing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false}]},"record":{"type":"string","pattern":"^[0-9a-f]{40}([0-9a-f]{24})?$","description":"The object id of this acquisition's record: the identity a later release or renewal names."},"error_line":{"type":"object","description":"On stderr: a usage failure (exit 2), a store that could not be read (exit 2; nothing is reported free or held), or a failed operation (exit 1).","required":["event","reason","detail"],"properties":{"event":{"const":"error"},"reason":{"enum":["usage","store-read","failed"]},"detail":{"type":"string"}},"additionalProperties":false},"usage_line":{"type":"object","description":"`git locks help` on stdout (exit 0), ` --help`, or a usage error on stderr (exit 2): the usage text as one string.","required":["event","usage"],"properties":{"event":{"const":"usage"},"usage":{"type":"string"}},"additionalProperties":false},"acquisition":{"type":"string","minLength":1,"description":"The identity of one acquisition. Minted by a claim (and by a re-claim, which is a new acquisition), kept by extend and by a child admission's rewrite of the parent record, so a caller can release the acquisition it made even after renewals. Distinct from record, the oid of the current version of its record."},"finding_line":{"type":"object","description":"One stdout line per invariant `git locks doctor` found broken, as it is found. `check` names the invariant, `subject` the job, ref or semaphore it failed for, `detail` what was seen.","required":["event","check","subject","detail"],"properties":{"event":{"const":"finding"},"check":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"subject":{"type":"string","minLength":1},"detail":{"type":"string","minLength":1}},"additionalProperties":false},"doctor_line":{"type":"object","description":"The last stdout line of `git locks doctor`: the store, the reading basis (how many refs and records one snapshot held, and the clock the liveness checks used), the checks run, the number of finding lines, and the verdict. Exit 0 when healthy, 1 with findings. An unreadable store prints an error line instead and exits 2; it is never reported healthy.","required":["event","store","basis","checks","findings","healthy"],"properties":{"event":{"const":"doctor"},"store":{"type":"string","minLength":1},"basis":{"type":"object","required":["refs","records","now"],"properties":{"refs":{"type":"integer","minimum":0},"records":{"type":"integer","minimum":0},"now":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"checks":{"type":"array","items":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"minItems":1},"findings":{"type":"integer","minimum":0},"healthy":{"type":"boolean"}},"additionalProperties":false}}} EOF } @@ -1852,7 +2146,7 @@ main() { printf '{"name":"git-locks","version":"%s"}\n' "${VERSION}" exit 0 ;; - claim | batch | release | check | list | sweep | store | show | ttl | extend | with | sem) ;; + claim | batch | release | check | list | sweep | store | show | ttl | extend | with | sem | doctor) ;; *) usage ;; esac for a in "$@"; do diff --git a/lib/000-prelude.sh b/lib/000-prelude.sh index 61604d2..c8823dc 100644 --- a/lib/000-prelude.sh +++ b/lib/000-prelude.sh @@ -60,7 +60,7 @@ DEFAULT_TTL=14400 SCHEMA='git-locks/1' SEM_SCHEMA='git-locks-sem/1' SLOT_SCHEMA='git-locks-slot/1' -VERSION='0.4.0' +VERSION='0.5.0' RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times NOW_CACHED='' # the clock, read once per invocation by now() @@ -79,6 +79,7 @@ usage: git locks claim --job --holder [--ttl ] [--parent git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete + git locks doctor git locks version git locks help | schema @@ -104,6 +105,9 @@ with claim, run the command, release the acquisition it made (also on failur sem capacity, not exclusivity: up to jobs hold a named semaphore at once; a slot expires like a lock; acquire is one transaction with a compare-and-swap on the semaphore's generation, so racers beyond capacity fail and exactly win +doctor read-only invariant check of the store: one finding line per problem, then a doctor line with the + basis (refs and records read, the clock) and the verdict; exit 1 on findings, 2 when the store + cannot be read (an unreadable store is never healthy). Diagnosis only: nothing is repaired schema print the JSON Schema every output line conforms to output: JSON Lines, always: one object per result on stdout, written as each result is known; @@ -150,6 +154,7 @@ sub_usage_text() { ttl) printf 'usage: git locks ttl --job \n' ;; extend) printf 'usage: git locks extend --job --ttl \n' ;; with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ...\n' ;; + doctor) printf 'usage: git locks doctor\n' ;; sem) printf 'usage: git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete \n' ;; *) usage_text ;; esac diff --git a/lib/010-json.sh b/lib/010-json.sh index 1d08881..a4dbf47 100644 --- a/lib/010-json.sh +++ b/lib/010-json.sh @@ -36,18 +36,18 @@ json_paths() { # VAR: set VAR to a JSON array of the lines on stdin printf -v "$1" '[%s]' "${items[*]}" } -json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork - local text="$2" line items=() one IFS - while [[ -n "${text}" ]]; do - line="${text%%$'\n'*}" - if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi - if [[ -n "${line}" ]]; then - json_str one "${line}" - items+=("${one}") +json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork (locals underscored so none can shadow VAR) + local _jp_text="$2" _jp_line _jp_items=() _jp_one IFS + while [[ -n "${_jp_text}" ]]; do + _jp_line="${_jp_text%%$'\n'*}" + if [[ "${_jp_line}" == "${_jp_text}" ]]; then _jp_text=''; else _jp_text="${_jp_text#*$'\n'}"; fi + if [[ -n "${_jp_line}" ]]; then + json_str _jp_one "${_jp_line}" + _jp_items+=("${_jp_one}") fi done IFS=',' - printf -v "$1" '[%s]' "${items[*]}" + printf -v "$1" '[%s]' "${_jp_items[*]}" } json_jobs() { # VAR job... -> JSON array of job ids diff --git a/lib/050-the-snapshot.sh b/lib/050-the-snapshot.sh index 36d7145..403cdf4 100644 --- a/lib/050-the-snapshot.sh +++ b/lib/050-the-snapshot.sh @@ -98,16 +98,16 @@ ref_oid() { # ref -> oid or empty field_v() { # VAR oid key: set VAR to the value of `key:` in the record's header (before paths:), empty when absent; no fork, so the parse memoises in this shell ensure_snapshot parse_record "$2" - local fields="${R_FIELDS[$2]:-}" rest - if [[ "${fields}" == "$3"$'\x1f'* ]]; then - rest="${fields#"$3"$'\x1f'}" - elif [[ "${fields}" == *$'\x1e'"$3"$'\x1f'* ]]; then - rest="${fields#*$'\x1e'"$3"$'\x1f'}" + local _fv_fields="${R_FIELDS[$2]:-}" _fv_rest # underscored: a local named like the caller's VAR would swallow the printf -v + if [[ "${_fv_fields}" == "$3"$'\x1f'* ]]; then + _fv_rest="${_fv_fields#"$3"$'\x1f'}" + elif [[ "${_fv_fields}" == *$'\x1e'"$3"$'\x1f'* ]]; then + _fv_rest="${_fv_fields#*$'\x1e'"$3"$'\x1f'}" else printf -v "$1" '' return 0 fi - printf -v "$1" '%s' "${rest%%$'\x1e'*}" + printf -v "$1" '%s' "${_fv_rest%%$'\x1e'*}" } field() { # oid key -> the value on stdout; inside $(…) the parse happens in the subshell, so hot paths use field_v diff --git a/lib/175-doctor.sh b/lib/175-doctor.sh new file mode 100644 index 0000000..74cf545 --- /dev/null +++ b/lib/175-doctor.sh @@ -0,0 +1,289 @@ +# ---------------------------------------------------------------- doctor +# +# A read-only invariant check over one snapshot. Every finding is one line +# as it is found; the last line states the basis (how many refs and records +# were read, at what clock) and the verdict. A store that cannot be read is +# a store-read error, exit 2, never "healthy". Diagnosis only: nothing here +# writes, and repair, if it ever exists, is a separate explicit command. + +DOC_FINDINGS=0 +DOC_CHECKS='"record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"' + +finding() { # check subject detail -> one finding line on stdout + local _j1 _j2 _j3 + json_str _j1 "$1" + json_str _j2 "$2" + json_str _j3 "$3" + printf '{"event":"finding","check":%s,"subject":%s,"detail":%s}\n' "${_j1}" "${_j2}" "${_j3}" + DOC_FINDINGS=$((DOC_FINDINGS + 1)) +} + +is_int() { [[ "$1" =~ ^[0-9]+$ ]]; } + +doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: each path becomes a file, hash-object hashes them all + local dir p i=0 files=() todo=() out h rc + for p in "$@"; do + [[ -n "${PATH_HASH[${p}]+x}" ]] && continue + in_list "${p}" "${todo[@]}" && continue + todo+=("${p}") + done + ((${#todo[@]} > 0)) || return 0 + dir="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-doctor.XXXXXX")" || fail 'cannot create a temporary directory for hashing' 2 + for p in "${todo[@]}"; do + printf '%s' "${p}" >"${dir}/${i}" + files+=("${dir}/${i}") + i=$((i + 1)) + done + out="$(g hash-object --no-filters "${files[@]}" 2>&1)" # --no-filters: the same bytes path_ref hashes from stdin + rc=$? + rm -rf "${dir}" + ((rc == 0)) || store_error "hash-object exited ${rc}: ${out}" + i=0 + while IFS= read -r h; do + [[ -z "${h}" ]] && continue + valid_oid "${h}" || store_error "hash-object line does not parse: ${h}" + PATH_HASH["${todo[${i}]}"]="${h}" + i=$((i + 1)) + done <<<"${out}" + ((i == ${#todo[@]})) || store_error "hash-object returned ${i} hashes for ${#todo[@]} paths" +} + +doctor_lock_record() { # subject oid -> 0 when the record decodes as a lock record, else findings and 1 + local schema job holder claimed expires acq paths bad=0 + field_v schema "$2" schema + if [[ "${schema}" != "${SCHEMA}" ]]; then + finding record-decodes "$1" "record ${2} has schema '${schema}', not ${SCHEMA}" + return 1 + fi + field_v job "$2" job + valid_job "${job}" || { + finding record-decodes "$1" "record ${2} has no valid job id" + bad=1 + } + field_v holder "$2" holder + [[ -n "${holder}" ]] || { + finding record-decodes "$1" "record ${2} has no holder" + bad=1 + } + field_v claimed "$2" claimed + is_int "${claimed}" || { + finding record-decodes "$1" "record ${2} has no numeric claimed" + bad=1 + } + field_v expires "$2" expires + is_int "${expires}" || { + finding record-decodes "$1" "record ${2} has no numeric expires" + bad=1 + } + field_v acq "$2" acquisition + [[ -n "${acq}" ]] || { + finding record-decodes "$1" "record ${2} has no acquisition id" + bad=1 + } + record_paths_v paths "$2" + [[ -n "${paths}" ]] || { + finding record-decodes "$1" "record ${2} lists no paths" + bad=1 + } + return "${bad}" +} + +doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a slot of that semaphore for that job + local schema v bad=0 + field_v schema "$2" schema + if [[ "${schema}" != "${SLOT_SCHEMA}" ]]; then + finding sem-record "$1" "slot record ${2} has schema '${schema}', not ${SLOT_SCHEMA}" + return 1 + fi + field_v v "$2" semaphore + [[ "${v}" == "$3" ]] || { + finding sem-record "$1" "slot record ${2} names semaphore '${v}'" + bad=1 + } + field_v v "$2" job + [[ "${v}" == "$4" ]] || { + finding sem-record "$1" "slot record ${2} names job '${v}'" + bad=1 + } + field_v v "$2" holder + [[ -n "${v}" ]] || { + finding sem-record "$1" "slot record ${2} has no holder" + bad=1 + } + field_v v "$2" claimed + is_int "${v}" || { + finding sem-record "$1" "slot record ${2} has no numeric claimed" + bad=1 + } + field_v v "$2" expires + is_int "${v}" || { + finding sem-record "$1" "slot record ${2} has no numeric expires" + bad=1 + } + return "${bad}" +} + +cmd_doctor() { + (($# == 0)) || usage + ensure_snapshot + local rows ref oid job name rest at refs_n=0 recs_n="${#BLOB[@]}" + local -A JOB_OID=() OID_JOBS=() PATHREF_OID=() EXPECTED_PATHREF=() JOB_OK=() + local -A SEM_META=() SEM_GEN=() SEM_SLOT_OIDS=() SEM_SLOT_JOBS=() SEM_NAMES=() + local jobs=() sems=() pathrefs=() all_paths=() p paths + now_v at + rows="$(refs_under "${NS}/")" # sorted, so findings come in a stable order + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + refs_n=$((refs_n + 1)) + case "${ref}" in + "${NS}"/jobs/*) + job="${ref#"${NS}"/jobs/}" + JOB_OID["${job}"]="${oid}" + OID_JOBS["${oid}"]+="${job} " + jobs+=("${job}") + ;; + "${NS}"/paths/*) + PATHREF_OID["${ref}"]="${oid}" + pathrefs+=("${ref}") + ;; + "${NS}"/sem/*) + rest="${ref#"${NS}"/sem/}" + name="${rest%%/*}" + rest="${rest#*/}" + if [[ -z "${SEM_NAMES[${name}]+x}" ]]; then + SEM_NAMES["${name}"]=1 + sems+=("${name}") + fi + case "${rest}" in + meta) SEM_META["${name}"]="${oid}" ;; + gen) SEM_GEN["${name}"]="${oid}" ;; + slots/*) + SEM_SLOT_OIDS["${name}"]+="${oid} " + SEM_SLOT_JOBS["${name}"]+="${rest#slots/} " + ;; + *) finding unknown-ref "${ref}" 'a semaphore ref that is not meta, gen or a slot' ;; + esac + ;; + *) finding unknown-ref "${ref}" 'a ref in the namespace that is not a job, path or semaphore ref' ;; + esac + done <<<"${rows}" + + # Job records decode and name their own job; collect every path they list. + for job in "${jobs[@]}"; do + oid="${JOB_OID[${job}]}" + doctor_lock_record "${job}" "${oid}" || continue + JOB_OK["${job}"]=1 + field_v rest "${oid}" job + [[ "${rest}" == "${job}" ]] || finding job-ref-name "${job}" "the job ref points at a record for job '${rest}'" + record_paths_v paths "${oid}" + while [[ -n "${paths}" ]]; do + p="${paths%%$'\n'*}" + if [[ "${p}" == "${paths}" ]]; then paths=''; else paths="${paths#*$'\n'}"; fi + [[ -n "${p}" ]] && all_paths+=("${p}") + done + done + doctor_hash_paths "${all_paths[@]}" + + # Every listed path has a path ref pointing at this record; every path ref is listed by the record it points at. + local have key + for job in "${jobs[@]}"; do + [[ -n "${JOB_OK[${job}]+x}" ]] || continue + oid="${JOB_OID[${job}]}" + record_paths_v paths "${oid}" + while [[ -n "${paths}" ]]; do + p="${paths%%$'\n'*}" + if [[ "${p}" == "${paths}" ]]; then paths=''; else paths="${paths#*$'\n'}"; fi + [[ -n "${p}" ]] || continue + ref="${NS}/paths/${PATH_HASH[${p}]}" + have="${PATHREF_OID[${ref}]:-}" + if [[ -z "${have}" ]]; then + finding path-ref-missing "${job}" "no path ref for '${p}': a claim on it would not see this lock" + elif [[ "${have}" != "${oid}" ]]; then + finding path-ref-elsewhere "${job}" "the path ref for '${p}' points at record ${have} (job ${OID_JOBS[${have}]:-of no job ref})" + fi + EXPECTED_PATHREF["${ref}|${oid}"]=1 # this record lists a path hashing to this ref + done + done + for ref in "${pathrefs[@]}"; do # in ref order, from the sorted rows + oid="${PATHREF_OID[${ref}]}" + if [[ -z "${OID_JOBS[${oid}]+x}" ]]; then + finding path-ref-orphan "${ref}" "points at record ${oid}, which no job ref points at: the path reads as held by nothing a release can name" + elif key="${ref}|${oid}" && [[ -z "${EXPECTED_PATHREF[${key}]+x}" ]]; then + finding path-ref-stray "${ref}" "points at record ${oid} (job ${OID_JOBS[${oid}]% }) which lists no path hashing to this ref" + fi + done + + # Families: the parent exists, is live, has the same holder, and the chain has no cycle. + local parent pexp pholder holder cur steps + for job in "${jobs[@]}"; do + [[ -n "${JOB_OK[${job}]+x}" ]] || continue + oid="${JOB_OID[${job}]}" + field_v parent "${oid}" parent + [[ -n "${parent}" ]] || continue + if [[ -z "${JOB_OID[${parent}]+x}" ]]; then + finding parent-missing "${job}" "names parent '${parent}', which has no job ref: a child cannot outlive its parent" + continue + fi + field_v pexp "${JOB_OID[${parent}]}" expires + ((${pexp:-0} > at)) || finding parent-expired "${job}" "parent '${parent}' expired at ${pexp:-0}; sweep removes both" + field_v holder "${oid}" holder + field_v pholder "${JOB_OID[${parent}]}" holder + [[ "${holder}" == "${pholder}" ]] || finding parent-holder "${job}" "held by '${holder}' but parent '${parent}' is held by '${pholder}'" + cur="${parent}" + steps=0 + while [[ -n "${cur}" && -n "${JOB_OID[${cur}]+x}" ]] && ((steps <= ${#jobs[@]})); do + if [[ "${cur}" == "${job}" ]]; then + finding family-cycle "${job}" "its parent chain returns to itself" + break + fi + field_v cur "${JOB_OID[${cur}]}" parent + steps=$((steps + 1)) + done + done + + # Semaphores: meta and gen exist, records decode, live slots fit the capacity. + local cap live sjob soid slot_oids slot_jobs exp + for name in "${sems[@]}"; do + if [[ -z "${SEM_META[${name}]+x}" ]]; then + finding sem-meta "${name}" 'no meta ref: the semaphore has no capacity' + cap='' + else + field_v rest "${SEM_META[${name}]}" schema + if [[ "${rest}" != "${SEM_SCHEMA}" ]]; then + finding sem-record "${name}" "meta record ${SEM_META[${name}]} has schema '${rest}', not ${SEM_SCHEMA}" + cap='' + else + field_v cap "${SEM_META[${name}]}" capacity + if ! is_int "${cap}" || ((cap < 1)); then + finding sem-record "${name}" "meta record ${SEM_META[${name}]} has capacity '${cap}'" + cap='' + fi + field_v rest "${SEM_META[${name}]}" semaphore + [[ "${rest}" == "${name}" ]] || finding sem-record "${name}" "meta record names semaphore '${rest}'" + fi + fi + [[ -n "${SEM_GEN[${name}]+x}" ]] || finding sem-gen "${name}" 'no gen ref: acquire and release cannot compare-and-swap' + live=0 + slot_oids="${SEM_SLOT_OIDS[${name}]:-}" + slot_jobs="${SEM_SLOT_JOBS[${name}]:-}" + while [[ -n "${slot_oids}" ]]; do + soid="${slot_oids%% *}" + slot_oids="${slot_oids#* }" + sjob="${slot_jobs%% *}" + slot_jobs="${slot_jobs#* }" + doctor_slot_record "${name}/${sjob}" "${soid}" "${name}" "${sjob}" || continue + field_v exp "${soid}" expires + ((exp > at)) && live=$((live + 1)) + done + if [[ -n "${cap}" ]] && ((live > cap)); then + finding sem-capacity "${name}" "${live} live slots over a capacity of ${cap}" + fi + done + + local _j1 healthy=true + ((DOC_FINDINGS == 0)) || healthy=false + json_str _j1 "${STORE}" + printf '{"event":"doctor","store":%s,"basis":{"refs":%s,"records":%s,"now":%s},"checks":[%s],"findings":%s,"healthy":%s}\n' \ + "${_j1}" "${refs_n}" "${recs_n}" "${at}" "${DOC_CHECKS}" "${DOC_FINDINGS}" "${healthy}" + ((DOC_FINDINGS == 0)) +} diff --git a/lib/990-main.sh b/lib/990-main.sh index 5aff298..b6d6a69 100644 --- a/lib/990-main.sh +++ b/lib/990-main.sh @@ -25,7 +25,7 @@ main() { printf '{"name":"git-locks","version":"%s"}\n' "${VERSION}" exit 0 ;; - claim | batch | release | check | list | sweep | store | show | ttl | extend | with | sem) ;; + claim | batch | release | check | list | sweep | store | show | ttl | extend | with | sem | doctor) ;; *) usage ;; esac for a in "$@"; do diff --git a/schema/git-locks.schema.json b/schema/git-locks.schema.json index fdbaf79..c9642c3 100644 --- a/schema/git-locks.schema.json +++ b/schema/git-locks.schema.json @@ -43,6 +43,12 @@ { "$ref": "#/$defs/sem_event_line" }, + { + "$ref": "#/$defs/finding_line" + }, + { + "$ref": "#/$defs/doctor_line" + }, { "$ref": "#/$defs/error_line" }, @@ -920,6 +926,123 @@ "type": "string", "minLength": 1, "description": "The identity of one acquisition. Minted by a claim (and by a re-claim, which is a new acquisition), kept by extend and by a child admission's rewrite of the parent record, so a caller can release the acquisition it made even after renewals. Distinct from record, the oid of the current version of its record." + }, + "finding_line": { + "type": "object", + "description": "One stdout line per invariant `git locks doctor` found broken, as it is found. `check` names the invariant, `subject` the job, ref or semaphore it failed for, `detail` what was seen.", + "required": [ + "event", + "check", + "subject", + "detail" + ], + "properties": { + "event": { + "const": "finding" + }, + "check": { + "enum": [ + "record-decodes", + "job-ref-name", + "path-ref-missing", + "path-ref-elsewhere", + "path-ref-orphan", + "path-ref-stray", + "parent-missing", + "parent-expired", + "parent-holder", + "family-cycle", + "sem-meta", + "sem-gen", + "sem-record", + "sem-capacity", + "unknown-ref" + ] + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "doctor_line": { + "type": "object", + "description": "The last stdout line of `git locks doctor`: the store, the reading basis (how many refs and records one snapshot held, and the clock the liveness checks used), the checks run, the number of finding lines, and the verdict. Exit 0 when healthy, 1 with findings. An unreadable store prints an error line instead and exits 2; it is never reported healthy.", + "required": [ + "event", + "store", + "basis", + "checks", + "findings", + "healthy" + ], + "properties": { + "event": { + "const": "doctor" + }, + "store": { + "type": "string", + "minLength": 1 + }, + "basis": { + "type": "object", + "required": [ + "refs", + "records", + "now" + ], + "properties": { + "refs": { + "type": "integer", + "minimum": 0 + }, + "records": { + "type": "integer", + "minimum": 0 + }, + "now": { + "$ref": "#/$defs/epoch" + } + }, + "additionalProperties": false + }, + "checks": { + "type": "array", + "items": { + "enum": [ + "record-decodes", + "job-ref-name", + "path-ref-missing", + "path-ref-elsewhere", + "path-ref-orphan", + "path-ref-stray", + "parent-missing", + "parent-expired", + "parent-holder", + "family-cycle", + "sem-meta", + "sem-gen", + "sem-record", + "sem-capacity", + "unknown-ref" + ] + }, + "minItems": 1 + }, + "findings": { + "type": "integer", + "minimum": 0 + }, + "healthy": { + "type": "boolean" + } + }, + "additionalProperties": false } } } diff --git a/test/test.sh b/test/test.sh index 261cc63..3bd9c1d 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1052,6 +1052,175 @@ GIT_LOCKS_TRACE="${TRACE5}" git-locks list >/dev/null 2>&1 parses="$(grep -c '^parse' "${TRACE5}")" check "each record is parsed exactly once for a list (one parse line per blob in the trace)" "${parses}" "200" +# ---------------------------------------------------------------- #19: doctor, a read-only invariant check + +findings() { # VAR TEXT CHECK: the number of finding lines for that check (the local is not named n: printf -v would fill it instead of the caller's) + local _c + _c="$(printf '%s\n' "$2" | grep -c "\"event\":\"finding\",\"check\":\"$3\"")" + printf -v "$1" '%s' "${_c}" +} +aref='' r='' xr='' yr='' # filled by pref, which shellcheck cannot see through + +R="$(mkrepo)" +cd "${R}" || exit 2 +line="$(git-locks store)" +S='' +jstr S "${line}" store +gs() { git --git-dir="${S}" "$@"; } +blob() { gs hash-object -w --stdin; } # stdin -> oid, written into the store (tests corrupt the store directly) +pref() { # VAR path: the path ref for a path + local h + h="$(printf '%s' "$2" | gs hash-object --stdin)" + printf -v "$1" 'refs/locks/paths/%s' "${h}" +} + +out="$(git-locks doctor 2>&1)" +check "doctor on an empty store exits 0" "$?" "0" +jfields "and says so on one line with its basis" "${out}" 'event="doctor"' 'findings=0' 'healthy=true' +valid "doctor line" "${out}" +lines n "${out}" +check "an empty store has no finding lines" "${n}" "1" + +git-locks claim --job a --holder alice a.md b.md >/dev/null 2>&1 +git-locks claim --job c --holder alice --parent a c.md >/dev/null 2>&1 +git-locks sem create s --capacity 2 >/dev/null 2>&1 +git-locks sem acquire s --job s1 --holder bob >/dev/null 2>&1 +out="$(git-locks doctor 2>&1)" +check "doctor on a store the tool built exits 0" "$?" "0" +jfields "and reports it healthy against the refs it read" "${out}" 'findings=0' 'healthy=true' +valid "doctor line on a populated store" "${out}" +lines n "${out}" +check "a healthy store has no finding lines" "${n}" "1" + +git_count git-locks doctor +before="${n}" +for i in $(seq 1 30); do git-locks claim --job "d${i}" --holder alice "d${i}.md" "e${i}.md" >/dev/null 2>&1; done +git_count git-locks doctor +after="${n}" +check "doctor spawns the same number of git processes for 3 locks as for 33 (paths are hashed in one process)" "${after}" "${before}" + +# A path ref hijacked to another job's record: job a lists a.md but its path ref points at job d1's record. +d1="$(gs rev-parse refs/locks/jobs/d1)" +pref aref a.md +gs update-ref "${aref}" "${d1}" +out="$(git-locks doctor 2>&1)" +check "a hijacked path ref makes doctor exit 1" "$?" "1" +findings n "${out}" path-ref-elsewhere +check "and is reported once as path-ref-elsewhere, against job a" "${n}" "1" +findings n "${out}" path-ref-stray +check "and once as path-ref-stray, against the ref: d1's record lists no a.md" "${n}" "1" +contains "the elsewhere finding names the path and the record it points at" "${out}" "\"subject\":\"a\"" +valid "finding lines" "${out}" +aoid="$(gs rev-parse refs/locks/jobs/a)" +gs update-ref "${aref}" "${aoid}" +git-locks doctor >/dev/null 2>&1 +check "restoring the ref restores health" "$?" "0" + +# A path ref with no job: delete job d2's job ref and leave its path refs behind. +gs update-ref -d refs/locks/jobs/d2 +out="$(git-locks doctor 2>&1)" +findings n "${out}" path-ref-orphan +check "two orphan path refs are two path-ref-orphan findings" "${n}" "2" +last="$(printf '%s\n' "${out}" | tail -1)" +jfields "the summary counts them and is not healthy" "${last}" 'findings=2' 'healthy=false' +pref r d2.md +gs update-ref -d "${r}" +pref r e2.md +gs update-ref -d "${r}" + +# A job whose path ref is missing. +pref r e3.md +gs update-ref -d "${r}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" path-ref-missing +check "a job listing a path with no path ref is one path-ref-missing finding" "${n}" "1" +contains "naming the job" "${out}" "\"subject\":\"d3\"" +d3="$(gs rev-parse refs/locks/jobs/d3)" +gs update-ref "${r}" "${d3}" + +# A child whose parent is gone, and a parent held by someone else. +gs update-ref -d refs/locks/jobs/a +gs update-ref -d "${aref}" +pref r b.md +gs update-ref -d "${r}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" parent-missing +check "a child whose parent has no job ref is one parent-missing finding" "${n}" "1" +git-locks release --job c >/dev/null 2>&1 +git-locks claim --job p --holder alice p.md >/dev/null 2>&1 +git-locks claim --job q --holder alice --parent p q.md >/dev/null 2>&1 +poid="$(gs rev-parse refs/locks/jobs/p)" +rewritten="$(gs cat-file -p "${poid}" | sed 's/^holder: alice$/holder: mallory/' | blob)" +gs update-ref refs/locks/jobs/p "${rewritten}" +pref r p.md +gs update-ref "${r}" "${rewritten}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" parent-holder +check "a parent held by someone else is one parent-holder finding" "${n}" "1" +git-locks release --job p >/dev/null 2>&1 + +# A cycle, written by hand: x's parent is y and y's parent is x. +xrec="$(printf 'schema: git-locks/1\njob: x\nholder: alice\nclaimed: 1000000\nexpires: 2000000\nparent: y\nfamily: 0\nacquisition: t-1\npaths:\nx.md' | blob)" +yrec="$(printf 'schema: git-locks/1\njob: y\nholder: alice\nclaimed: 1000000\nexpires: 2000000\nparent: x\nfamily: 0\nacquisition: t-2\npaths:\ny.md' | blob)" +gs update-ref refs/locks/jobs/x "${xrec}" +gs update-ref refs/locks/jobs/y "${yrec}" +pref xr x.md +pref yr y.md +gs update-ref "${xr}" "${xrec}" +gs update-ref "${yr}" "${yrec}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" family-cycle +check "a two-job cycle is reported for each job in it" "${n}" "2" +gs update-ref -d refs/locks/jobs/x +gs update-ref -d refs/locks/jobs/y +gs update-ref -d "${xr}" +gs update-ref -d "${yr}" + +# A record that does not decode. +bad="$(printf 'not a record' | blob)" +gs update-ref refs/locks/jobs/z "${bad}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" record-decodes +check "a job ref at a blob that is not a record is one record-decodes finding" "${n}" "1" +gs update-ref -d refs/locks/jobs/z + +# A job ref at another job's record. +d5="$(gs rev-parse refs/locks/jobs/d5)" +gs update-ref refs/locks/jobs/w "${d5}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" job-ref-name +check "a job ref pointing at a record for a different job is one job-ref-name finding" "${n}" "1" +gs update-ref -d refs/locks/jobs/w + +# Semaphores: a slot over capacity, then a missing gen. +git-locks sem create one --capacity 1 >/dev/null 2>&1 +git-locks sem acquire one --job o1 --holder bob >/dev/null 2>&1 +o2="$(printf 'schema: git-locks-slot/1\nsemaphore: one\njob: o2\nholder: bob\nclaimed: 1000000\nexpires: 2000000\nacquisition: t-3' | blob)" +gs update-ref refs/locks/sem/one/slots/o2 "${o2}" +out="$(git-locks doctor 2>&1)" +findings n "${out}" sem-capacity +check "two live slots on a capacity of one is one sem-capacity finding" "${n}" "1" +contains "naming the semaphore and the numbers" "${out}" '"subject":"one","detail":"2 live slots over a capacity of 1"' +gs update-ref -d refs/locks/sem/one/slots/o2 +gs update-ref -d refs/locks/sem/one/gen +out="$(git-locks doctor 2>&1)" +findings n "${out}" sem-gen +check "a semaphore without its gen ref is one sem-gen finding" "${n}" "1" +valid "semaphore finding lines" "${out}" + +# An unreadable store is an error, never healthy. +err="$(PATH="${BROKEN}:${PATH}" git-locks doctor 2>&1 >/dev/null)" +rc="$?" +out="$(PATH="${BROKEN}:${PATH}" git-locks doctor 2>/dev/null)" +check "doctor on an unreadable store exits 2" "${rc}" "2" +check "and prints no doctor line" "${out}" "" +jfields "and the error is a store-read error" "${err}" 'event="error"' 'reason="store-read"' + +out="$(git-locks doctor extra 2>&1)" +check "doctor takes no arguments" "$?" "2" +out="$(git-locks doctor --help 2>&1)" +jfields "doctor --help is a usage object" "${out}" 'event="usage"' + printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" if ((FAIL > 0)); then printf 'failed: %s\n' "${FAILED[@]}"