Skip to content

feat(dispatch): add runner registry foundation (pull-dispatch slice 1/6)#341

Open
as535364 wants to merge 5 commits into
mainfrom
feat/dispatch-runner-registry
Open

feat(dispatch): add runner registry foundation (pull-dispatch slice 1/6)#341
as535364 wants to merge 5 commits into
mainfrom
feat/dispatch-runner-registry

Conversation

@as535364

Copy link
Copy Markdown
Member

Closes Normal-OJ/Normal-OJ#61

Slice 1/6 of the pull-based sandbox runner rework. Dark PR — no callers; nothing outside dispatch/ and its test module changes.

Spec carriers: docs/specs/pull-based-job-dispatch.md §5/§7.1/§8/§13, docs/adr/0004-ephemeral-runner-identity.md (meta-repo).

What

  • dispatch/redis_keys.py — centralized §8 key namespace (job keys pre-declared for slices 2–3, unused here by design)
  • dispatch/config.py — §13 parameters + fail-closed RUNNER_REGISTRATION_TOKEN accessor (read live, unset/empty ⇒ registration disabled)
  • dispatch/runner.py — identity layer: register (rn_+ULID / rk_+token_urlsafe(32), stores SHA-256 hex only), constant-time verify_token / verify_registration_token, lazy 7d identity GC on register/list, list_runners

Security notes (auth slice — please review with a security lens)

  • Token returned exactly once; Redis never holds a usable credential (SHA-256 hex only)
  • All comparisons via hmac.compare_digest on UTF-8 bytes; non-ASCII and non-str hostile inputs fail closed (regression-tested) instead of raising
  • Revocation = delete runner:<rn>:token_hash → immediate 401 (ADR-0004)

Tests

27 fakeredis unit tests (tests/test_dispatch_runner.py): registration TTLs/ZSET, token verify incl. cross-runner & revocation, GC boundary at exactly 7d, hostile-input matrix. Full suite locally: 523 passed, 7 skipped, 1 xfailed.

as535364 added 4 commits July 14, 2026 01:09
Slice 1/6 of the pull-based sandbox runner rework (Normal-OJ/Normal-OJ#61).
Dark PR: no callers yet.

- dispatch/redis_keys.py: centralized spec-§8 key namespace
- dispatch/config.py: spec-§13 parameters + fail-closed registration token accessor
- dispatch/runner.py: register (rn_/rk_, SHA-256 hash only), constant-time
  verify_token / verify_registration_token, lazy 7d identity GC, list_runners
- tests: 19 fakeredis unit tests incl. revocation and non-ASCII auth inputs
verify_token and verify_registration_token now reject non-str inputs
(bytes/int/list/dict) with False instead of raising AttributeError on
.encode(). JSON request bodies can legally carry non-str values, so the
auth boundary must fail closed (401) rather than crash (500).

Add regression tests covering bytes/int/list/dict for both functions.
… evaporated members

External review on PR #341 found a TOCTOU in _gc(): it did zrangebyscore
then unconditionally deleted meta/token_hash/alive + zrem. A heartbeat (or
clock skew) landing between the scan and the delete could get its
just-renewed token_hash deleted, turning a live runner into a 401.

Close the race structurally instead of with atomicity machinery: TTL is
now the ONLY thing that invalidates a live identity. _gc() keeps the >7d
score prefilter, then EXISTS-checks each candidate's token_hash and skips
any that still exists (its TTL has not fired). Only members whose
token_hash already evaporated are swept (zrem + delete meta/alive). Safe
without locks because a token_hash can never reappear for the same rn_id:
register always mints a fresh ULID and heartbeat requires token auth.

Adapt GC tests to simulate the token_hash TTL firing by deleting the key
(fakeredis TTLs use real wall-clock, not the monkeypatched _now), and add
a regression test asserting a stale-by-score member with a surviving
token_hash is spared and still verifies.
@as535364

Copy link
Copy Markdown
Member Author

外部 review 發現 _gc() 有一個 scan-then-delete 的 TOCTOU:掃描與刪除之間若撞上 heartbeat 續期,會誤刪剛續期的 token_hash(活 runner → 401)。已在 66cfb11 修正,e5dc757 為後續小改名。

取徑(沒有照原建議用 Lua,而是消除整類競態):GC 語意改為「TTL 是唯一使活身分失效的機制,GC 只收屍」——score 預篩後,逐一檢查 EXISTS token_hash鑰匙還在就完全不碰;只清鑰匙已因 TTL 蒸發的成員(zrem + meta/alive 殘留)。刪除 token_hash 的程式碼已不存在,所以「誤刪活人鑰匙」在結構上不可能,不需原子化機制。

安全論證:token_hash 一旦消失就不可能為同一 rn_id 重現——register 永遠發新 ULID,heartbeat 續期需先通過 token 驗證。已由獨立驗證以注入探測確認(在 EXISTS 檢查後、刪除前強制重建 token_hash,仍不會被刪)。新增回歸測試 test_gc_spares_stale_member_whose_token_hash_survives

Spec 同步(meta-repo):§7.1 GC 措辭已更新;§7.2 新增約束「heartbeat 續 TTL 一律 EXPIRE、不得重建已蒸發的 key」——這是未來 heartbeat 切片(HTTP 層)review 時需要盯的不變量。

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the first slice of the pull-based sandbox runner rework by adding a foundational runner identity/registration layer under dispatch/, including Redis key namespacing, configuration parameters, and a test suite validating token behavior and lazy GC semantics.

Changes:

  • Added dispatch/runner.py to register runners, verify runner tokens/registration token, list runners, and lazily GC expired identities.
  • Added dispatch/redis_keys.py and dispatch/config.py to centralize Redis key naming and dispatch parameters / registration-token access.
  • Added tests/test_dispatch_runner.py with fakeredis unit tests for registration, token verification, and GC behaviors.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
dispatch/runner.py Implements runner identity registration, verification, listing, and lazy GC over Redis.
dispatch/redis_keys.py Centralizes Redis key naming for identity and (future) job dispatch.
dispatch/config.py Defines pull-dispatch timing/TTL parameters and a live env-backed registration token accessor.
dispatch/__init__.py Adds module docstring describing the slice scope.
tests/test_dispatch_runner.py Adds fakeredis tests covering identity registration, verification, and GC.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread dispatch/runner.py
Comment thread dispatch/runner.py Outdated
Comment thread tests/test_dispatch_runner.py
Comment thread tests/test_dispatch_runner.py
Address Copilot review on #341:
- list_runners docstring no longer claims a liveness view; revoked/dead
  identities stay listed until GC as a deliberate observability window
  (revocation only guarantees immediate auth failure, ADR-0004) — pinned
  by test_revoked_runner_stays_listed_until_gc
- compare_digest comments now cite the hmac docs' 'str (ASCII only)'
  contract (same-type non-ASCII str raises TypeError; empirically checked)
@as535364
as535364 requested review from Bogay and uier July 16, 2026 08:04
Comment thread dispatch/config.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

是不是把它合併到 https://github.com/Normal-OJ/Back-End/blob/main/mongo/config.py 比較好?雖然我們目前 config 還挺亂的,或是等 FastAPI 那 pr 合併會更好 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

這樣放在 mongo 底下很怪,然後 fastapi 躺有點久了
如果要改 config 位子也是可以在這個 PR 直接處理完?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我倒是覺得 #333 幾乎可以 merge 了,剩下一點 format 問題跟或許可以整理一下 commit history,當初沒動是因為我想說學期間不要有這麼大的改動比較好

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

那我就把 config 統一拉到 root folder 囉?

Comment thread dispatch/runner.py
Comment on lines +64 to +71
def verify_registration_token(candidate: Optional[str]) -> bool:
"""Constant-time check of a register request's shared secret. Fails closed."""
expected = config.registration_token()
# Fail closed: no configured secret ⇒ registration is disabled, not open.
# Reject non-str candidates too — a JSON body can carry ints/lists/bytes,
# and .encode() below would otherwise raise instead of returning False.
if not expected or not isinstance(candidate, str) or not candidate:
return False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: 這是不是應該在 request body 就 parse 好?不應該讓非 str 的資料進來。然後 config 那段應該是 app init 階段就要驗證

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config 哪段?🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected = config.registration_token(),我不大確定這邊是否該允許空值

@as535364 as535364 Jul 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm 有道理,但空值應該要檢查一下
然後把 parse 在 request body 處理好就好?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backend: dispatch foundation — runner registry, tokens, GC (slice 1/6)

3 participants