fix(wake): #908 unify observed_seq on a single store-side allocator (dissolve detector-private-counter seam) (#915)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #915.
This commit is contained in:
@@ -29,23 +29,24 @@
|
||||
# silently "no state".
|
||||
#
|
||||
# CONTRACT NOTES (flagged, not silently guessed — see PR body):
|
||||
# * observed_seq DUAL-ALLOCATOR HAZARD (fail-closed; #908 tracks the fix).
|
||||
# observed_seq has TWO INDEPENDENT allocators today: the detector's PRIVATE
|
||||
# counter ($STATE_DIR/detector/observed_seq_counter, W4) and — if this
|
||||
# reconciler enumerated — the STORE cursor (observed_seq+offset). Because the
|
||||
# detector's next seq is (private_counter+1), NOT (store_observed_seq+1), the
|
||||
# two are decoupled and SERIALIZING THEM DOES NOT PREVENT COLLISION: after
|
||||
# the reconciler enumerates alpha->seq1, beta->seq2 (store observed_seq=2,
|
||||
# detector private still 0), the detector's next delta allocates private
|
||||
# 0->1 and enqueues seq1 — ALIASING alpha. A consumer acking CONSUMED 1
|
||||
# (trusting §2.4's unique observed_seq) then silently DROPS a distinct
|
||||
# obligation — the exact G3 swallow this reconciler exists to prevent.
|
||||
# THEREFORE, until #908 unifies on a single store-side allocator, the
|
||||
# reconciler FAILS CLOSED: it REFUSES to enumerate (loud, non-zero; the
|
||||
# obligation is FLAGGED, never silently swallowed) whenever a detector is
|
||||
# co-feeding this store, and otherwise requires the operator to assert
|
||||
# reconciler-SOLE-FEEDER mode. OPERATIONAL GUARD: a detector and this
|
||||
# reconciler MUST NOT co-feed one store until #908 lands.
|
||||
# * observed_seq DUAL-ALLOCATOR HAZARD — RESOLVED by #908 (single store-side
|
||||
# allocator). HISTORY: observed_seq once had TWO INDEPENDENT allocators — the
|
||||
# detector's PRIVATE counter (W4) and the STORE cursor (observed_seq+offset)
|
||||
# this reconciler used to enumerate from. Because the detector's next seq was
|
||||
# (private_counter+1), NOT (store_observed_seq+1), the two were decoupled and
|
||||
# even serializing them did NOT prevent collision: the reconciler enumerating
|
||||
# alpha->seq1, beta->seq2 while the detector's private counter was still 0
|
||||
# meant the detector's next delta re-allocated seq1, ALIASING alpha, and a
|
||||
# consumer acking CONSUMED 1 silently dropped a distinct obligation.
|
||||
# #908 DISSOLVES this at the root: store.sh enqueue is now the SOLE allocator
|
||||
# (reads its own observed_seq cursor, next=+1, under an exclusive lock). This
|
||||
# reconciler now enumerates by calling store.sh enqueue WITHOUT --seq — the
|
||||
# SAME single allocator the detector uses — so co-feeding one store is SAFE:
|
||||
# both paths draw distinct, contiguous seqs from the one cursor. The former
|
||||
# fail-closed refusal (and the reconciler-SOLE-FEEDER assertion) are therefore
|
||||
# RETIRED; --allow-enumerate / WAKE_RECONCILE_ALLOW_ENUMERATE remain accepted
|
||||
# as deprecated no-ops for caller compatibility. The G3 accounting invariants
|
||||
# (0-UNACCOUNTED, vacuous-pass prevention) are unaffected and still enforced.
|
||||
# * "accounted" is judged against observed_seq/inbox (§4/G3 wording) PLUS the
|
||||
# reconciler's OWN durable reconciled-state ledger — NOT the detector's
|
||||
# hash-file. Judging by the detector baseline would let a silent first-seen
|
||||
@@ -67,13 +68,6 @@ STATE_DIR="$(wake_state_dir)"
|
||||
# Reconciler-local state (its durable reconciled-state ledger) lives in its own
|
||||
# subdir under the store's STATE_DIR so it never collides with store/detector.
|
||||
RECON_DIR="$STATE_DIR/reconciler"
|
||||
# The detector's PRIVATE state dir (W4). Its presence signals a detector is (or
|
||||
# has been) allocating observed_seq into THIS store from an INDEPENDENT private
|
||||
# counter — the co-feeding condition under which reconciler enumeration is
|
||||
# unsafe (see the dual-allocator hazard note above; #908). Read-only coupling:
|
||||
# reconcile.sh never writes or imports W4, it only OBSERVES this path to fail
|
||||
# closed rather than silently alias a seq.
|
||||
DET_STATE="$STATE_DIR/detector"
|
||||
|
||||
_need_jq() {
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
@@ -119,16 +113,14 @@ Commands:
|
||||
ENUMERATED into the durable store (the startup/pre-existing obligation
|
||||
path). Exit 0 IFF 0 unaccounted were FOUND this pass.
|
||||
|
||||
DUAL-ALLOCATOR SAFETY (fail-closed; #908): enumeration WRITES observed_seq
|
||||
from an allocator INDEPENDENT of the detector's private counter, so it
|
||||
FAILS LOUD (refuses; the obligation is FLAGGED, never swallowed) when it
|
||||
would risk aliasing a seq:
|
||||
* if a detector is co-feeding this store (its private state dir exists)
|
||||
=> refuse, ALWAYS (even with the ack below);
|
||||
* else the operator MUST assert reconciler-SOLE-FEEDER mode via
|
||||
--allow-enumerate (or WAKE_RECONCILE_ALLOW_ENUMERATE=1), promising no
|
||||
detector co-feeds this store until #908 lands.
|
||||
--no-enumerate (and `check`) never write, so they are always safe.
|
||||
SINGLE STORE-SIDE ALLOCATOR (#908, resolved): enumeration allocates
|
||||
observed_seq by calling store.sh enqueue WITHOUT --seq — the SAME sole
|
||||
allocator the detector uses (store cursor +1 under an exclusive lock). A
|
||||
detector co-feeding this store is therefore SAFE: both draw distinct,
|
||||
contiguous seqs from the one cursor, so no aliasing is possible. The old
|
||||
fail-closed refusal is retired; --allow-enumerate (and
|
||||
WAKE_RECONCILE_ALLOW_ENUMERATE=1) are accepted as deprecated no-ops.
|
||||
--no-enumerate (and `check`) never write, so they remain side-effect-free.
|
||||
|
||||
check
|
||||
The full §4/G3 gate, side-effect-free: `inventory` AND
|
||||
@@ -141,8 +133,9 @@ Environment:
|
||||
contract as the detector: <cmd> <kind> <id>, def on
|
||||
stdin; exit!=0 or empty => FAIL LOUD).
|
||||
WAKE_STATE_HOME/WAKE_AGENT store namespace (see store.sh).
|
||||
WAKE_RECONCILE_ALLOW_ENUMERATE set to 1 to assert reconciler-SOLE-FEEDER mode
|
||||
(see reconcile's dual-allocator safety note; #908).
|
||||
WAKE_RECONCILE_ALLOW_ENUMERATE DEPRECATED no-op (#908 made co-feeding safe via
|
||||
the single store-side allocator); accepted for
|
||||
backward compatibility, no longer required.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -404,30 +397,14 @@ cmd_reconcile() {
|
||||
"$STORE_SH" init >/dev/null 2>&1 || true
|
||||
mkdir -p "$RECON_DIR"
|
||||
|
||||
# --- DUAL-ALLOCATOR SAFETY GATE (fail-closed; #908) ------------------------
|
||||
# Decide, ONCE, whether ENUMERATION (the observed_seq WRITE path) is safe this
|
||||
# pass. It is unsafe — and we REFUSE (flag, never swallow) — whenever a seq we
|
||||
# allocate could be REISSUED by the detector's INDEPENDENT private counter and
|
||||
# alias a distinct obligation (see the header hazard note). --no-enumerate and
|
||||
# `check` never write, so the gate does not apply to them.
|
||||
local enum_mode="$enumerate" enum_refuse_reason=""
|
||||
if [ "$enumerate" -eq 1 ]; then
|
||||
if [ -e "$DET_STATE" ]; then
|
||||
enum_mode=0
|
||||
enum_refuse_reason="a detector is co-feeding this store ($DET_STATE exists); its private observed_seq counter (W4) is INDEPENDENT of the store cursor this reconciler allocates from, so enumerating would risk REISSUING/ALIASING a seq and silently swallowing a distinct obligation. #908 tracks the store-side allocator unification that makes co-feeding safe. OPERATIONAL GUARD: do not run a detector and the reconciler against one store until #908."
|
||||
elif [ "$allow_enumerate" -ne 1 ]; then
|
||||
enum_mode=0
|
||||
enum_refuse_reason="enumeration writes observed_seq via an allocator INDEPENDENT of the detector's private counter (W4); until #908 unifies them, co-feeding one store aliases seqs even under strictly-sequential calls. Set --allow-enumerate (or WAKE_RECONCILE_ALLOW_ENUMERATE=1) to assert this store is reconciler-SOLE-FEEDER (no detector co-feeds it)."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Seq allocation base: the store's authoritative observed_seq (§2.4). Used ONLY
|
||||
# on the sole-feeder path the gate above has proven safe.
|
||||
local obs_base offset
|
||||
obs_base="$("$STORE_SH" cursors 2>/dev/null | sed -n 's/^observed_seq=//p')"
|
||||
case "$obs_base" in '' | *[!0-9]*) obs_base=0 ;; esac
|
||||
offset=0
|
||||
local refuse_announced=0 refused=0
|
||||
# --- SINGLE STORE-SIDE ALLOCATOR (#908) -----------------------------------
|
||||
# Enumeration allocates observed_seq by calling store.sh enqueue WITHOUT --seq
|
||||
# (the sole allocator the detector also uses). Co-feeding one store is safe —
|
||||
# both paths draw distinct, contiguous seqs from the one store cursor under its
|
||||
# exclusive lock — so the former dual-allocator fail-closed refusal is retired.
|
||||
# allow_enumerate is now a deprecated no-op, retained only so old callers that
|
||||
# pass --allow-enumerate / WAKE_RECONCILE_ALLOW_ENUMERATE=1 still work.
|
||||
: "$allow_enumerate"
|
||||
|
||||
local pairs kind id failed=0 unaccounted=0 enumerated=0
|
||||
pairs="$(printf '%s' "$json" | jq -r '[ .watches[].sources[] | "\(.kind)\t\(.id)" ] | unique | .[]')"
|
||||
@@ -478,34 +455,24 @@ cmd_reconcile() {
|
||||
unaccounted=$((unaccounted + 1))
|
||||
printf ' %s/%s: UNACCOUNTED (current state not reflected in observed_seq/inbox)\n' "$kind" "$id" >&2
|
||||
|
||||
if [ "$enumerate" -eq 1 ] && [ "$enum_mode" -eq 0 ]; then
|
||||
# REFUSE to enumerate (fail-closed): the obligation is loudly FLAGGED, NOT
|
||||
# silently swallowed. Announce the dual-allocator reason once.
|
||||
if [ "$refuse_announced" -eq 0 ]; then
|
||||
echo "reconcile.sh: FAIL LOUD (dual-allocator hazard, #908) — $enum_refuse_reason" >&2
|
||||
refuse_announced=1
|
||||
fi
|
||||
refused=1
|
||||
printf ' -> REFUSED enumeration (FLAGGED, not swallowed; see dual-allocator hazard / #908)\n' >&2
|
||||
elif [ "$enum_mode" -eq 1 ]; then
|
||||
# ENUMERATE the pre-existing/startup obligation into the durable store.
|
||||
# This path runs ONLY when the safety gate proved the reconciler is the
|
||||
# SOLE feeder (no detector co-feeding), so its store-cursor allocation
|
||||
# cannot be aliased. Class = non-coalescible fail-safe `actionable` (§2.3)
|
||||
if [ "$enumerate" -eq 1 ]; then
|
||||
# ENUMERATE the pre-existing/startup obligation into the durable store via
|
||||
# the SINGLE store-side allocator (#908): store.sh enqueue WITHOUT --seq
|
||||
# allocates the next observed_seq under the store's lock and PRINTS it. Both
|
||||
# the detector and this reconciler draw from that one cursor, so a co-fed
|
||||
# store cannot alias. Class = non-coalescible fail-safe `actionable` (§2.3)
|
||||
# so distinct pre-existing obligations never collapse together.
|
||||
offset=$((offset + 1))
|
||||
local seq locators
|
||||
seq=$((obs_base + offset))
|
||||
locators="$(jq -cn --arg kind "$kind" --arg id "$id" --arg hash "$curhash" \
|
||||
--argjson sdef "$def" \
|
||||
'{kind:$kind, id:$id, observed_hash:$hash, reconciled:true}
|
||||
+ ( $sdef | {repo, path, anchor, remote, branches} | with_entries(select(.value != null)) )')"
|
||||
if "$STORE_SH" enqueue --seq "$seq" --class actionable --locators "$locators" >/dev/null 2>&1; then
|
||||
if seq="$("$STORE_SH" enqueue --class actionable --locators "$locators")"; then
|
||||
printf '%s' "$curhash" | _atomic_write "$ledger"
|
||||
enumerated=$((enumerated + 1))
|
||||
printf ' -> enumerated into store as observed_seq=%s (class=actionable)\n' "$seq"
|
||||
else
|
||||
echo "reconcile.sh: enumerate FAILED for '$kind/$id' seq $seq" >&2
|
||||
echo "reconcile.sh: enumerate FAILED for '$kind/$id'" >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
@@ -519,9 +486,7 @@ EOF
|
||||
return 1
|
||||
fi
|
||||
if [ "$unaccounted" -ne 0 ]; then
|
||||
if [ "$refused" -eq 1 ]; then
|
||||
echo "reconcile.sh: FLAG — $unaccounted unaccounted source-state(s) REFUSED enumeration (dual-allocator hazard, #908). They are flagged, NOT swallowed. Resolve co-feeding / assert sole-feeder, then re-run." >&2
|
||||
elif [ "$enumerate" -eq 1 ]; then
|
||||
if [ "$enumerate" -eq 1 ]; then
|
||||
echo "reconcile.sh: FLAG — $unaccounted unaccounted source-state(s) found and enumerated. Re-run to confirm 0." >&2
|
||||
else
|
||||
echo "reconcile.sh: FLAG — $unaccounted unaccounted source-state(s) (audit mode; nothing enumerated)." >&2
|
||||
|
||||
Reference in New Issue
Block a user