Skip to content

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 23 May 03:45
· 130 commits to main since this release

概述

v0.3.0 是一次全栈性能批次升级:照着 docs/plans/2026-05-22-performance-improvement-plan.md 三档 21 条全部落地,主线四件事:

  1. 前端首屏与渲染:bundle 从 227 KB 拆到 37 KB(vendor chunk 独立 + 首页换轻量 SVG),编辑录入页不再每键全树重渲染
  2. 后端 hot path:snapshot 构建 N+1 消除、FX 同步阻塞 30 s 改异步、分析端点加进程级缓存
  3. 桌面冷启动:daily snapshot 改 daemon 线程不再卡 /health,loading 页接真实 backend stage
  4. 数据模型清理:新增 daily_totals slim 表为未来轻量端点铺路,去掉无消费方的 event snapshot 双写让单次编辑后端 IO 减半

每条改动独立 commit 独立测试,共 22 个 commit。后端 98/101 通过、前端 76/76 全过、桌面 67/67 全过(3 个 fail 全部在 v0.2.0 baseline 同样 fail,与本次性能改动无关)。


主要变更

1. 前端首屏与 bundle 体积

  • index.js 从 227 KB 缩到 37 KB(gzip 73 → 13):vite.config.ts 拆出 react-vendor(react + react-dom + react-router-dom + @tanstack/react-query)+ icons-vendor(lucide-react)两个独立 chunk。业务代码迭代时 vendor 不变,用户增量下载只命中差量
  • 首屏 LCP 路径剥离 ~600 KB echarts:OverviewPage 趋势图换成新写的纯 SVG SparklineTrend(~120 行,含三条折线 / 端点圆点 / 渐变面 / 图例 / 起止日期),不再触发 echarts-core (436 KB) + zrender (173 KB) + echarts-react (17 KB) 链下载。分析看板仍用完整 ECharts
  • ECharts 增量 setOption:去掉 notMerge: true(之前每次 option 引用变化都重建实例),6 个 chart 组件补全 useMemo 包 buildXxxOption,ResizeObserver 加 requestAnimationFrame 合批。切换 analytics 日期范围 / 滚动 / 勾选 / 输入不再触发图表抖动

2. React Query 缓存与 re-render

  • 拆 holding mutate invalidate 范围:原一条编辑触发 8 个 query key 失效,分析 6 个重端点并发雪崩。缩到核心 2 个 key(holdings.all() + analyticsDateBounds.all()),分析端点统一加 staleTime: 30s,用户切回分析页才自然 stale 重 fetch;新增 invalidateAllHoldingDependentQueries 给 CSV import / bulk delete 等真正大批量场景
  • 合并 settings 缓存 key:原 OverviewPage / AnalyticsPage / EntryPage 三处用 settings.scope('xxx') 切成 3 份独立缓存,跨页切换重复 fetch;统一改 settings.all() 共享一份
  • AnalyticsPage 加 placeholderData: keepPreviousData:5 个 date-range 分析 query 在日期切换时不再回退 Skeleton 闪屏,旧数据占位过渡到新数据
  • EntryPage / EntryHoldingsTable 渲染重做:模块级 EMPTY_HOLDINGS / EMPTY_MEMBERS 常量替代 ?? [] 稳定引用;buildGroups 加 useMemo;GroupBlock 包 React.memo;handler 全部 useCallback 锁;keyword 输入用 useDeferredValue 让 input 立即响应,过滤计算延后

3. 后端 hot path

  • snapshot 构建消除 N+1:_build_snapshot_payload 原每条 holding 跑 3 次 session.get(Category) = 40 条 holding 120 次单查;改一次 SELECT ... WHERE id IN (...) 批量预取,DB 往返 -90%+。被 trend / sankey / rebalance / currency-overview 与每次 holding 写路径反复调用
  • FX resolve_rate_for_pair 同步阻塞 30 s 改异步:原汇率缺失时同步调 refresh_rates()(httpx 5s × 3 retries × 2 providers = 最长 30 s 阻塞);改为「exact → 历史 fallback(is_estimated)+ 后台 daemon 线程 refresh → 真冷启动才同步」三段。录入新币种 P99 延迟从 30 s 降到 ~100 ms
  • build_daily_series 进程级缓存:trend / volatility / correlation 三端点同 cold load 反序列化 N 天 payload 3 次;现共享 LRU cache(max 32 entries),版本指纹由 (MAX(snapshot_date), COUNT, MAX(holdings.updated_at)) 自动失效
  • currency-overview 改读 latest snapshot:跟 sankey / rebalance 一致,不再调 build_current_payload 重新跑全表 SELECT + N+1
  • get_default_family + tz + sankey member name 三处合并优化:前两者加 Session.info 级缓存(请求生命周期内复用),sankey member 改单条 IN(...) batch 替代 N 次 get_scoped_member

4. 桌面冷启动与运行时

  • daily snapshot 改 daemon 线程异步执行:原 bootstrap_runtime 同步跑 create_daily_snapshot,/health 在快照写完前不返回 200,Electron loading 页要等这一步。改异步后桌面冷启动 -1 ~ -3 s
  • loading 页接真实 backend stage:原 1700 ms fake 轮播假进度,跟 backend ready 节奏脱钩。现在主进程通过 webContents.executeJavaScript 推送真实文案(「正在等待本地服务就绪」/「正在加载主界面」),fake 仅作为兜底
  • 更新下载进度节流 250 ms:原每个 chunk 都触发 state.json fsync + IPC 广播,100 MB 包对应 20-1600+ 次写盘;节流后写盘 -95%+,更新期间 UI 流畅
  • BackgroundScheduler 1 worker + lazy 实例化:APScheduler 默认 10 worker,常驻 ~20 MB 内存白付;本项目只有两个 job 互不并发,限到 1 worker;模块 import 时不再立即实例化

5. 数据模型清理

  • 新增 daily_totals slim 表:alembic migration 创建 + 一次性回填存量 snapshot_daily.payload_json.totals;create_daily_snapshot 双写。snapshot_daily 仍是 holding 粒度真理源,新表给未来 totals-only 端点(如轻量净资产趋势)提供不必反序列化的快路径
  • 去掉 holding mutate 时的 event snapshot 双写:grep 全仓库无任何前端 / 脚本消费 /api/v1/snapshots/events,是写而不读的纯历史记录。_refresh_snapshots 改为只刷 daily snapshot;高价值事件(CSV import / settings 改 base_currency)仍由 import_service / settings_service 显式写。单条 holding mutate 后端 IO -50%
  • 新增 metadata-only summary 端点:/api/v1/snapshots/events/summary 与 /snapshots/daily/summary 不反序列化 payload_json(events 返 4 个 metadata 字段、daily 直接读 daily_totals 新表),相同 limit 下响应体积 -95%。旧端点保留向后兼容

6. 构建与打包

  • PyInstaller bundle 瘦身:backend/build_desktop.py 加 --exclude-module 列表(watchfiles / tkinter / unittest / test / pytest / _pytest / setuptools),DMG 估计 -10 ~ -30 MB,冷启动 import -0.5 ~ -1.5 s

收益对照表(可验证)

指标 Before After 验证点
Bundle index.js 227 KB / 73 KB gzip 37 KB / 13 KB gzip M5 build 输出
首屏 LCP 必下载 ~860 KB / ~230 KB gzip ~250 KB / ~80 KB gzip M6 OverviewPage 不再 import echarts
单条 holding 编辑后端 IO 2× JSON + 2× INSERT 1× JSON + 1× INSERT L3 _refresh_snapshots 去 event 写
单条 holding 编辑前端请求 8 个 query invalidate 2 个(holdings + dateBounds) Q2 拆 invalidate 范围
_build_snapshot_payload DB 往返 3N 次 1 次 IN 预取 Q5 N+1 修复
FX 新币种 P99 最长 ~30 s ~100 ms Q6 异步化
桌面冷启动 /health 含 1-3 s 同步 daily snapshot 不卡 Q7 异步化
更新下载 IO/IPC 每 chunk 1× fsync + 1× IPC 每 250 ms ≤1 次 Q8 节流
Snapshots list 响应 几 MB(含全 payload) summary 端点 -95% L5 metadata-only
Scheduler 常驻 worker 10 1 M8

升级说明

  • 存量用户:alembic migration 自动创建 daily_totals 新表 + 回填存量数据;event snapshot 表保留不动,老数据完整可读;分类与 holdings schema 完全不变。无需手动操作
  • 开发期:删 backend/data/app.db 不再必要(migration 自动升级);如需重建,bootstrap 仍会写入方案 D 树
  • 桌面端:v0.2.0 用户启动后自动检测、后台静默下载,点击通知确认即可升级(流程跟 v0.2.0 一致,无需 Gatekeeper 二次授权)

构建产物



Overview

v0.3.0 is a full-stack performance batch: every one of the 21 items in docs/plans/2026-05-22-performance-improvement-plan.md lands across all three tiers (Quick Wins / Medium / Large). Four main threads:

  1. Front-end first-paint and render: the bundle's index.js drops from 227 KB to 37 KB (vendor chunks split off, the home page replaces ECharts with a lightweight SVG), and the entry page no longer re-renders the whole tree on every keystroke
  2. Backend hot paths: snapshot N+1 elimination, FX 30-second sync block turned async, analytics endpoints get a process-level cache
  3. Desktop cold start: the daily snapshot moves off the lifespan thread so /health no longer waits for it; the loading page now reflects real backend stages
  4. Data-model cleanup: a new daily_totals slim table prepares for future lightweight endpoints, and the no-consumer event-snapshot dual-write is removed, halving the backend IO per holding mutation

Every item lands as its own commit with its own tests — 22 commits in total. Backend tests 98/101 pass, frontend 76/76, desktop 67/67 (the three failures all reproduce on v0.2.0 main and are unrelated to this batch).


Highlights

1. Front-end first-paint and bundle size

  • index.js from 227 KB to 37 KB (gzip 73 → 13): vite.config.ts carves out react-vendor (react + react-dom + react-router-dom + @tanstack/react-query) and icons-vendor (lucide-react) into independent chunks. On subsequent releases vendors stay cached and users only download the diff
  • First-paint LCP path sheds ~600 KB of ECharts: OverviewPage's trend chart is replaced by a hand-rolled SparklineTrend SVG (~120 lines: three lines, endpoint dots, gradient area fill, legend, date range), no longer pulling in echarts-core (436 KB) + zrender (173 KB) + echarts-react (17 KB). The analytics dashboard still uses the full ECharts
  • ECharts switches to incremental setOption: drops notMerge: true (which forced a fresh instance on every option reference change); the six chart components all gain useMemo around buildXxxOption; the ResizeObserver callback is now batched through requestAnimationFrame. Switching analytics date ranges, scrolling, toggling checkboxes and typing no longer cause chart redraw jitter

2. React Query cache and re-render

  • Shrink holding-mutation invalidate scope: each single-row edit used to invalidate 8 query keys, firing 6 heavy analytics endpoints in parallel. Now invalidates only the two essentials (holdings.all() + analyticsDateBounds.all()); analytics queries share a 30 s staleTime so they re-fetch naturally on next mount. A new invalidateAllHoldingDependentQueries covers genuinely wholesale paths (CSV import, bulk delete)
  • Unify the settings cache key: previously three pages used settings.scope('xxx') and split the same response into three caches, repeating the fetch on every page switch. Now all three share settings.all()
  • AnalyticsPage uses placeholderData: keepPreviousData: the five date-range driven queries no longer fall back to Skeleton on every range change; the previous response stays visible until the new one arrives
  • EntryPage / EntryHoldingsTable render overhaul: module-level EMPTY_HOLDINGS / EMPTY_MEMBERS constants replace ?? [] for reference stability; buildGroups is memoised; GroupBlock is wrapped in React.memo; handlers are useCallback-stable; keyword input flows through useDeferredValue so typing stays smooth

3. Backend hot paths

  • _build_snapshot_payload N+1 fix: previously called session.get(Category) three times per holding — 40 holdings = 120 single-row lookups. Now one SELECT ... WHERE id IN (...) batch prefetch, ~90% fewer DB round-trips. Called from trend / sankey / rebalance / currency-overview and every holding write
  • FX resolve_rate_for_pair no longer blocks 30 s: previously when a rate was missing it synchronously called refresh_rates() (httpx 5 s × 3 retries × 2 providers = up to 30 s blocking). The lookup is now exact → historical fallback (with is_estimated=True) + fire-and-forget daemon-thread refresh → cold-start sync only. New-currency holding P99 latency drops from 30 s to ~100 ms
  • build_daily_series process-level cache: trend / volatility / correlation all called it independently, each deserialising N daily payloads. Now a shared LRU cache (max 32 entries) keyed on (family_id, window, start, end), version-stamped with (MAX(snapshot_date), COUNT, MAX(holdings.updated_at)) for automatic invalidation
  • currency-overview reads the latest snapshot: aligned with sankey / rebalance, no longer calls build_current_payload to rebuild from scratch
  • get_default_family + timezone + sankey member name consolidation: the first two now cache in Session.info (per-request lifetime); sankey member lookups collapse from N get_scoped_member calls into one IN-batched query

4. Desktop cold start and runtime

  • Daily snapshot moved to a daemon thread: previously bootstrap_runtime ran create_daily_snapshot synchronously, so /health couldn't return 200 until it finished and the Electron loading page waited. Async now drops 1-3 s off cold start
  • Loading page reflects real backend stages: the 1700 ms fake carousel was decoupled from actual backend readiness. The main process now pushes real copy via webContents.executeJavaScript ("waiting for local service to be ready" / "loading the main interface"); fake states are kept only as fallback
  • Update download progress throttled to 250 ms: every chunk used to trigger a state.json fsync + IPC broadcast — for a 100 MB package that was 20-1600+ writes/broadcasts. Throttled now: ~95% fewer writes, UI stays smooth during updates
  • BackgroundScheduler capped to 1 worker + lazy instantiation: APScheduler defaults to 10 worker threads (~20 MB of resident memory the project never uses); the project only has two jobs and they don't run concurrently. The scheduler instance also no longer instantiates at module-import time

5. Data-model cleanup

  • New daily_totals slim table: an alembic migration creates the table and back-fills from existing snapshot_daily.payload_json.totals; create_daily_snapshot now dual-writes. snapshot_daily remains the source of truth at holding granularity; the new table is a fast path for future totals-only endpoints (e.g. a lightweight net-asset sparkline)
  • Remove the event-snapshot dual-write on holding mutations: a repo-wide grep found no frontend or script consumers of /api/v1/snapshots/events. _refresh_snapshots now only refreshes the daily snapshot; high-value events (CSV import, settings base_currency change) still call create_event_snapshot explicitly. Per-mutation backend IO drops ~50%
  • New metadata-only summary endpoints: /api/v1/snapshots/events/summary and /snapshots/daily/summary skip payload_json deserialisation entirely (events project four metadata fields, daily reads the new daily_totals table). Response body shrinks ~95% at the same limit; the old endpoints are retained for backwards compatibility

6. Build and packaging

  • PyInstaller bundle trim: backend/build_desktop.py adds an --exclude-module list (watchfiles / tkinter / unittest / test / pytest / _pytest / setuptools). DMG estimated to drop 10-30 MB and cold-start imports 0.5-1.5 s

Before / after

Metric Before After Where to verify
Bundle index.js 227 KB / 73 KB gzip 37 KB / 13 KB gzip M5 build output
First-paint LCP path ~860 KB / ~230 KB gzip ~250 KB / ~80 KB gzip M6, OverviewPage no longer imports echarts
Backend IO per holding edit 2× JSON serialise + 2× INSERT 1× / 1× L3 _refresh_snapshots drops event write
Frontend requests per holding edit 8 query invalidates 2 (holdings + dateBounds) Q2 scope shrink
_build_snapshot_payload DB round-trips 3N 1 IN prefetch Q5 N+1 fix
FX new-currency P99 up to ~30 s ~100 ms Q6 async
Desktop cold-start /health blocked 1-3 s by sync snapshot unblocked Q7 async
Update download IO/IPC per-chunk fsync + IPC ≤1 per 250 ms Q8 throttle
Snapshots list response multiple MB (full payloads) summary endpoint -95% L5 metadata-only
Scheduler resident workers 10 1 M8

Upgrade notes

  • Existing users: an alembic migration automatically creates the new daily_totals table and back-fills existing data; the event-snapshot table is untouched and old data remains readable; categories and holdings schema are unchanged. No manual action needed
  • Dev environment: deleting backend/data/app.db is no longer required for upgrades — the migration handles it. If you do rebuild, bootstrap still seeds the Scheme D taxonomy
  • Desktop: v0.2.0 users will auto-detect, background-download and prompt with a single confirm to upgrade (same flow as v0.2.0; no Gatekeeper re-authorisation needed)

Artifacts