convert service to async pattern
This commit is contained in:
parent
2245176cb0
commit
b3645a7bd1
3 changed files with 36 additions and 194 deletions
|
|
@ -30,7 +30,7 @@ repos:
|
||||||
# std.Io.Group async/await methods. `zig fmt` rewrites the
|
# std.Io.Group async/await methods. `zig fmt` rewrites the
|
||||||
# `@"async"`/`@"await"` workaround back to the bare form,
|
# `@"async"`/`@"await"` workaround back to the bare form,
|
||||||
# so we can't dodge it locally either.
|
# so we can't dodge it locally either.
|
||||||
exclude: ^src/(tui|PortfolioData)\.zig$
|
exclude: ^src/(tui|PortfolioData|service)\.zig$
|
||||||
- repo: https://github.com/batmac/pre-commit-zig
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
rev: v0.3.0
|
rev: v0.3.0
|
||||||
hooks:
|
hooks:
|
||||||
|
|
|
||||||
138
TODO.md
138
TODO.md
|
|
@ -5,144 +5,6 @@ ordered roughly by priority within each section. Priority labels
|
||||||
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
||||||
ranking; unlabeled items are "someday, if the mood strikes."
|
ranking; unlabeled items are "someday, if the mood strikes."
|
||||||
|
|
||||||
## Review tab: cursor + symbol selection + drill-down — priority MEDIUM
|
|
||||||
|
|
||||||
The review tab is currently a static table — you can sort it but
|
|
||||||
not select a row. Common workflow: see something interesting on
|
|
||||||
the review tab (e.g. NKE has the worst trailing returns), want to
|
|
||||||
jump straight into the per-symbol detail tabs (performance, quote,
|
|
||||||
options) for it. Today that takes either:
|
|
||||||
|
|
||||||
- typing `/<symbol>` to set the active symbol manually, or
|
|
||||||
- switching to portfolio tab, finding the row, pressing `s` /
|
|
||||||
space / left-click on it, then switching back.
|
|
||||||
|
|
||||||
Both are small but noticeable papercuts when scanning the review
|
|
||||||
table.
|
|
||||||
|
|
||||||
### What to add
|
|
||||||
|
|
||||||
Mirror the portfolio tab's pattern (`src/tui/portfolio_tab.zig`):
|
|
||||||
|
|
||||||
- `State.cursor: usize` — selected row index.
|
|
||||||
- `j`/`k` and arrow keys move the cursor (already routed by the
|
|
||||||
framework's `onCursorMove` hook; opt in by declaring it on the
|
|
||||||
tab).
|
|
||||||
- Mouse wheel scrolls (already handled by App when the tab opts
|
|
||||||
out via `handleMouse` returning false on wheel events).
|
|
||||||
- Mouse click on a data row sets the cursor to that row.
|
|
||||||
- Cursor row gets `selectStyle` highlight in the row's
|
|
||||||
`StyleSpan` set so it visibly stands out.
|
|
||||||
- Active-symbol indicator: rows whose `symbol` matches `app.symbol`
|
|
||||||
get an asterisk or similar marker (matches portfolio tab's
|
|
||||||
star convention).
|
|
||||||
- Press `s` (or space, or Enter) to set `app.symbol` to the
|
|
||||||
cursor row's symbol — same `select_symbol` action portfolio tab
|
|
||||||
binds. The framework validator already prevents tab-local
|
|
||||||
bindings from colliding with global keys, so reusing `s`/space
|
|
||||||
is fine because portfolio tab does it too.
|
|
||||||
|
|
||||||
### Drill-down navigation
|
|
||||||
|
|
||||||
A natural extension once selection works: a hotkey that both
|
|
||||||
selects the symbol AND switches to a per-symbol tab (performance
|
|
||||||
is the obvious target since that's the deep-dive surface). Maybe
|
|
||||||
`Enter` with a row selected → set symbol + jump to performance
|
|
||||||
tab. Compare: portfolio tab's Enter is "expand/collapse"; review
|
|
||||||
rows don't expand, so Enter is free.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Cursor moves on j/k/arrows, clamps at edges.
|
|
||||||
- Click on row N sets cursor to N.
|
|
||||||
- `select_symbol` action sets `app.symbol` and triggers a
|
|
||||||
`loadTabData()` if a downstream tab is active.
|
|
||||||
- Active-symbol asterisk renders for the row matching `app.symbol`.
|
|
||||||
|
|
||||||
## TUI: share candle/dividend maps across tabs — priority MEDIUM
|
|
||||||
|
|
||||||
`App.ensurePortfolioDataLoaded` builds a complete per-symbol
|
|
||||||
`candle_map` via `buildPortfolioData`, uses it once to compute
|
|
||||||
historical-snapshot values, then **frees it** at
|
|
||||||
`src/tui.zig:1404-1406`. Every per-position TUI tab that
|
|
||||||
subsequently needs candles (`review`, `performance`, parts of
|
|
||||||
`portfolio` and `quote`) re-reads them from the SRF cache via
|
|
||||||
`getCachedCandles` — ~27 redundant reads per tab activation on
|
|
||||||
a 27-symbol portfolio, each running its own SRF iterator pass.
|
|
||||||
|
|
||||||
Most visible in debug builds (~2s tab activation); release
|
|
||||||
mode is sub-second but still measurable on first switch.
|
|
||||||
|
|
||||||
### Fix sketch
|
|
||||||
|
|
||||||
Promote `candle_map` and add a `dividend_map` to fields on
|
|
||||||
`App.portfolio.PortfolioData`. Tabs read from
|
|
||||||
`app.portfolio.candle_map` / `app.portfolio.dividend_map`
|
|
||||||
instead of re-fetching. Lifetime tied to the existing
|
|
||||||
`summary` ownership: cleared atomically on portfolio reload,
|
|
||||||
freed once in `PortfolioData.deinit`.
|
|
||||||
|
|
||||||
The review tab's `State.dividend_map` field is removed —
|
|
||||||
that data lives on App now.
|
|
||||||
|
|
||||||
### Loading strategy options
|
|
||||||
|
|
||||||
**Eager.** Populate both maps inside
|
|
||||||
`ensurePortfolioDataLoaded` so they're ready when the first
|
|
||||||
per-symbol tab activates. Pros: tab switches are always
|
|
||||||
instant. Cons: pays the dividend-cache read cost (~27 SRF
|
|
||||||
reads) at TUI startup even if the user never opens a tab
|
|
||||||
that needs them.
|
|
||||||
|
|
||||||
**Lazy.** App exposes `ensureDividendMap()` (parallel to
|
|
||||||
`ensureAccountMap`); first tab to need dividends pays the
|
|
||||||
load. Pros: no startup cost for users who don't open
|
|
||||||
review/performance/etc. Cons: first review-tab activation
|
|
||||||
still slow.
|
|
||||||
|
|
||||||
**Async (recommended middle ground).** On TUI startup, spawn
|
|
||||||
a background task using Zig 0.16.0's `Io` async to populate
|
|
||||||
both maps. Tabs read through a synchronization wrapper —
|
|
||||||
something like:
|
|
||||||
|
|
||||||
```zig
|
|
||||||
pub const PortfolioCache = struct {
|
|
||||||
candle_map: ?std.StringHashMap([]const Candle) = null,
|
|
||||||
dividend_map: ?std.StringHashMap([]const Dividend) = null,
|
|
||||||
ready: std.Thread.Semaphore, // or io.Async equivalent
|
|
||||||
|
|
||||||
pub fn waitReady(self: *PortfolioCache) void { ... }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Tabs that need the maps call `waitReady()` (cheap when
|
|
||||||
already loaded; brief block on first call if the background
|
|
||||||
task hasn't finished). Uses Zig 0.16's `io` async layer so
|
|
||||||
we don't manage thread lifecycle directly. Pros: zero
|
|
||||||
perceived startup cost AND zero perceived tab-switch cost
|
|
||||||
for typical workflows. Cons: more design work; need to
|
|
||||||
handle the failure case (background task errored — fall
|
|
||||||
back to lazy load).
|
|
||||||
|
|
||||||
### Other call sites to audit
|
|
||||||
|
|
||||||
- `src/tui/portfolio_tab.zig:1756` — re-reads cached candles
|
|
||||||
to compute the latest-quote-date footer. Re-route through
|
|
||||||
the shared map.
|
|
||||||
- `src/tui/quote_tab.zig` and `src/tui/performance_tab.zig` —
|
|
||||||
these are intentionally per-symbol-narrow (single ticker
|
|
||||||
detail views), so they probably stay on `getTrailingReturns`
|
|
||||||
but could short-circuit when the candles are already in
|
|
||||||
the shared map.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Lifetime test: portfolio reload clears both maps before
|
|
||||||
the next load assigns new ones; no use-after-free.
|
|
||||||
- Async path: failure in the background task surfaces as a
|
|
||||||
visible status message but doesn't break tab activation
|
|
||||||
(lazy fallback still works).
|
|
||||||
|
|
||||||
## Projections: future enhancements
|
## Projections: future enhancements
|
||||||
|
|
||||||
- **Configurable return cap per position — priority MEDIUM.**
|
- **Configurable return cap per position — priority MEDIUM.**
|
||||||
|
|
|
||||||
|
|
@ -2032,8 +2032,6 @@ pub const DataService = struct {
|
||||||
/// Drives `--refresh-data=never`.
|
/// Drives `--refresh-data=never`.
|
||||||
skip_network: bool = false,
|
skip_network: bool = false,
|
||||||
color: bool = true,
|
color: bool = true,
|
||||||
/// Maximum concurrent server sync requests. 0 = auto (8).
|
|
||||||
max_concurrent: usize = 0,
|
|
||||||
|
|
||||||
/// Map this config to the per-call `FetchOptions` shape.
|
/// Map this config to the per-call `FetchOptions` shape.
|
||||||
/// Convenience for paths that need to pass through to
|
/// Convenience for paths that need to pass through to
|
||||||
|
|
@ -2210,7 +2208,6 @@ pub const DataService = struct {
|
||||||
needs_fetch.items,
|
needs_fetch.items,
|
||||||
&result,
|
&result,
|
||||||
&server_failures,
|
&server_failures,
|
||||||
config,
|
|
||||||
aggregate_progress,
|
aggregate_progress,
|
||||||
total_count,
|
total_count,
|
||||||
);
|
);
|
||||||
|
|
@ -2242,27 +2239,32 @@ pub const DataService = struct {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parallel server sync using thread pool.
|
/// Parallel server sync via `std.Io.Group`.
|
||||||
|
///
|
||||||
|
/// Concurrency shape: one task per symbol, spawned into a
|
||||||
|
/// single `Group`. The `std.Io` implementation owns
|
||||||
|
/// scheduling and concurrency limits (e.g. `Io.Threaded`
|
||||||
|
/// sizes its pool from CPU count); we don't second-guess it
|
||||||
|
/// with our own worker cap or work-stealing queue.
|
||||||
|
///
|
||||||
|
/// Each task hits `io.checkCancel()` before its sync, so a
|
||||||
|
/// cancelation request propagating through `Group.await`
|
||||||
|
/// stops pending work at task granularity.
|
||||||
fn parallelServerSync(
|
fn parallelServerSync(
|
||||||
self: *DataService,
|
self: *DataService,
|
||||||
symbols: []const []const u8,
|
symbols: []const []const u8,
|
||||||
result: *LoadAllResult,
|
result: *LoadAllResult,
|
||||||
failures: *std.ArrayList([]const u8),
|
failures: *std.ArrayList([]const u8),
|
||||||
config: LoadAllConfig,
|
|
||||||
aggregate_progress: ?AggregateProgressCallback,
|
aggregate_progress: ?AggregateProgressCallback,
|
||||||
total_count: usize,
|
total_count: usize,
|
||||||
) void {
|
) void {
|
||||||
const max_threads = if (config.max_concurrent > 0) config.max_concurrent else 8;
|
|
||||||
const thread_count = @min(symbols.len, max_threads);
|
|
||||||
|
|
||||||
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .server_sync);
|
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .server_sync);
|
||||||
|
|
||||||
// Shared state for worker threads
|
// Shared state for tasks
|
||||||
var completed = AtomicCounter{};
|
var completed = AtomicCounter{};
|
||||||
var next_index = AtomicCounter{};
|
|
||||||
const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch {
|
const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch {
|
||||||
// Allocation failed — fall back to marking all as failures
|
// Allocation failed — fall back to marking all as failures
|
||||||
for (symbols) |sym| failures.append(self.allocator, sym) catch |err| log.warn("parallelFetch slots-alloc-fallback failures append({s}): {t}", .{ sym, err });
|
for (symbols) |sym| failures.append(self.allocator, sym) catch |err| log.warn("parallelServerSync slots-alloc-fallback failures append({s}): {t}", .{ sym, err });
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer self.allocator.free(sync_results);
|
defer self.allocator.free(sync_results);
|
||||||
|
|
@ -2272,62 +2274,40 @@ pub const DataService = struct {
|
||||||
sr.* = .{ .symbol = symbols[i], .success = false };
|
sr.* = .{ .symbol = symbols[i], .success = false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn worker threads
|
|
||||||
var threads = self.allocator.alloc(std.Thread, thread_count) catch {
|
|
||||||
for (symbols) |sym| failures.append(self.allocator, sym) catch |err| log.warn("parallelFetch threads-alloc-fallback failures append({s}): {t}", .{ sym, err });
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer self.allocator.free(threads);
|
|
||||||
|
|
||||||
const WorkerContext = struct {
|
|
||||||
svc: *DataService,
|
|
||||||
symbols: []const []const u8,
|
|
||||||
results: []ServerSyncResult,
|
|
||||||
next_index: *AtomicCounter,
|
|
||||||
completed: *AtomicCounter,
|
|
||||||
};
|
|
||||||
|
|
||||||
var ctx = WorkerContext{
|
|
||||||
.svc = self,
|
|
||||||
.symbols = symbols,
|
|
||||||
.results = sync_results,
|
|
||||||
.next_index = &next_index,
|
|
||||||
.completed = &completed,
|
|
||||||
};
|
|
||||||
|
|
||||||
const worker = struct {
|
const worker = struct {
|
||||||
fn run(wctx: *WorkerContext) void {
|
fn run(io: std.Io, svc: *DataService, slot: *ServerSyncResult, done: *AtomicCounter) std.Io.Cancelable!void {
|
||||||
while (true) {
|
defer _ = done.increment();
|
||||||
const idx = wctx.next_index.increment();
|
try io.checkCancel();
|
||||||
if (idx >= wctx.symbols.len) break;
|
slot.success = svc.syncCandlesFromServer(slot.symbol);
|
||||||
|
|
||||||
const sym = wctx.symbols[idx];
|
|
||||||
const success = wctx.svc.syncCandlesFromServer(sym);
|
|
||||||
wctx.results[idx].success = success;
|
|
||||||
_ = wctx.completed.increment();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start threads
|
// Spawn one task per symbol. Group.async requires an
|
||||||
var spawned: usize = 0;
|
// eventual Group.await/cancel to release resources; the
|
||||||
for (threads) |*t| {
|
// single await below covers all paths.
|
||||||
t.* = std.Thread.spawn(.{}, worker.run, .{&ctx}) catch continue;
|
var group: std.Io.Group = .init;
|
||||||
spawned += 1;
|
for (sync_results) |*sr| {
|
||||||
|
group.async(self.io, worker.run, .{ self.io, self, sr, &completed });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress reporting while waiting
|
// Progress reporting while the group runs
|
||||||
if (aggregate_progress) |p| {
|
if (aggregate_progress) |p| {
|
||||||
while (completed.load() < symbols.len) {
|
while (completed.load() < symbols.len) {
|
||||||
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(50), .awake) catch |err| log.debug("parallelFetch progress-poll sleep interrupted: {t}", .{err});
|
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(50), .awake) catch |err| {
|
||||||
|
log.debug("parallelServerSync progress-poll sleep interrupted: {t}", .{err});
|
||||||
|
break;
|
||||||
|
};
|
||||||
p.emit(result.cached_count + completed.load(), total_count, .server_sync);
|
p.emit(result.cached_count + completed.load(), total_count, .server_sync);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all threads
|
// Wait for all tasks. On cancelation the unstarted tasks
|
||||||
for (threads[0..spawned]) |t| {
|
// exit at their checkCancel point; partial results (slots
|
||||||
t.join();
|
// that completed) are still processed below — they came
|
||||||
}
|
// from successful cache writes.
|
||||||
|
group.await(self.io) catch |err| {
|
||||||
|
log.debug("parallelServerSync group await: {t}", .{err});
|
||||||
|
};
|
||||||
|
|
||||||
// Process results
|
// Process results
|
||||||
for (sync_results) |sr| {
|
for (sync_results) |sr| {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue