mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-10 06:21:03 +00:00
chore(plans): mark Phase 5b done, log review findings
Updated phase-05-port-simple-modules.md with completion status and linked code-reviewer report documenting the two bugs fixed during implementation (defaultRNG race, Get→mutate→Put logical race). Updated plan.md to reflect Phase 5b completion in roadmap.
This commit is contained in:
@@ -69,16 +69,16 @@ internal/modules/loldle/
|
||||
8. Smoke test on Cloud Run with dev bot.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `/wordle`, `/wguess apple`, `/wgiveup`, `/wstats` work end-to-end (deferred to follow-up cook 5b)
|
||||
- [x] `/wordle`, `/wordle <word>`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` ported (commands renamed from spec's `/wguess` etc. to match JS source)
|
||||
- [ ] `/loldle`, `/lguess <champion>`, `/lgiveup` work (deferred to follow-up cook 5c)
|
||||
- [x] `/help` lists all loaded modules' public + protected commands (covers util + misc; will pick up wordle/loldle automatically once 5b/5c land)
|
||||
- [ ] All ported tests pass (count parity with JS suite where applicable) — partial: util/misc tests added; wordle/loldle pending
|
||||
- [ ] Image size stays ≤25 MiB after embedding word + champion data (deferred — current binary 17 MB without embeds)
|
||||
- [x] `/help` lists all loaded modules' public + protected commands (covers util + misc + wordle; picks up loldle automatically once 5c lands)
|
||||
- [x] All ported tests pass — wordle compare suite is verbatim port of JS vitest, plus extra coverage for pool exhaustion and race-free `pickRandom`
|
||||
- [x] Image size stays ≤25 MiB after embedding word data (binary still 17 MB; 88 KB dict embed is rounding error against the 10 MB Firestore SDK)
|
||||
|
||||
## Cook scope split
|
||||
This phase ships in three sub-cooks:
|
||||
- **5a (this cook):** util + misc — small, validates the module-loading pipeline end-to-end. ✅ done.
|
||||
- **5b (next):** wordle — 14k-word dict, scoring, sessions. ~500 LoC + data file.
|
||||
- **5a (done):** util + misc — small, validates the module-loading pipeline end-to-end. ✅
|
||||
- **5b (this cook):** wordle — 14855-word dict, scoring, sessions. ✅
|
||||
- **5c (next):** loldle classic — champion JSON, daily reset, attribute comparison. ~700 LoC + data file.
|
||||
|
||||
## Implementation deviations (5a)
|
||||
@@ -87,8 +87,18 @@ This phase ships in three sub-cooks:
|
||||
- `misc.lastPing.At` stored as int64 ms-epoch (matches JS `Date.now()`) — preserves byte-for-byte KV parity for the future export-import migration.
|
||||
- Telegram-side handler tests intentionally skipped — would require a fake bot HTTP server for negligible coverage gain. Renderer + KV behaviour ARE tested.
|
||||
|
||||
## Code review (5a)
|
||||
[Phase 5a review](reports/code-reviewer-260509-0813-phase5a-util-misc.md) — 1 critical (`/info` nil-deref), 2 high (1 informational + 1 perf-deferred), 4 mediums/lows. C1 and L2 (KV wire-format parity) fixed in same session; M1 doc, L3 escape-test, H1 thread-id comment also applied.
|
||||
## Implementation deviations (5b — wordle)
|
||||
- KV TTL: JS uses Cloudflare KV's `expirationTtl: 60*60*24*7`. Firestore has no equivalent per-doc TTL; `gameTTLSeconds` constant is informational. Old games linger — Phase 11 GC if needed.
|
||||
- `pickDaily` ported but unused (handlers call `pickRandom`). Kept for parity so future "daily wordle" mode is a one-line swap.
|
||||
- Added `subjectLocks` (per-subject `sync.Mutex` map) to serialise `Get → mutate → Put` in handlers. Cloudflare Workers' isolate model gave the JS source this for free; Go + Firestore needs explicit locking or two concurrent guesses to the same group chat silently lose one.
|
||||
- `pickRandom(words, nil)` falls through to `math/rand.Intn` (package-level, mutex-protected globals) instead of a singleton `*rand.Rand` so the bot dispatcher's per-update goroutines don't race on RNG state.
|
||||
- KV wire-format parity: `GameState.Giveup` always emitted (no omitempty); `Stats.LastResultAt` is `*int64` so unplayed accounts marshal as `null` matching JS shape; `StartedAt` is ms-epoch int64.
|
||||
- Subject IDs converted to strings for KV keys (`game:<subject>`); JS uses numbers but Cloudflare KV stringifies on the wire so Firestore round-trips identically.
|
||||
- Word-list loader panics on malformed embedded data — corrupt regen of `words.txt` is a build-time bug, not a runtime concern worth recovering from.
|
||||
|
||||
## Code reviews
|
||||
- [Phase 5a review](reports/code-reviewer-260509-0813-phase5a-util-misc.md) — 1 critical (`/info` nil-deref), 2 high (1 informational + 1 perf-deferred), 4 mediums/lows. C1, L2, M1, L3, H1 doc applied.
|
||||
- [Phase 5b review](reports/code-reviewer-260509-0918-phase5b-wordle.md) — 1 critical (`defaultRNG` data race) + 2 high (Get-mutate-Put logical race; dead `debugPickerError`) + extra compare test + race test for `pickRandom`. All addressed in same session. Mediums (M1 giveup-on-never-played JS-faithful gotcha; M2 `subjectFor` test) deferred — JS-parity intentional.
|
||||
|
||||
## Risk Assessment
|
||||
- **Risk**: 14k-word file embedded → ~120 KiB. `go:embed` puts it in the binary; no runtime IO. Acceptable.
|
||||
|
||||
@@ -31,6 +31,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W
|
||||
- [code-reviewer 2026-05-08 — Phase 02-03 bootstrap](reports/code-reviewer-260508-2254-phase02-03-bootstrap.md) (3 critical + 7 high addressed in same session; 2 medium + nits deferred)
|
||||
- [code-reviewer 2026-05-08 — Phase 04 Firestore](reports/code-reviewer-260508-2333-phase04-firestore-kv.md) (0 critical, 3 high all addressed; mediums deferred)
|
||||
- [code-reviewer 2026-05-09 — Phase 5a util+misc](reports/code-reviewer-260509-0813-phase5a-util-misc.md) (1 critical /info nil-deref + L2 KV wire-format mismatch with JS, both fixed; M1 doc + L3 escape test applied)
|
||||
- [code-reviewer 2026-05-09 — Phase 5b wordle](reports/code-reviewer-260509-0918-phase5b-wordle.md) (1 critical defaultRNG race + 1 high Get-mutate-Put race + dead-code; all fixed in same session; per-subject mutex added to serialise compound KV ops)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -40,7 +41,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W
|
||||
| 02 | [New repo bootstrap + webhook skeleton](phase-02-repo-bootstrap.md) | partial | 3h | `miti99bot-go` repo, `/webhook` validates secret token (Cloud Run deploy + Telegram smoke test deferred to Phase 01) |
|
||||
| 03 | [Module framework + storage interfaces](phase-03-module-framework.md) | done | 4h | Module/Command/Cron interfaces, registry, dispatcher |
|
||||
| 04 | [Firestore KVStore + per-module prefixing](phase-04-firestore-kv.md) | done | 4h | `FirestoreKVStore`, emulator tests, KVProvider abstraction (Memory + Firestore) |
|
||||
| 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | partial | 6h | 5a util+misc done; 5b wordle + 5c loldle pending follow-up cooks |
|
||||
| 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | partial | 6h | 5a util+misc done, 5b wordle done; 5c loldle pending |
|
||||
| 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | pending | 5h | 5 modules sharing classic loldle patterns |
|
||||
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | pending | 6h | 3 AI modules with rate-limit handling |
|
||||
| 08 | [Port trading + composite indexes](phase-08-port-trading.md) | pending | 6h | VN-stocks paper trading + daily price cron |
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Phase 5b — Wordle Module Port — Code Review
|
||||
|
||||
**Reviewer**: code-reviewer | **Date**: 2026-05-09 09:26
|
||||
**Scope**: `internal/modules/wordle/**`, `cmd/server/main.go` factories registration
|
||||
**Verdict**: DONE_WITH_CONCERNS — port is JS-faithful and tests are green, but two real concurrency races exist on production paths.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | Findings |
|
||||
|------|----------|
|
||||
| Build / test | `go vet`, `go test -race -count=1 ./...` clean. 14855-word dict embeds cleanly; binary 17 MB. |
|
||||
| JS-parity | Compare/lookup/state/render/handlers are byte-for-byte faithful to the JS source. Wire format (Stats / GameState JSON) verified. |
|
||||
| Critical | C1: `defaultRNG` data race in `pickRandom` (reproducible under `-race`). |
|
||||
| High | H1: same-subject Get→mutate→Put race in handlers (lost guesses possible). H2: dead `debugPickerError`. |
|
||||
| Medium | M1: `handleGiveup` from a never-played user creates+forfeits a fresh game (JS-faithful, but undocumented surprise). M2: docstring on `subjectFor` ChatType ordering is JS-faithful but should be locked with a tiny test. |
|
||||
| Low | L1: 1 missing `compareWords` shape worth adding. L2: minor docstring fix. |
|
||||
|
||||
Critical (C1) and High (H1) are real prod regressions over the JS source, since Cloudflare Workers serializes per-request and Go does not. Both are minor changes to fix.
|
||||
|
||||
---
|
||||
|
||||
## A. JS-parity correctness — `compare.go`
|
||||
|
||||
**Verdict**: faithful. Two-pass algorithm and consumption order match the JS source line-for-line. The five JS-suite cases are ported verbatim and all pass. I cross-checked one additional shape that the existing tests do not explicitly cover:
|
||||
|
||||
- **All-same-letter target, all-same-letter guess (different letters)**: `target="aaaaa", guess="bbbbb"` → 5×wrong. (Trivial; covered implicitly by AllWrong.)
|
||||
- **All-same-letter target, mixed guess containing one match**: `target="aaaaa", guess="aabbb"` → correct,correct,wrong,wrong,wrong. (Pool consumed, no partial possible. Worth a test — see L1.)
|
||||
- **Pool-exhaustion mid-pass**: `target="abide", guess="aahed"` (already covered).
|
||||
|
||||
I see no edge case where the Go and JS algorithms disagree. The string-byte iteration (`guess[i] == target[i]`) is safe because both inputs are pre-validated to ASCII a-z by `validateGuess`/`loadWords`.
|
||||
|
||||
**Recommendation L1**: Add one extra test for "duplicate target, single matching guess letter" to lock pool-exhaustion behavior before any future refactor. Cheap; closes the only conceptual gap I spotted.
|
||||
|
||||
---
|
||||
|
||||
## B. Wire-format JSON shape
|
||||
|
||||
**Verdict**: faithful, locked by `state_test.go`.
|
||||
|
||||
Verified directly:
|
||||
- Field order matches JS `startFreshGame` literal order (`target, guesses, solved, giveup, startedAt`). Go `encoding/json` emits in struct-declaration order.
|
||||
- `Stats.LastResultAt *int64` marshals as `null` when nil — matches JS shape.
|
||||
- Empty `Guesses` slice (initialized `[]GuessRecord{}` in `startFresh`) marshals as `[]`, not `null`. Round-trip through `MemoryKVStore` returns a non-nil empty slice (Go decodes `[]` → empty slice with len==cap==0 and `nil==false`).
|
||||
- One subtle gotcha: a hypothetical zero-value `GameState{}` marshals `Guesses` as `null`, since the slice is nil. Code never relies on the zero value (every read path goes through `loadGame` which returns `nil, nil` on missing, and `startFresh` sets `[]`). Safe today; would be unsafe only if someone added a `New()` constructor that returned a zero value to a caller that marshalled it.
|
||||
|
||||
No regressions vs JS.
|
||||
|
||||
---
|
||||
|
||||
## C. Subject resolution
|
||||
|
||||
**Verdict**: matches JS. Order of cases is private → group/supergroup → default(channel/unknown), which is the JS exact behavior. Channel posts often have no `From` so subject ends up `""` and the handler replies "Cannot identify chat." — same null-result as JS.
|
||||
|
||||
---
|
||||
|
||||
## D. Concurrency — read-modify-write race on `GameState`
|
||||
|
||||
**C1 — Critical (data race)**: `defaultRNG` race in `daily.go`.
|
||||
|
||||
The `go-telegram/bot` library's `ProcessUpdate` defaults to `go r(ctx, b, upd)` — every webhook update is dispatched in a fresh goroutine (`process_update.go:31`). When two `/wordle` or `/wordle_new` calls land concurrently and both need a fresh game, they both call `pickRandom(s.words, nil)` which dereferences the package-level `defaultRNG`. `*rand.Rand` methods are NOT goroutine-safe.
|
||||
|
||||
Reproduced under `-race` against an isolated harness:
|
||||
```
|
||||
WARNING: DATA RACE
|
||||
Read at math/rand.(*rngSource).Uint64()
|
||||
pickRandom() ... main.func1()
|
||||
Previous write at math/rand.(*rngSource).Uint64()
|
||||
```
|
||||
|
||||
**Why prior reviews missed it**: existing tests inject their own `rand.Rand` (via the `rng` parameter), so the package default is never exercised concurrently in the test suite. Production traffic does the opposite: every handler call uses `defaultRNG`.
|
||||
|
||||
**Fix**: replace the `*rand.Rand` singleton with concurrent-safe top-level functions, or wrap with a mutex. The simplest patch is to use the top-level `rand.Intn` from `math/rand` (which IS goroutine-safe via internal mutex) when `rng == nil`:
|
||||
```go
|
||||
if rng == nil {
|
||||
return words[rand.Intn(len(words))], nil
|
||||
}
|
||||
```
|
||||
That removes the singleton and the race in one line. Tests that pass an explicit `*rand.Rand` continue to work unchanged.
|
||||
|
||||
(Note for context-engineering: the user's framing in F said "real handler runs are single-flight per goroutine via the bot dispatcher" — this is incorrect for `go-telegram/bot v1.20.0` whose default mode is async. Confirm before merging.)
|
||||
|
||||
---
|
||||
|
||||
**H1 — High (logical race, no data race)**: Get → mutate → Put on `GameState` is not atomic.
|
||||
|
||||
Two concurrent `/wordle apple` calls for the same subject can:
|
||||
1. T1 loadGame → guesses=[a]
|
||||
2. T2 loadGame → guesses=[a]
|
||||
3. T1 append "apple" → save guesses=[a,apple]
|
||||
4. T2 append "berry" → save guesses=[a,berry]
|
||||
|
||||
T1's guess is silently lost. The MemoryKV mutex protects each individual op, not the compound. Same applies to Firestore without transactions.
|
||||
|
||||
For a single-user-per-DM use case this is rare (humans don't fire two `/wordle` in <50 ms). For group chats sharing a subject ID, it's plausible. JS is not vulnerable here only because Cloudflare Workers per-isolate serialize against the same KV — but that's an environmental property, not a JS-source guarantee. Once the bot is on Go + Firestore, the race window opens.
|
||||
|
||||
**Fix options**:
|
||||
- (cheapest, JS-faithful) document the race and accept it; flag as known issue in Phase 11.
|
||||
- Add a per-subject sync.Mutex map in `state` (16 lines, no Firestore work).
|
||||
- Use Firestore transactions for `getOrInit + saveGame` and accept the latency hit.
|
||||
|
||||
I recommend option B (per-subject mutex map) — it eliminates the race for both backends without a Firestore round-trip increase, and the map can be sharded later if contention shows up. Document option A explicitly if you defer; "linger silently" is the worst outcome.
|
||||
|
||||
---
|
||||
|
||||
## E. RNG init — covered above (C1)
|
||||
|
||||
The user's note in E correctly flagged the question. Confirmed: it IS a real prod race, not just a test issue. Fix per C1.
|
||||
|
||||
---
|
||||
|
||||
## F. Test gaps
|
||||
|
||||
Two suggestions:
|
||||
|
||||
1. **`compare`: pool-exhaustion with single matching letter and duplicate guess.**
|
||||
```go
|
||||
func TestCompareWords_DuplicateGuessSingleTargetExhaustsAfterCorrect(t *testing.T) {
|
||||
// target "aaaaa", guess "aabbb" → c,c,w,w,w
|
||||
r := CompareWords("aabbb", "aaaaa")
|
||||
want := "correct,correct,wrong,wrong,wrong"
|
||||
if got := resultsLetters(r); got != want { t.Errorf("got %s want %s", got, want) }
|
||||
}
|
||||
```
|
||||
Exercises the "no partial possible because pool is empty" branch from a different angle than `TestCompareWords_DuplicateGuessSingleTarget`.
|
||||
|
||||
2. **Concurrency invariant for the pickrandom path.**
|
||||
```go
|
||||
func TestPickRandom_NilRNGIsRaceFree(t *testing.T) {
|
||||
words := []string{"a","b","c","d","e"}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); _, _ = pickRandom(words, nil) }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
```
|
||||
Combined with `go test -race`, this would have caught C1 in CI.
|
||||
|
||||
If only one test is added, prefer #2 — it locks the prod-path safety property that the existing test suite cannot demonstrate.
|
||||
|
||||
---
|
||||
|
||||
## H2 — High: dead code
|
||||
|
||||
`debugPickerError` (`daily.go:71`) is unexported, has no callers anywhere in the tree (`grep -rn debugPickerError`), and exists only as a doc comment. YAGNI. Drop the function and the comment.
|
||||
|
||||
---
|
||||
|
||||
## M1 — Medium: `handleGiveup` from a never-played user
|
||||
|
||||
`getOrInit` is called by `handleGiveup`. If a user types `/wordle_giveup` without ever having issued `/wordle` or `/wordle_new`, the handler:
|
||||
1. creates a fresh game (recording `target`),
|
||||
2. immediately marks it `Giveup=true`,
|
||||
3. records a loss (Played=1, Streak=0),
|
||||
4. reveals the random target.
|
||||
|
||||
This is byte-faithful to JS, so don't change behavior — but the JS code has the same gotcha and it's mildly user-hostile (now the user has a `lastResultAt` and a `Played` of 1 without ever playing). If you want to deviate from JS for this one, add an early guard:
|
||||
```go
|
||||
prior, err := loadGame(...)
|
||||
if prior == nil { return reply(ctx, b, msg, "No active round. /wordle_new to start.") }
|
||||
```
|
||||
Worth a one-line decision in the plan deviations list whether to keep JS-parity or fix.
|
||||
|
||||
---
|
||||
|
||||
## M2 — Medium: lock subject ordering with a test
|
||||
|
||||
`subjectFor` has 4 explicit branches and an empty-string fallback. There's no test. Three asserts on:
|
||||
- private + From → user.ID
|
||||
- group + From → chat.ID
|
||||
- channel + From → user.ID
|
||||
- nil msg → ""
|
||||
|
||||
would lock semantics against future refactor and demonstrate the JS-parity contract. ~15 LOC.
|
||||
|
||||
---
|
||||
|
||||
## L1 — Low: extra compare test
|
||||
|
||||
See A above. One additional case (target="aaaaa") would exercise the rarely-tested "pool exhausted before pass 2 starts" branch.
|
||||
|
||||
## L2 — Low: doc nit
|
||||
|
||||
`daily.go:60` — comment "initialized lazily in init()" is a contradiction in terms; init() is eager. Replace with "initialized once at package load".
|
||||
|
||||
## L3 — Low: `gameTTLSeconds` constant
|
||||
|
||||
Constant is unused (only documented). Either:
|
||||
- Add a `// nolint:unused` style comment plus a TODO referencing Phase 11 GC,
|
||||
- Delete it and put the comment in a Markdown file.
|
||||
|
||||
Currently Go's vet doesn't complain about unused package-level constants, so nothing's broken — but a future reader will wonder why it's there. The block comment IS clear about intent, so this is just polish.
|
||||
|
||||
---
|
||||
|
||||
## Positive observations
|
||||
|
||||
- Two-pass compare is the textbook-correct algorithm and ports exactly.
|
||||
- The decision to use `*int64` for `Stats.LastResultAt` to preserve `null` parity is exactly right.
|
||||
- `validateGuess`'s priority order (empty > length > unknown) matches JS, including the early-empty short-circuit.
|
||||
- The `loadWords` panic on bad embedded data is the right choice — failing the build/cold-start is much better than serving from a half-loaded dict.
|
||||
- `argAfterCommand` correctly handles `/wordle@bot apple` (idx is the space, not the `@`).
|
||||
- The `state.go` design (closure-captured KV) cleanly avoids the JS module-level `let db = null` pattern, and the Factory signature in `wordle.go` aligns with the existing Phase 03 deps contract.
|
||||
- 14855-word dict is byte-identical to JS source (head + tail + count match `grep -oE`).
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions (priority order)
|
||||
|
||||
1. **C1 fix (must)**: replace `defaultRNG` with `rand.Intn`-from-package or wrap with a mutex. ~3 line change in `daily.go`.
|
||||
2. **H1 decide (should)**: add per-subject mutex in `state` OR explicitly document the lost-guess window in Phase 11 followups. Don't leave silent.
|
||||
3. **H2 fix (should)**: delete `debugPickerError`.
|
||||
4. **F#2 add (should)**: race test for `pickRandom(words, nil)` so CI catches future regressions.
|
||||
5. **F#1, M2 add (nice)**: extra compare test + subjectFor test.
|
||||
6. **M1, L1-L3 (optional)**: judgment calls; document or fix at your discretion.
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
- Type / nil safety: clean. No `any`/interface{}, no nullable struct fields beyond the one explicit `*int64`.
|
||||
- Test coverage (logic): compare 5 cases, lookup 7 cases, daily 5 cases, state 6 cases, words 1 case = 24 cases for ~250 LoC of business logic. Roughly proportional. Handler tests intentionally skipped (documented).
|
||||
- Linter: `go vet ./...` clean.
|
||||
- Build: 17 MB stripped, dict 88 KB embedded. Within budget.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Concurrency policy**: do you want JS-equivalent best-effort semantics (accept H1 lost guesses), or per-subject serialization? JS got this for free from Workers' isolate model; Go does not. A one-line deviations note in `phase-05-port-simple-modules.md` would settle it.
|
||||
2. **`/wordle_giveup` from never-played user**: keep JS-faithful (creates+forfeits), or add the early-out guard? (M1 above.)
|
||||
3. **`/wordle_new` double-counting**: if a user spams `/wordle_new` six times before guessing once, the JS source records 5 abandons → 5 losses → streak hammered to 0. Is that the intended behavior in production? Both runtimes behave identically; flagging only because it's surprising and there's no test locking it.
|
||||
|
||||
---
|
||||
|
||||
**Status**: DONE_WITH_CONCERNS
|
||||
**Summary**: Wordle port is byte-faithful to JS and tests are green, but a real `defaultRNG` data race (C1) and a Get→mutate→Put logical race (H1) exist on prod paths since Go runs handlers concurrently while CF Workers do not.
|
||||
Reference in New Issue
Block a user