-
Notifications
You must be signed in to change notification settings - Fork 0
fix(transactions): WATCH/UNWATCH now actually guard EXEC on the production dispatch paths #470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1722,6 +1722,23 @@ def _missing_captures(root: Path) -> list[str]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _contract_status(root: Path, slug: str) -> str | None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """First word of the task's `Status:` line, upper-cased, emphasis stripped. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `**FROZEN @ v1**` and `FROZEN @ v1` are the same state; only the marker | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| differs. Returns None when the task declares no Status line at all. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| task_md = root / "tasks" / slug / "TASK.md" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for line in task_md.read_text(encoding="utf-8").splitlines(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if line.startswith("Status:"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body = line[len("Status:"):].strip().lstrip("*").strip() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return body.split()[0].rstrip("*").upper() if body.split() else "" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except OSError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1725
to
+1738
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target function and callers ---'
sed -n '1690,1775p' .add/tooling/add.py
rg -n -C 4 '_contract_status|contract status|Status:' .add/tooling/add.py .add/state.json .add/tasks tasks 2>/dev/null || true
printf '%s\n' '--- task files and state records ---'
if [ -d tasks ]; then
find tasks -name TASK.md -print | sort
fi
if [ -f .add/state.json ]; then
sed -n '1,240p' .add/state.json
fi
printf '%s\n' '--- deterministic decode probe ---'
python3 - <<'PY'
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "TASK.md"
p.write_bytes(b"Status: FROZEN\\n\\xff\\n")
try:
p.read_text(encoding="utf-8")
except Exception as exc:
print(type(exc).__name__, isinstance(exc, OSError), isinstance(exc, UnicodeError))
PYRepository: pilotspace/moon Length of output: 50373 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target function and callers ---'
sed -n '1690,1775p' .add/tooling/add.py
rg -n -C 4 '_contract_status|contract status|Status:' .add/tooling/add.py .add/state.json .add/tasks tasks 2>/dev/null || true
printf '%s\n' '--- task files and state records ---'
if [ -d tasks ]; then
find tasks -name TASK.md -print | sort
fi
if [ -f .add/state.json ]; then
sed -n '1,240p' .add/state.json
fi
printf '%s\n' '--- deterministic decode probe ---'
python3 - <<'PY'
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "TASK.md"
p.write_bytes(b"Status: FROZEN\n\xff\n")
try:
p.read_text(encoding="utf-8")
except Exception as exc:
print(type(exc).__name__, isinstance(exc, OSError), isinstance(exc, UnicodeError))
PYRepository: pilotspace/moon Length of output: 50373 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- check aggregation and phase definitions ---'
sed -n '1,120p' .add/tooling/add.py
sed -n '1755,1815p' .add/tooling/add.py
rg -n 'PHASES|failed|checks' .add/tooling/add.py | head -80
printf '%s\n' '--- state task phases and parsed Status values ---'
python3 - <<'PY'
import json
from pathlib import Path
state = json.loads(Path(".add/state.json").read_text(encoding="utf-8"))
phases = ["specify", "scenarios", "contract", "tests", "build", "verify", "done"]
tasks = state.get("tasks", {})
for slug, record in tasks.items():
phase = record.get("phase")
if phase not in phases[phases.index("tests"):]:
continue
path = Path(".add") / "tasks" / slug / "TASK.md"
status_lines = []
try:
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("Status:"):
status_lines.append(line)
except Exception as exc:
print(f"{slug}\tphase={phase!r}\tread_error={type(exc).__name__}")
continue
print(f"{slug}\tphase={phase!r}\tstatus={status_lines!r}")
print(f"total_tasks={len(tasks)}")
PY
printf '%s\n' '--- deterministic exception-class probe ---'
python3 - <<'PY'
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "TASK.md"
p.write_bytes(b"Status: FROZEN\n\xff\n")
try:
p.read_text(encoding="utf-8")
except Exception as exc:
print({
"exception": type(exc).__name__,
"is_OSError": isinstance(exc, OSError),
"is_UnicodeError": isinstance(exc, UnicodeError),
"is_caught_by_original": isinstance(exc, OSError),
"is_caught_by_proposed": isinstance(exc, (OSError, UnicodeError)),
})
PYRepository: pilotspace/moon Length of output: 17945 Catch Malformed UTF-8 raises Proposed fix- except OSError:
+ except (OSError, UnicodeError):📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def cmd_check(args: argparse.Namespace) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Read-only integrity check of the .add project. Exit 1 if anything fails.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| as_json = getattr(args, "json", False) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1750,6 +1767,17 @@ def cmd_check(args: argparse.Namespace) -> None: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| marker, want = _read_task_phase(root, slug), t.get("phase") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checks.append((marker == want, f"task '{slug}' marker matches state", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"marker={marker!r} state={want!r}")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # phase/Status drift: this engine has no `freeze` subcommand, so §3 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # `Status:` is hand-maintained and can silently lag the phase marker — | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # a task can sit at `build` with its contract still DRAFT, i.e. built | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # against an unapproved shape. Only DRAFT-past-contract is red; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # `INHERITED` (contract adopted from a sibling task) is a real state, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # and emphasis markers are cosmetic. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if want in PHASES[PHASES.index("tests"):]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _cstat = _contract_status(root, slug) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checks.append((_cstat in ("FROZEN", "INHERITED"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"task '{slug}' contract is frozen for phase '{want}'", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"Status is {_cstat or 'absent'!s} — freeze §3 before leaving contract")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1770
to
+1780
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python - <<'PY'
import json
from pathlib import Path
state = json.loads(Path(".add/state.json").read_text(encoding="utf-8"))
phases = ("tests", "build", "verify", "observe", "done")
for slug, task in state.get("tasks", {}).items():
if task.get("phase") not in phases:
continue
path = Path(".add") / "tasks" / slug / "TASK.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
statuses = [
line[len("Status:"):].strip().split()[0].strip("*").upper()
for line in text.splitlines()
if line.startswith("Status:") and line[len("Status:"):].strip()
]
if not any(status in {"FROZEN", "INHERITED"} for status in statuses):
print(f"{slug}: {task.get('phase')} -> missing frozen contract status")
PYRepository: pilotspace/moon Length of output: 153 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- _contract_status definition and callers ---'
rg -n -A30 -B8 'def _contract_status|_contract_status\(' .add/tooling/add.py
printf '%s\n' '--- relevant task-file encoding check ---'
python3 - <<'PY'
import json
from pathlib import Path
state = json.loads(Path(".add/state.json").read_text(encoding="utf-8"))
phases = {"tests", "build", "verify", "observe", "done"}
for slug, task in state.get("tasks", {}).items():
if task.get("phase") not in phases:
continue
path = Path(".add") / "tasks" / slug / "TASK.md"
if path.is_file():
try:
path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
print(f"{slug}: {task.get('phase')} -> invalid UTF-8 ({exc})")
else:
print(f"{slug}: {task.get('phase')} -> missing TASK.md")
PYRepository: pilotspace/moon Length of output: 5001 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as d:
path = Path(d) / "TASK.md"
path.write_bytes(b"Status: FROZEN\n\xff\n")
try:
path.read_text(encoding="utf-8")
except OSError:
print("caught as OSError")
except UnicodeDecodeError:
print("UnicodeDecodeError escapes the OSError handler")
PYRepository: pilotspace/moon Length of output: 200 Handle invalid UTF-8 in
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # drift: milestone + dependency references must resolve | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ms = t.get("milestone") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ms is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,6 +69,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |
| `--shards 1` and `--shards 4`). | ||
|
|
||
| ### Fixed | ||
| - **`WATCH` / `UNWATCH` now actually guard a transaction on the production | ||
| dispatch paths.** Both commands parsed and answered `+OK`, and the tokio and | ||
| embedded handlers re-checked the recorded versions at `EXEC` — but the two | ||
| paths clients really reach (`handler_monoio`, `handler_sharded`) never | ||
| consulted the watch set at all. A conflicting write from another client | ||
| committed anyway, so every check-and-set built on `WATCH` (inventory | ||
| decrement, balance transfer, leader election) silently degraded to | ||
| last-writer-wins. Four defects, all fixed together because a partial fix is | ||
| indistinguishable from none: | ||
|
|
||
| 1. **The watch set was not consulted at `EXEC`** on the monoio and sharded | ||
| handlers. `EXEC` now aborts with a RESP null array on any version | ||
| mismatch, and clears the watch set on *both* outcomes by construction | ||
| (`mem::take`) rather than by a `clear()` each exit path has to remember. | ||
| 2. **A watched key on another shard was read from the local slice** — a | ||
| different database entirely, so the version compared was some unrelated | ||
| key's or zero. `WATCH` now snapshots versions where the keys live, via a | ||
| new `ShardMessage::ReadVersions` (one hop per owning shard, not per key), | ||
| and a watch set spanning shards is classified and refused `CROSSSLOT` | ||
| like a cross-shard `MULTI` body already was. | ||
| 3. **`WATCH` inside `MULTI` was queued** as an ordinary command instead of | ||
| being refused, and `WATCH` with no arguments answered "unknown command" | ||
| instead of an arity error. | ||
| 4. **Delete + recreate was invisible (ABA).** Versions are per-entry and die | ||
| with the entry, so every incarnation of a key started at | ||
| `INITIAL_VERSION`: `DEL k` + `SET k` handed the watcher back the exact | ||
| token it had recorded and `EXEC` committed on a key that had been | ||
| destroyed and rebuilt underneath it — where Redis aborts. Entries are now | ||
| stamped from a per-database creation ticket, so a recreated key is | ||
| observably a different incarnation. Restored keys draw tickets too: | ||
| versions are not persisted, so otherwise the first key created after a | ||
| restart would collide with the whole restored population. | ||
|
|
||
| Residual risk, stated rather than buried: the ticket shares the entry's | ||
| 24-bit version field and so wraps at 16,777,216 creations (~18s of saturated | ||
| single-database insert at the measured 914K/s). A miss now needs that wrap to | ||
| land inside one client's open `WATCH`..`EXEC` window *and* hit the one | ||
| watched key — ~1 in 16.7M, against the pre-fix certainty. Only a wider | ||
| incarnation field removes it entirely; `WatchToken` stays a named struct so | ||
| adding one later does not churn the call sites. | ||
|
Comment on lines
+105
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Document the disk-offload The task records that promotion assigns a fresh creation ticket and can abort 🤖 Prompt for AI Agents |
||
|
|
||
| New suite `tests/watch_cas_transactions.rs` (10 wire-level tests, raw-byte | ||
| assertions, two connections, run at both `--shards 1` and `--shards 4`), | ||
| plus WATCH/CAS entries in `scripts/test-consistency.sh` and | ||
| `scripts/test-commands.sh`. | ||
|
|
||
| - **RESP3 reply types now match Redis, and no longer change with the calling | ||
| context.** A live sweep against `redis-server` 8.6.1 found the conversion | ||
| table wrong in both directions and, structurally, unable to be right: it keyed | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -578,6 +578,88 @@ both SET edge:touch "val" | |
| assert_both "TOUCH" TOUCH edge:touch | ||
| assert_both "TOUCH missing" TOUCH edge:nomiss | ||
|
|
||
| # =========================================================================== | ||
| # WATCH / UNWATCH optimistic locking (CAS) | ||
| # =========================================================================== | ||
| log "=== WATCH/CAS ===" | ||
|
|
||
| # A CAS conflict needs TWO connections interleaved: the transaction has to stay | ||
| # open while a second client writes the watched key. `redis-cli` one-shot mode | ||
| # cannot express that (each invocation is its own connection, closed on exit), | ||
| # so bash's /dev/tcp holds the transaction connection open and drives it with | ||
| # inline commands. The verdict is read from the key's FINAL VALUE rather than | ||
| # from EXEC's reply, which keeps this free of RESP parsing: `from-txn` means the | ||
| # transaction committed, `from-other` means it aborted and the interloper's | ||
| # write stands. | ||
| # | ||
| # Both servers run the identical sequence and the outcomes are compared, so this | ||
| # asserts Redis parity rather than a hardcoded expectation. | ||
| watch_cas_outcome() { | ||
| local port="$1" conflict="$2" line="" | ||
| redis-cli -p "$port" SET cas:k base >/dev/null 2>&1 || true | ||
| exec 3<>"/dev/tcp/127.0.0.1/${port}" || { echo "__CONNECT_FAILED__"; return 0; } | ||
| printf 'WATCH cas:k\r\nMULTI\r\nSET cas:k from-txn\r\n' >&3 | ||
| if [[ "$conflict" == "yes" ]]; then | ||
| redis-cli -p "$port" SET cas:k from-other >/dev/null 2>&1 || true | ||
| fi | ||
| # ECHO after EXEC is a round-trip barrier: reading its reply proves EXEC has | ||
| # been applied before the connection closes, so the GET below cannot race it. | ||
| printf 'EXEC\r\nECHO cas-done\r\n' >&3 | ||
| while IFS= read -r -t 5 line <&3; do | ||
| [[ "${line%$'\r'}" == "cas-done" ]] && break | ||
| done | ||
| exec 3>&- | ||
| redis-cli -p "$port" GET cas:k 2>&1 | ||
| } | ||
|
Comment on lines
+597
to
+613
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win No barrier between
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| assert_eq "WATCH: conflicting write aborts EXEC" \ | ||
| "$(watch_cas_outcome "$PORT_REDIS" yes)" "$(watch_cas_outcome "$PORT_RUST" yes)" | ||
| assert_eq "WATCH: unconflicted EXEC commits" \ | ||
| "$(watch_cas_outcome "$PORT_REDIS" no)" "$(watch_cas_outcome "$PORT_RUST" no)" | ||
|
|
||
| # The ABA hole: versions are per-entry and die with the entry, so before the | ||
| # per-db creation ticket a DEL + re-SET handed the watcher back the exact token | ||
| # WATCH had recorded and EXEC committed on a key that had been destroyed and | ||
| # rebuilt underneath it. | ||
| watch_cas_aba_outcome() { | ||
| local port="$1" line="" | ||
| redis-cli -p "$port" SET aba:k base >/dev/null 2>&1 || true | ||
| exec 3<>"/dev/tcp/127.0.0.1/${port}" || { echo "__CONNECT_FAILED__"; return 0; } | ||
| printf 'WATCH aba:k\r\nMULTI\r\nSET aba:k from-txn\r\n' >&3 | ||
| redis-cli -p "$port" DEL aba:k >/dev/null 2>&1 || true | ||
| redis-cli -p "$port" SET aba:k rebuilt >/dev/null 2>&1 || true | ||
| printf 'EXEC\r\nECHO cas-done\r\n' >&3 | ||
| while IFS= read -r -t 5 line <&3; do | ||
| [[ "${line%$'\r'}" == "cas-done" ]] && break | ||
| done | ||
| exec 3>&- | ||
| redis-cli -p "$port" GET aba:k 2>&1 | ||
| } | ||
|
|
||
| assert_eq "WATCH: delete + recreate aborts EXEC (ABA)" \ | ||
| "$(watch_cas_aba_outcome "$PORT_REDIS")" "$(watch_cas_aba_outcome "$PORT_RUST")" | ||
|
|
||
| # UNWATCH releases every dependency, so the same conflicting write commits. | ||
| watch_unwatch_outcome() { | ||
| local port="$1" line="" | ||
| redis-cli -p "$port" SET uw:k base >/dev/null 2>&1 || true | ||
| exec 3<>"/dev/tcp/127.0.0.1/${port}" || { echo "__CONNECT_FAILED__"; return 0; } | ||
| printf 'WATCH uw:k\r\nUNWATCH\r\nMULTI\r\nSET uw:k from-txn\r\n' >&3 | ||
| redis-cli -p "$port" SET uw:k from-other >/dev/null 2>&1 || true | ||
| printf 'EXEC\r\nECHO cas-done\r\n' >&3 | ||
| while IFS= read -r -t 5 line <&3; do | ||
| [[ "${line%$'\r'}" == "cas-done" ]] && break | ||
| done | ||
| exec 3>&- | ||
| redis-cli -p "$port" GET uw:k 2>&1 | ||
| } | ||
|
|
||
| assert_eq "UNWATCH releases the dependency" \ | ||
| "$(watch_unwatch_outcome "$PORT_REDIS")" "$(watch_unwatch_outcome "$PORT_RUST")" | ||
|
|
||
| assert_both "WATCH arity" WATCH | ||
| assert_both "UNWATCH outside MULTI" UNWATCH | ||
|
|
||
| # =========================================================================== | ||
| # SWAPDB consistency | ||
| # =========================================================================== | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: pilotspace/moon
Length of output: 33543
🏁 Script executed:
Repository: pilotspace/moon
Length of output: 27762
🏁 Script executed:
Repository: pilotspace/moon
Length of output: 22473
🏁 Script executed:
Repository: pilotspace/moon
Length of output: 15743
Place the complete scope declaration on the first line.
add.pyparses only backticked tokens on the firstScope (may touch):line.scope.declaredmatches the three paths currently parsed. The paths on lines 342–345 are ignored and will not pass the scope gate.Move all approved paths to the first declaration line, then re-establish the tests-to-build snapshot. Do not edit
.add/state.jsonmanually.📍 Affects 2 files
.add/state.json#L290-L296(this comment).add/tasks/watch-cas-transactions/TASK.md#L341-L345🤖 Prompt for AI Agents