Refactor Core Architecture & Eliminate Polling#522
Open
SuperCoolPencil wants to merge 45 commits into
Open
Conversation
…rker notification
…oduce event-driven orchestrator components
…d ProgressAggregator components
…tatic-analysis linting for Unicode literals
…te fields for improved type consistency
…and implementing a bitmask-based tracker
Binary Size Analysis
|
…lidation using master list data
…d resume event publish
…m Add and AddWithID service methods
…tenv while cleaning up orchestrator test constants
…d update event handling logic
… filename collision exhaustion
…r wait threshold for 429 concurrency tests
…ertions, and implement synchronous download persistence
…aster list versioning
❌ Test Failures on
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Architecture Refactor: High Level Design
This document visually illustrates the recent structural changes to the Surge architecture, moving from a tightly coupled monolithic engine to a layered, domain-driven design.
The Old Architecture (Monolithic)
In the previous design, the
internal/enginepackage acted as a catch-all monolith. The UI and Core tightly coupled to the engine's internal workings, and state was updated via direct callbacks and polling.graph TD A[TUI / CLI] --> B[internal/core] B --> C[internal/processing] C --> D((internal/engine)) subgraph Monolithic Engine D --> E[engine/concurrent] D --> F[engine/single] D --> G[engine/transport] D --> H[engine/state] D --> I[engine/types/progress.go] end E -.-> |Hooks / Polling| C F -.-> |Hooks / Polling| CThe New Architecture (Decoupled & Layered)
The new architecture introduces strict layering and unidirectional data flow. Components are isolated, and the monolithic
enginehas been decomposed intostrategy,scheduler,orchestrator,progress, andstore. TheEventBusreplaces direct callback hooks, andServiceprovides a clean API boundary.graph TD UI[TUI / CLI] --> API[Service Layer<br>internal/service] API --> ORCH[Orchestrator Layer<br>internal/orchestrator] subgraph Core Domain ORCH --> SCHED[Scheduler Layer<br>internal/scheduler] SCHED --> STRAT[Strategy Layer<br>internal/strategy] STRAT --> C[concurrent] STRAT --> S[single] STRAT --> PROG[Progress Tracking<br>internal/progress] STRAT --> TRANS[Transport / Network<br>internal/transport] end STRAT -.-> |Publishes Typed Events| BUS[EventBus] PROG -.-> |Publishes Stats| BUS BUS -.-> |Subscribed Stream| API API -.-> |Real-time Updates| UI ORCH --> STORE[State / DB Layer<br>internal/store]Download Lifecycle Flow
Here is how a download request flows through the new system:
sequenceDiagram participant U as User (UI/CLI) participant S as Service participant O as Orchestrator participant SCH as Scheduler participant W as Strategy (Worker) participant B as EventBus U->>S: Add(url) S->>O: Enqueue Download O->>B: Publish(State: Queued) O->>SCH: Submit Task SCH->>W: Execute(Strategy) W->>B: Publish(State: Downloading) loop During Download W->>W: Fetch Chunks W->>B: Publish(ProgressEvent) end W->>SCH: Complete/Error W->>B: Publish(State: Completed) SCH->>O: Task DoneGreptile Summary
This PR decomposes the monolithic
internal/enginepackage into layered domains (strategy,scheduler,orchestrator,progress,store,service) and replaces direct callback hooks with a typedEventBus. The change touches 152 files and corrects several previously-flagged issues: thestateProgresswrong-type assertion in the TUI, theensureDirsdata race in the store, theReloadSettingsno-op, and thebroadcastLoopshutdown drain.EventBus(internal/orchestrator/event_bus.go): replaces polling with a subscriber-fan-out model; drain loop before close and correct shutdown ordering (GracefulShutdown→eventBus.Shutdown) are now in place.enqueueResolvedpersistence race (internal/orchestrator/manager.go): the synchronousAddToMasterListfor the queued record is called afterdispatchToScheduler/pool.Add, so a fast pool worker can emitEventStartedtoInputChfirst; the event worker then processesEventStarted("downloading") followed byEventQueued("queued"), leaving the master-list entry with the wrong status during the download's lifetime.time.Aftertimer leak (internal/orchestrator/event_bus.go): bothbroadcastMsg(non-progress path) andPublishcreate atime.Timerviatime.Afteron every call without stopping it when the send branch is taken; the timers leak for 1 second after each successful send.Confidence Score: 3/5
The enqueue path can leave a download's master-list status as 'queued' while it is actively downloading, which affects crash-recovery resume fidelity.
The ordering of
dispatchToSchedulerbefore the synchronousAddToMasterListinenqueueResolvedmeans a fast pool worker can writeEventStartedtoInputChbefore the queued record is persisted. The event worker then processesEventStartedthenEventQueuedin that order, overwriting the correct 'downloading' status with 'queued'. Any crash between that point and the next lifecycle event leaves the entry in a stale state that prevents correct resume-from-chunk behaviour.internal/orchestrator/manager.go(enqueueResolved persist ordering) andinternal/orchestrator/events.go(EventQueued handler unconditional overwrite) deserve the most attention.Important Files Changed
dispatchToScheduleris called before the synchronousAddToMasterListinenqueueResolved, creating a race whereEventStartedcan arrive inInputChbeforeEventQueuedand corrupt the download's DB status to "queued" while it is actively downloading.broadcastMsgandPublishusetime.Afterwithout explicit timer stop, leaking a goroutine per invocation for up to 1 second on successful sends.EventQueuedhandler unconditionally overwrites any existing master-list entry withStatus: "queued", which can regress a concurrent "downloading" entry set by the earlierEventStartedhandler.LoadStateswith master-list lookup so queued/never-started downloads can be resumed;buildResumeConfigcorrectly copies task list and chunk bitmap from savedState.ensureDirsnow snapshotsconfigured/baseDirundermasterMu.RLock()before use;cleanupOrphansclosure uses the captureddirparameter rather than the package-level variable; data-race concerns from previous reviews appear addressed.stateProgressnow correctly assertsstateto*types.DownloadRecordthen callsengineprogress.CfgProgress(dr), fixing the previously broken chunk-map visualization.ReloadSettingsnow delegates tolifecycle.ApplySettings(config.LoadSettings())instead of being a no-op; service layer correctly wraps the lifecycle manager.uniqueFilePathnow returns""when all 100 candidates are taken, and callers can detect the failure;RunDownloadusesprogress.CfgProgressfor safe narrowing throughout.CfgProgress(previously duplicated across orchestrator, scheduler, and service packages); correctly guards against nil ProgressState.DecodeSSEMessagesignature cleaned up: unusedeventStrparameter and always-trueboolreturn removed;Statefield correctly typed as*DownloadRecord.Comments Outside Diff (8)
internal/orchestrator/pause_resume.go, line 211-232 (link)ResumeBatchcold path fails for downloads without a detail state filestore.LoadStates(coldIDs)only reads fromdetails/<id>.gobfiles written bySaveStateWithOptions(i.e., downloads that have been through at least oneEventPausedcycle). Queued downloads that never started, and errored downloads that never saved a pause snapshot, have no detail file. For these IDsstates[id]will be absent, andResumeBatchreturns"download not found or completed".In contrast, the single
Resumepath callsstore.GetDownload(id)first and succeeds even whenLoadStatefails, becausebuildResumeConfigaccepts anilsavedStateand falls back to the master-listentry. The batch path should do the same: supplementLoadStateswith a batch master-list lookup and fall through tobuildResumeConfig(id, outputPath, entry, nil, settings)for IDs not present in the detail map.Prompt To Fix With AI
internal/orchestrator/pause_resume.go, line 246-252 (link)savedStateis nil when a download lives only in the master list (queued but never started or saved a pause snapshot). At this pointbuildResumeConfigsucceeds because it falls back toentry, but the immediately followingsavedState.Filenamedereference panics. AnyResumeBatchcall that includes a download that was queued and never executed (e.g., the app was killed before the scheduler ran it) will crash the process.Prompt To Fix With AI
internal/orchestrator/manager.go, line 331-342 (link)EventQueuedmeans download is lost on restartPublishreturnscontext.DeadlineExceededafter 1 second ifInputChis full; calling code discards the error with_ =. When this happens the download is already in the scheduler pool butStartEventWorkernever seesEventQueued, sostore.AddToMasterListis never called. The in-memory entry disappears on the next restart and the user has no indication the download was lost. The pool is also cleared byGracefulShutdownbefore state is written, so there's no recovery path.InputChcan fill up whenbroadcastLoopis delayed by slow subscribers (each non-progress event waits up to 1 s per subscriber inbroadcastMsg), making this reachable without a crash — a single lagging TUI or HTTP SSE client is sufficient.Consider logging or surfacing a non-nil
Publisherror here, or using a directstore.AddToMasterListcall as a synchronous fallback before the event is dispatched.Prompt To Fix With AI
internal/scheduler/manager.go, line 69-71 (link)uniqueFilePathsilently returns the originalpath— which is known to conflict (the existence check at the top of the function confirmed it). Any caller expecting a non-conflicting path from this function would silently overwrite an existing file. The fallback should either return an error-sentinel empty string or append a timestamp/random suffix to guarantee a unique result.Prompt To Fix With AI
cmd/root.go, line 114-124 (link)ProgressStatewill panic on unexpected typescfg.ProgressState.(*progress.DownloadProgress)is used without the comma-ok form in two places here. BecauseProgressStateis typed asinterface{}, any value that is not*progress.DownloadProgresstriggers a panic insidebuildActiveDownloadChecker. The rest of the codebase consistently usesprogress.CfgProgress(&cfg)for this narrowing, which handles nil and wrong-type gracefully — the new code should use the same helper.Prompt To Fix With AI
internal/store/db.go, line 31-35 (link)configuredandbaseDirread without a lock inensureDirsConfigureandCloseDBwriteconfiguredandbaseDirundermasterMu, butensureDirsreads both without holding any lock. WhenSaveStateWithOptionscallsensureDirsbefore acquiringmasterMu, a concurrentConfigureorCloseDBcall races with these reads. The Go race detector will flag this. HoldingmasterMu.RLock()for the read-only check at the top ofensureDirswould close the race.Prompt To Fix With AI
internal/store/db.go, line 43-48 (link)cleanupOrphans(baseDir)reads package variable without a lockThe PR applied the previous review's suggestion by reading
configuredandbaseDirundermasterMu.RLock()and snapshotting them intoisConfigured/dir— but thecleanupOnce.Doclosure at line 45 reads the package variablebaseDirdirectly instead of the captureddir. This is still a concurrent read without the lock.Race scenario: Thread A releases
masterMu.RUnlock()withdir="/path1", Thread B callsCloseDB(setsbaseDir="") thenConfigurewith/path2(setsbaseDir="/path2"), Thread A enterscleanupOnce.Doand callscleanupOrphans("/path2")— cleaning temp files from an unrelated base directory. On Linux, ifbaseDiris""when the closure fires,os.ReadDir("")traverses the current working directory and can delete any.tmp-*files found there.Prompt To Fix With AI
internal/orchestrator/manager.go, line 312-362 (link)dispatchToSchedulerat line 312 callsmgr.pool.Add(cfg), which may immediately schedule a pool worker. That worker callssafeSendProgress(cfg.ProgressCh, EventStarted)— writing directly toInputCh— beforeenqueueResolvedreaches thestore.AddToMasterListcall at line 342 or thePublish(EventQueued)call at line 358. ThebroadcastLoopthen serialises them forStartEventWorkerin arrival order:EventStartedis processed first (sets status"downloading"), thenEventQueuedis processed second (unconditionalAddToMasterListwithStatus: "queued"atevents.go:331), overwriting the correct status. Any crash between theEventQueuedwrite and the next lifecycle event (EventComplete/EventPaused/EventError) leaves the DB entry as"queued"for a download that had already started, preventing correct crash-recovery resume.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (11): Last reviewed commit: "fix: handle EventRemoved state cleanup a..." | Re-trigger Greptile