Update architecture: input batching, priority workers, memory monitoring

- Add input batching to Logic goroutine via non-blocking select loop
- Add two-tier priority queue (high/low) to Worker pool
- Add §10 Memory Monitoring and Management (heap stats, debug page, future governor)
- Update diagram and channel topology to reflect new channels

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Greg Pomerantz 2026-05-08 10:10:06 -04:00
parent 4b048b679e
commit f8f81b1fec

View File

@ -27,15 +27,17 @@ Pad runs three concurrent components coordinated by channels and a single mutex:
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Logic goroutine │ │ Logic goroutine │
│ - select on: inputChan, worker results, async I/O │ │ - select on: inputChan, worker results, async I/O │
│ - Drains inputChan completely (batching) before layout │
│ - Processes input: state mutation + layout computation │ │ - Processes input: state mutation + layout computation │
│ - Dispatches long-running tasks to workers │ - Dispatches work to priority-aware worker pool
│ - Sends computed frames on frameChan │ │ - Sends computed frames on frameChan │
│ - Guarantees: process + layout < 16 ms │ - Guarantees: process + layout < 16 ms
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
↓ workChan (dispatch) ↑ resultChan (receive) highWorkChan / lowWorkChan (dispatch)
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Worker pool (fixed goroutines) │ │ Worker pool (fixed goroutines) │
│ - Priority-aware: highWorkChan > lowWorkChan │
│ - File I/O, diff computation, undo replay on open │ │ - File I/O, diff computation, undo replay on open │
│ - Results posted on resultChan │ │ - Results posted on resultChan │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
@ -73,22 +75,67 @@ This goroutine exists so that the logic goroutine can send frames without blocki
Owns all application state. Runs a `select` loop over multiple channels. Responsibilities: Owns all application state. Runs a `select` loop over multiple channels. Responsibilities:
- **Input processing**: receives user input from `inputChan`, mutates state, computes new layout - **Input batching**: drains `inputChan` completely before proceeding to state mutation and layout. This ensures that multiple rapid input events (e.g., fast typing or scroll momentum) are processed together, preventing "input lag" where the UI stays one frame behind the user.
- **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state - **Input processing**: mutates state based on the batched input, computes new layout.
- **Work dispatch**: identifies long-running tasks (file I/O, diff, undo replay) and dispatches them to the worker pool - **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state.
- **Frame production**: computes `[]Element` from current state, sends on `frameChan` - **Work dispatch**: identifies long-running tasks and dispatches them to the worker pool via the appropriate priority channel (`highWorkChan` or `lowWorkChan`).
- **State persistence**: persists state to disk (sync or async, runtime decision — see §5) - **Frame production**: computes `[]Element` from current state, sends on `frameChan`.
- **State persistence**: persists state to disk (sync or async, runtime decision — see §5).
The logic goroutine is the sole owner of mutable state. No other goroutine reads or writes state directly. The logic goroutine is the sole owner of mutable state. No other goroutine reads or writes state directly.
#### Input Batching Implementation
The logic goroutine uses a looping non-blocking `select` to drain the input channel:
```go
for {
select {
case input := <-inputChan:
state.Apply(input)
default:
// Channel is empty, proceed to layout
goto layout
}
}
layout:
// Compute frame...
```
### 2.4 Worker Pool ### 2.4 Worker Pool
A fixed set of goroutines (e.g., 4) that execute long-running tasks. Responsibilities: A fixed set of goroutines (e.g., 4) that execute long-running tasks with two-tier priority.
- **File I/O**: reading file chunks, writing auto-saves, persisting state files #### Priority Tiers
- **Diff computation**: computing diffs for conflict resolution, external update detection
- **Undo replay on open**: replaying undo chains when restoring state across sessions 1. **High Priority (`highWorkChan`)**: UI-critical operations that block user progress.
- **Line index building**: streaming pass over a file to build the line-offset index - Loading a file chunk for the cursor/viewport.
- Loading a directory page in the browser.
- Computing a diff for an active merge session.
2. **Low Priority (`lowWorkChan`)**: Background operations that can be delayed without affecting responsiveness.
- Auto-saving file content.
- Persisting undo stacks.
- Building/updating the line index.
#### Implementation
Workers prioritize high-priority tasks using a nested `select`:
```go
for {
select {
case work := <-highWorkChan:
work.Execute()
default:
select {
case work := <-highWorkChan:
work.Execute()
case work := <-lowWorkChan:
work.Execute()
}
}
}
```
Workers post results on `resultChan`. The channel is sized to the worker pool size so sends never block. Workers post results on `resultChan`. The channel is sized to the worker pool size so sends never block.
@ -97,9 +144,10 @@ Workers post results on `resultChan`. The channel is sized to the worker pool si
| Channel | Direction | Purpose | Blocking behavior | | Channel | Direction | Purpose | Blocking behavior |
|---|---|---|---| |---|---|---|---|
| `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). | | `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). |
| `inputChan` | Main → Logic | User input events (taps, keys, scroll) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. Logic's 16ms guarantee prevents main from blocking. | | `inputChan` | Main → Logic | User input events (taps, keys, scroll) | Unbuffered. Logic drains via batching loop, main sends after releasing mutex. |
| `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. Pool is bounded, so backlog is bounded. | | `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. |
| `workChan` | Logic → Workers | New async work items | Buffered to pool size. Logic dispatches and continues; workers drain at their own pace. | | `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to pool size. |
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to pool size. |
### Why Unbuffered Channels Work ### Why Unbuffered Channels Work
@ -107,11 +155,11 @@ Buffered channels were initially considered for deadlock avoidance. They are not
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store operation (pointer assignment + unlock). The logic goroutine's send blocks only during this brief window, which is sub-millisecond. 1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store operation (pointer assignment + unlock). The logic goroutine's send blocks only during this brief window, which is sub-millisecond.
2. **`inputChan`**: The main goroutine sends input after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one input event per frame (Gioui event loop pace). The logic goroutine's processing pace exceeds the main goroutine's production pace, so the channel never backs up. 2. **`inputChan`**: The main goroutine sends input after releasing the mutex. The logic goroutine processes input in < 16ms and drains the channel completely before layout. The main goroutine produces at most one input event per frame (Gioui event loop pace). The logic goroutine's processing pace exceeds the main goroutine's production pace, so the channel never backs up.
3. **`resultChan`**: The worker pool is bounded (fixed goroutine count). Results are posted as workers complete. The logic goroutine drains results in its `select` loop before processing the next input event. The bounded pool ensures the result rate is bounded. 3. **`resultChan`**: The worker pool is bounded (fixed goroutine count). Results are posted as workers complete. The logic goroutine drains results in its `select` loop before processing the next input event. The bounded pool ensures the result rate is bounded.
4. **`workChan`**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path — logic can send a frame before dispatching. 4. **Work Channels**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path — logic can send a frame before dispatching. Priority ensures critical tasks jump the queue.
## 4. Synchronous/Asynchronous Partitioning ## 4. Synchronous/Asynchronous Partitioning
@ -308,3 +356,30 @@ The concurrency model has no shared mutable state between goroutines (state is o
- **Merge state does not survive**: if the app dies during conflict resolution, the merge session is re-computed on relaunch (diff is idempotent) - **Merge state does not survive**: if the app dies during conflict resolution, the merge session is re-computed on relaunch (diff is idempotent)
The recovery flow (SPEC §8) reads persisted state, restores the active file, cursor, and undo stack. The concurrency model restarts fresh: new goroutines, new channels, same invariants. The recovery flow (SPEC §8) reads persisted state, restores the active file, cursor, and undo stack. The concurrency model restarts fresh: new goroutines, new channels, same invariants.
## 10. Memory Monitoring and Management
Pad implements a monitoring system for heap usage and latency, with hooks for future global memory management.
### 10.1 Monitoring
A background profiler goroutine periodically collects system metrics:
- **Heap Usage**: Collected via `runtime.ReadMemStats`. Tracks total allocated memory and heap size.
- **Persistence Latency**: Tracks the EMA of state persistence duration (see §5).
- **Logic Latency**: Tracks the time taken for the Logic goroutine to process input and compute layout.
These metrics are reported on a **Debug Page** within the application, allowing for real-time performance analysis during development and by power users.
### 10.2 Memory Pressure (Future Enhancement)
While initial versions use per-component eviction (e.g., LRU for chunk cache and line indices), a future **Global Memory Governor** is planned:
- **Centralized Eviction**: Instead of components deciding when to evict independently, the Governor monitors total heap pressure.
- **Pressure Tiers**:
- **Normal**: No proactive eviction.
- **Warning**: Proactively evict oldest line indices and non-visible file chunks.
- **Critical**: Evict all non-visible data; force garbage collection.
- **Implementation**: Components register "evictable" resources with the Governor, which calls back to release memory when thresholds are exceeded.
This ensures that the "No Practical Limits" philosophy does not lead to Out-Of-Memory (OOM) crashes on devices with limited RAM.