fix(progress): add per-page progress callback during batch hook execution#689
Conversation
…tion Adds BatchProgressFunc type and RunBatchWithProgress method that threads a progress callback through to batch hook functions. BatchCallHook uses an atomic counter to report completion across concurrent worker goroutines. The Transforms stage now reports real-time progress during hook execution instead of jumping to 100% after all results return. RunBatchWithTimeout delegates to RunBatchWithProgress with nil callback, preserving backward compatibility. Closes #686 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds real-time progress reporting during the Transforms (onPageRendered) batch hook stage, which previously sat idle for many seconds on large sites and then jumped straight to 100%. A new BatchProgressFunc callback is threaded from HookRegistry.RunBatchWithProgress through the registry batch closures down into NodeRuntime.BatchCallHook, which fires the callback (using an atomic.Int64) after each per-page bridge.Send completes across the worker pool.
Changes:
- Add
BatchProgressFuncand extendBatchHookFuncto take anonProgressarg; introduceRunBatchWithProgresswithRunBatchWithTimeoutdelegating for backward compat. - Update
NodeRuntime.BatchCallHooksignature and increment a shared atomic counter per completed page, invoking the callback from worker goroutines. - Wire a progress closure from the Transforms stage in
build.goand drop the now-unused per-resultreportUpdatetiming on the result-application loop.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| internal/plugin/hooks.go | New BatchProgressFunc type, updated BatchHookFunc signature, and RunBatchWithProgress implementation. |
| internal/plugin/node.go | BatchCallHook accepts progress callback and reports per-page completion via atomic.Int64. |
| internal/plugin/registry.go | Type assertion and three batchFn closures updated to thread the progress callback into BatchCallHook. |
| internal/pipeline/build.go | Transforms stage uses RunBatchWithProgress with a closure that calls reportUpdate; removes the post-batch per-page reportUpdate timing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var progressFn plugin.BatchProgressFunc | ||
| if reporter != nil { | ||
| progressFn = func(completed, total int) { | ||
| if completed > 0 && completed <= len(pages) { | ||
| reportUpdate(reporter, completed, pages[completed-1].RelPath, 0) | ||
| } | ||
| } | ||
| } | ||
| results, err := ps.Hooks.RunBatchWithProgress(plugin.OnPageRendered, payloads, progressFn) |
There was a problem hiding this comment.
Fixed in c3d7da8. Added sync.Mutex around the reportUpdate call in the progress callback. Concurrent worker goroutines now serialize through the lock before writing to the reporter.
| if reporter != nil { | ||
| progressFn = func(completed, total int) { | ||
| if completed > 0 && completed <= len(pages) { | ||
| reportUpdate(reporter, completed, pages[completed-1].RelPath, 0) |
There was a problem hiding this comment.
Fixed in c3d7da8. Dropped pages[completed-1].RelPath — the atomic counter is a running total across concurrent workers, not an index into the pages slice. Progress callback now passes empty string for the file path; the count alone is accurate.
| } else { | ||
| itemProgress = func(int, int) {} |
There was a problem hiding this comment.
The no-op closure is required by the spec — test "nil progress callback does not panic" (hooks_test.go:522) calls onProgress(i+1, len(ps)) without nil-checking and expects RunBatchWithProgress to supply a no-op. However, I removed the pointless wrapper indirection per comment #4 — onProgress is now passed directly when non-nil, and only the nil case gets a no-op.
| var itemProgress BatchProgressFunc | ||
| if onProgress != nil { | ||
| itemProgress = func(completed, total int) { onProgress(completed, total) } | ||
| } else { |
There was a problem hiding this comment.
Fixed in c3d7da8. Removed the wrapper — onProgress is now assigned directly to itemProgress when non-nil.
| @@ -319,8 +327,12 @@ func (r *Registry) registerRuntime(rt PluginFilterRuntime, pluginName string, ho | |||
| return caller.CallHook(name, payload) | |||
| } | |||
| if hasBatch { | |||
| batchFn := func(ctx context.Context, payloads []interface{}) ([]interface{}, error) { | |||
| return batcher.BatchCallHook(name, payloads) | |||
| batchFn := func(ctx context.Context, payloads []interface{}, onProgress BatchProgressFunc) ([]interface{}, error) { | |||
| var itemCb func(int) | |||
| if onProgress != nil { | |||
| itemCb = func(completed int) { onProgress(completed, len(payloads)) } | |||
| } | |||
| return batcher.BatchCallHook(name, payloads, itemCb) | |||
| } | |||
There was a problem hiding this comment.
Acknowledged. The three closures handle distinct registration paths (RegisterBatchWithOptions with scope, RegisterBatchWithPriority with priority, and the default-priority fallback). Extracting a helper would couple them and make each path harder to read independently. Leaving as-is since the pattern is pre-existing and each closure is 4 lines.
Code Review — PR #689Verdict: Ready with fixes — 9 reviewers, 6 findings (2 P1, 4 P2) P1 — High
P2 — Moderate
Residual risks
Testing gaps
Coverage
Run ID: |
…sure, timeout path - Serialize concurrent progress callbacks with sync.Mutex (P1 data race) - Drop misleading pages[completed-1].RelPath — atomic counter is a running total, not the index of the page that just finished (P1) - Remove pointless wrapper indirection, pass onProgress directly (P2) - Fire onProgress on timeout path in per-item fallback so count always reaches total (P2) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review response (c3d7da8)P1 #1: Wrong page name — fixedDropped P1 #2: Data race — fixedAdded P2 #3: Multi-hook progress reset — acknowledgedEach P2 #4: No-op closure — partially fixedRemoved the pointless wrapper indirection — P2 #5: Timeout path skips onProgress — fixedAdded P2 #6: Partial failure continues calling onProgress — acknowledgedWorker goroutines continue calling Copilot: Registry closure duplication — declinedPre-existing pattern across 3 distinct registration paths. Each closure is 4 lines. Extracting a helper would couple them without meaningful benefit. |
| "sync" | ||
| "strings" |
There was a problem hiding this comment.
Fixed in 2e8984a. Reordered to sort, strings, sync, time.
| progressFn = func(completed, total int) { | ||
| mu.Lock() | ||
| reportUpdate(reporter, completed, "", 0) | ||
| mu.Unlock() |
There was a problem hiding this comment.
Fixed in 2e8984a. Added a high-water mark tracked under the mutex — out-of-order updates from concurrent workers are now discarded. The progress bar only moves forward.
| if onProgress != nil { | ||
| onProgress(j+1, len(current)) | ||
| } |
There was a problem hiding this comment.
Fixed in 2e8984a. Updated BatchProgressFunc docstring to clarify it fires once per dispatched item (success or timeout), not just on successful completion.
Re-Review — PR #689 (batch progress reporting)Verdict: Approve | 10 reviewers | 0 actionable findings 4 of 6 first-review findings addressed. 2 explicitly declined with reasoning. Prior feedback status
New finding — advisoryLeaked goroutine after batch timeout (adversarial P1 + correctness convergent → advisory residual) After batch timeout fires in Demoted to advisory: fix requires threading context through Residual risks (advisory)
Demoted testing gapsTesting reviewer assigned P0/P1/P2 to missing coverage for error paths, concurrent callbacks, and mutex-protected reporter. All demoted: miscalibrated severity (P0 ≠ missing tests for correct code) and scope expansion on architect-owned coverage decisions. Reviewer breakdown
Run: |
…docstring - Track high-water mark under mutex to discard out-of-order progress updates from concurrent workers — prevents progress bar regression - Fix sync import alphabetical ordering (sort, strings, sync, time) - Update BatchProgressFunc docstring to clarify timeout items are included Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review response — round 3 (2e8984a)Reviewer approved with 0 actionable findings. Addressing 3 new Copilot comments: Non-monotonic progress — fixedAdded a high-water mark tracked under the mutex. Concurrent workers may deliver completed counts out of order (worker A gets 5, worker B gets 6, B acquires lock first), but updates where Import ordering — fixedReordered Timeout progress docstring — fixedUpdated |
Summary
BatchProgressFunctype andRunBatchWithProgressmethod toHookRegistryBatchCallHookusesatomic.Int64counter to report completion across concurrent worker goroutinesRunBatchWithTimeoutdelegates toRunBatchWithProgress(... nil)for backward compatibilitybuild.gonow reports real-time per-page progress during hook execution instead of jumping to 100% after all results returnBatchProgressFunc→func(int)forBatchCallHookCloses #686
Test plan
go build ./...compiles cleanly🤖 Generated with Claude Code