Repository navigation
Conversation
a2dddbb to
221d291
Compare
221d291 to
526787b
Compare
526787b to
40df9e9
Compare
4304c23 to
c2ff589
Compare
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 Review findings - created by zyin-squareup's code review agent - for /pull/197, c2ff589.
Verdict: ship — 1 finding: 0 blocking, 0 important, 0 nice-to-have, 1 suggestion.
This PR changes only tests. It makes the heartbeat-reader leak tests independent of test order by counting only goroutines with a pprof label. It also runs the engine cleanup tests without MySQL by using mock.PingDB. The highest risk was a goroutine count that silently reads 0 and makes the "no readers left" checks pass. The label inheritance and the foundSelf guard prevent this, and no defects survived verification.
Assumptions & unknowns
- Reviewed head
c2ff58942afd081a4b3bba5466c49d4f8605dca1againstorigin/Areson/repl-lag-level-setup, fetched fresh. The PR is stacked on #196. - Source-only review. Local
go testand CI-log retrieval were not available in this session. Execution evidence is limited to CI's pass status. - The claim that the pprof
debug=1format contains# labels: {"k":"v"}comes from reading the source, with CI passing on Go 1.24 as indirect evidence. If the format did not match, thefoundSelfpanic would fire.
General suggestions
monitor/engine_cleanup_test.go#L43: consider addingt.Cleanup(engine.Stop)afterNewEngine. When at.Fatalfat L48 or L52 fires, the engine is currently left running, so the newTestMainwaits 5s and prints a second, redundant FAIL. This gap predates the PR, and it only matters on a run that is already failing.
The one thing that could have broken, verified
A labeled count that never sees the reader would turn every Wait(..., 0, ...) into a pass. I verified by inspection that the labeled test goroutine starts the reader:
lag.go#L317runsgo c.lagReader.Start(), andreader.go#L131runsgo r.run(). Both are synchronous fromPrepare, so the child goroutines inherit the label.- Each zero check has a guard: a preceding
Wait(..., 1, ...), orrequireReaderStoppedon the error paths, where a not-yet-started reader would otherwise count 0. goroutine.go#L126panics if the caller's own labeled stack is missing, instead of returning 0.
Verified correct
- Label matching:
fmt.Sprintf("%q:%q", ...)includes the closing quote, so group id1can't match10. - Truncation guard: it checks only stacks without
fn, which is correct because truncation drops the outerrunframe. 100 frames is below the 128-frame profile cap. mock.PingDB:Engine.Prepareonly callsPingContext(engine.go#L165). Queries fail throughPreparewith a non-ErrBadConnerror, sodatabase/sqldoes not retry. The reader waitsReadErrorWait(1s) between attempts, so it doesn't spin.- Double
cleanup():BlipReader.Stopis safe to call more than once, concurrently, and beforeStart(reader.go#L232). - Spy reader: it wraps the real reader through the
newReaderseam. Reverting the stop-on-error defer inLag.Prepareleavesstoppedfalse and fails the test, verified by inspection. TestMainprocess-wide check: no othermonitororrepl.lagtest leaves a reader running. Therepl.lagtests registerstopReader, and nomonitortest plan usesrepl.lag. I found no false failure, including with local MySQL.- Removed symbols: the old
test.Goroutines(fn)func,requireNoReaders, and the duplicated constants have no remaining callers. - Categories: security, wire compatibility, migrations, and production performance are N/A because the PR changes only tests. CI passed: build (
go test ./...), DCO, Semgrep, and zizmor.
This review was generated by Claude Code (claude-opus-5-5). Local tests were not run.
The engine cleanup tests skipped in CI because they needed the MySQL fixture, but Engine.Prepare only pings the database. Add test.PingDB, a database/sql driver whose pings succeed and queries fail, and run both engine tests with it. The reader leak tests required no heartbeat readers in the whole process before they started, so they depended on earlier tests and on how fast stopped readers exit. Count only readers started by the test, using a pprof label that goroutines inherit (test.Goroutines). Error path tests also check that Prepare called Stop on the reader, because a leaked reader might not have started when the count is checked. Export the heartbeat reader function and timeout from the test package, instead of defining them in both test packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Group-scoped reader counts miss a reader leaked by a test that doesn't count readers. Add a TestMain to monitor and metrics/repl.lag that waits after all tests for no heartbeat readers in the process, using the new test.CountGoroutines and test.WaitGoroutines. It runs once, so it doesn't depend on test order. Replace test.Goroutines.Do with Label, which labels the test goroutine, so the goroutines it starts afterward are in the group without a closure at each call site. Make goroutine counts fail loudly instead of returning 0: the count must find its own goroutine in the profile, with the group label, and panics on a stack deep enough that the profile might have truncated it. Check that the reader stops in TestPrepareBlipAtMultipleLevelsDropsNoHeartbeat. Move PingDB to test/mock, next to ContextDB, built on sql.OpenDB with a connector instead of a registered driver. ContextDB also connects without MySQL, but its queries block until the read timeout, so each Engine.Stop in the reader leak test would wait 2s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ian Oberst <ioberst@block.xyz>
c2ff589 to
6c1d8fa
Compare
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 Review findings - created by zyin-squareup's code review agent - for /pull/197, 6c1d8fa.
Verdict: ship — 2 findings: 0 blocking, 0 important, 0 nice-to-have, 2 suggestions.
This is a re-review after a rebase. The PR's two commits are patch-identical to the earlier approved head c2ff589, and the only change is the newer base. The new base changes only sink/ and docs, which these test-only changes don't touch. A fresh finder and verifier pass over the full diff found no defects.
Assumptions & unknowns
- Reviewed head
6c1d8fac48f3cf012058bf6c8f23667040882573againstorigin/Areson/repl-lag-level-setup, fetched fresh. The PR is stacked on #196. git range-diffshows both commits=(identical) to the previously reviewedabe6e08/c2ff589. The base moved bysink/factory.go,sink/retry.go, their tests, and docs only.- Source-only review: I couldn't run
go testin this session. Execution evidence is CI only.
Prior findings
- The suggestion from the last round (add
t.Cleanup(engine.Stop)inTestEnginePrepareStopDoesNotLeakHeartbeatReader) is still open. It was optional and predates the PR; it's repeated below.
General suggestions
monitor/engine_cleanup_test.go#L43andmetrics/repl.lag/lag_test.go#L205: if a count check callst.Fatalfbeforeengine.Stop()orcleanup(), the reader keeps running until the process exits, andTestMainadds a second FAIL after a 5s wait. At.Cleanup(engine.Stop)here, orstopReader(t, cleanup)as inTestPrepareBlipAtMultipleLevelsDropsNoHeartbeat, would avoid that. This predates the PR and only affects runs that are already failing.test/goroutine.go#L110: the comment says it panics "if a counted stack might be truncated". The guard at L130 actually checks stacks that don't containfn, which is the right design because truncation drops the outerrunframe. Consider rewording the comment to match.
The one thing that could have broken, verified
A labeled count that never sees the reader would turn every Wait(..., 0, ...) into a pass. I verified by inspection that the labeled goroutine starts the reader:
lag.go#L317runsgo c.lagReader.Start(), andreader.go#L131runsgo r.run(). Both run synchronously fromPrepareafterLabel(), so both inherit the label.- Each zero check is paired with a preceding
Wait(..., 1, ...), or withrequireReaderStoppedon the error paths. goroutine.go#L144panics instead of returning 0 if the caller's labeled stack is missing.
Verified correct
- Rebase: the PR patches are unchanged, and the new
sink/base code starts no heartbeat readers, so the process-wideTestMainchecks are unaffected. - Profile parsing: the Go 1.24
debug=1format (N @ …,# labels: {"k":"v"},#\tframes) matches the parser. The%q:%qmatch includes the closing quote, so id1can't match11. - Truncation guard:
maxFrames = 100is below the 128-frame default (go.modisgo 1.24). mock.PingDB:Engine.Prepareonly callsPingContext(engine.go#L165).errPingDBisn'tErrBadConn, sodatabase/sqldoesn't retry, and the reader waitsReadErrorWaitbetween attempts instead of spinning.- Cleanup paths:
TestEnginePrepareErrorCleansUpPreparedCollectorsstill pins bothEngine.Preparecleanup paths (engine.go#L192,#L231). Verified by inspection. - Spy reader: it wraps the real
newBlipReaderthrough thec.newReaderseam, so reverting the stop-on-error defer inLag.PreparefailsrequireReaderStopped. Verified by inspection. - Removed symbols: the old
test.Goroutines(fn),requireNoReaders, and the local constants have no remaining callers. TestMainfalse failures: I found none. Nomonitortest plan usesrepl.lag, and therepl.lagtests that start readers registerstopReader.- Categories: security, wire compatibility, migrations, and production performance are N/A because the PR changes only tests. CI at this head passed: build (
go test ./...), DCO, Semgrep OSS, and zizmor.
This review was generated by Claude Code (claude-opus-5-5). Local tests were not run.
When a check in the loop failed, the engine and its heartbeat reader kept running, so TestMain waited its full timeout and reported a second, redundant failure. Stop each engine in t.Cleanup; Stop is a no-op after the loop's own Stop. Close the database in t.Cleanup too, so it closes after the engines stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ian Oberst <ioberst@block.xyz>
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 Review findings - created by zyin-squareup's code review agent - for /pull/197, 8bafcd4.
Verdict: ship — 2 findings: 0 blocking, 0 important, 0 nice-to-have, 2 suggestions.
This is a re-review. Since the last approved head 6c1d8fa, the only change is one commit, 8bafcd4. It changes two lines in TestEnginePrepareStopDoesNotLeakHeartbeatReader: each engine now stops in t.Cleanup, and the database closes in t.Cleanup after the engines. The change is correct: Engine.Stop is idempotent, and cleanups run in the right order. A fresh finder and verifier pass found no defects.
Assumptions & unknowns
- Reviewed head
8bafcd400dbf2fd327ccfff7f4c552ea00c6a332againstorigin/Areson/repl-lag-level-setup, fetched fresh. The PR is stacked on #196. git range-diffshows the first two commits are identical to the previously reviewed01bf52c/6c1d8fa, and8bafcd4is the only new commit. The base did not move.- This was a source-only review. I couldn't run
go testin this session, so the only execution evidence is CI.
Prior findings
t.Cleanup(engine.Stop)inTestEnginePrepareStopDoesNotLeakHeartbeatReader: fixed atengine_cleanup_test.go#L44. I verified this by inspection only; the change only affects runs that are already failing, so CI can't exercise it.- The same backstop in
TestPrepareBlipAtMultipleLevelsReturnsReaderCleanup: still open, repeated below. It was optional. - The
countGoroutinesdoc comment wording: still open, repeated below. It was optional.
General suggestions
metrics/repl.lag/lag_test.go#L200: if thist.Fatalffires whilecleanupis non-nil, the reader is left running.TestMainthen waits 5s and prints a second, redundant FAIL. RegisteringstopReader(t, cleanup)after theerrcheck, as#L274already does, would match the fix in the monitor test. This gap predates the PR and only affects runs that are already failing.test/goroutine.go#L110: the comment says the function panics "if a counted stack might be truncated". The guard at#L129checks stacks that do not containfn. That is the right design, because truncation drops the outerrunframe. Consider "if a stack without fn might be truncated".
The one thing that could have broken, verified
The new t.Cleanup(engine.Stop) calls Stop a second time after the loop's own Stop. It would also run on an engine whose Prepare failed. I verified by inspection that both cases are safe:
engine.go#L544-L545runscl.cleanup()and thencl.cleanup = nil, so a secondStopnever runs the reader cleanup twice. A repeatedcl.cancel()is a no-op on a context cancel func, andcollectorWG.Wait()returns immediately.- If
Preparefails, it cleans up its own collectors in its defer. It assignse.collectorsonly after every error return, soStopon that engine iterates over nothing. - Cleanups run last-in, first-out. The
db.Closecleanup at#L22is registered first, so it runs after all three engine cleanups, as the comment says.
Verified correct
- Goroutine label counting: the reader goroutines are started by
go c.lagReader.Start()and thengo r.run()from the labeled test goroutine, so they inherit its label. Each zero check has a guard: either a precedingWait(..., 1, ...)orrequireReaderStopped. A missing self-stack panics instead of returning 0. mock.PingDB:Engine.Prepareonly callsPingContext. Queries fail throughPreparewith a non-ErrBadConnerror, sodatabase/sqldoesn't retry them, and the reader waitsReadErrorWaitbetween attempts.TestEnginePrepareErrorCleansUpPreparedCollectorspins both cleanup paths inEngine.Prepare: the deferred loop and the backstop for a cleanup returned with an error. Verified by inspection.- Process-wide
TestMainchecks:- No other
monitortest leaves a blip reader running. The only default-plan path isTestLoaderReloadKeepsSinkCounters, which needs MySQL. On that pathrepl.lagresolves to thepfswriter, and the test unloads its monitor. - CI has no MySQL service, so those tests skip there.
- No other
- Categories: security, compatibility, migrations, and production performance are N/A because the PR changes only tests. CI at this head passed: build (
go test ./...), DCO, Semgrep OSS, and zizmor.
This review was generated by Claude Code (claude-opus-5-5). Local tests were not run.
Stacked on #196. Tests only.
monitor/engine_cleanup_test.goskipped in CI because they needed MySQL, butEngine.Prepareonly pings the database. Newmock.PingDB(next tomock.ContextDB) is a*sql.DBwhose pings succeed and queries fail right away. Both tests use it.TestEnginePrepareErrorCleansUpPreparedCollectorsfails if either cleanup path inEngine.Preparefrom Stop repl.lag heartbeat reader on monitor stop #189 is removed.mock.ContextDBalso connects without MySQL, but its queries block until the context ends, so eachEngine.Stopwaits the full heartbeat read timeout (2s).test.Goroutines.Label, which sets a pprof label on the test goroutine that the reader goroutines it starts inherit, and counts only goroutines with that label. Error path tests also check thatPreparecalledStop, because a leaked reader may not have started yet when the count is taken.TestPrepareBlipAtMultipleLevelsDropsNoHeartbeatnow checks that its reader stops, too.TestMaininmonitorandmetrics/repl.lagwaits after all tests for no heartbeat readers in the process, and fails the run otherwise. It runs once, after all tests, so it doesn't depend on test order.test.Goroutinesparses the text goroutine profile. If its format changes, a count of 0 would make every "no readers left" check pass. The count finds its own goroutine in the profile, with the group label, or panics. It also panics on a stack deep enough that the profile might have truncated it, since truncation drops the outer frames, like the reader'srun.test.HeartbeatReaderFuncandtest.HeartbeatReaderTimeoutreplace the copies in both test packages. The new tests from Set up repl.lag once and apply options at every level #196 use them too.🤖 Generated with Claude Code