Skip to content

refactor(ios): streaming data architecture for large tables and queries#1045

Merged
datlechin merged 10 commits intomainfrom
refactor/ios-streaming-data-architecture
May 6, 2026
Merged

refactor(ios): streaming data architecture for large tables and queries#1045
datlechin merged 10 commits intomainfrom
refactor/ios-streaming-data-architecture

Conversation

@datlechin
Copy link
Copy Markdown
Member

Summary

Rebuilds the iOS data layer around streaming so opening large tables and running ad-hoc queries no longer materialises the entire result set in memory. Eliminates several latent OOM crashes reported on TestFlight (the most recent being the 100k-row table bug fixed in #1041; same shape would still have killed the app on a single 700 MB BLOB column).

Approach

  • New shared types in TableProModels (used by both iOS and macOS). Cell enum has four cases: .null, .text(String), .truncatedText(prefix:totalBytes:ref:) for text > 4 KB, .binary(byteCount:ref:) for any binary column. Wide cells carry only metadata plus a CellRef (table + PK values) for re-fetching the full value on demand. Row wraps [Cell]. StreamElement is the streaming event type. StreamOptions carries truncation thresholds and lazy context.
  • New executeStreaming(query:options:) method on DatabaseDriver with a backwards-compatible default implementation that bridges through legacy execute(query:). macOS app and all 14 plugins keep working unchanged through the default; iOS drivers override with real C-level streaming.
  • MySQL: switched from mysql_store_result (buffered, libmariadb mallocs the full result before Swift sees the first row) to mysql_use_result (server-side cursor, one row per fetch).
  • Postgres: switched from PQexec to PQsendQuery + PQsetSingleRowMode + PQgetResult per-row.
  • SQLite: keeps sqlite3_step row loop. Stops eagerly base64-encoding BLOBs; emits Cell.binary(byteCount:ref:) so the bytes never enter Swift memory.
  • Cancellation: AsyncThrowingStream.onTermination propagates to the C layer (PQcancel, sqlite3_interrupt).
  • MemoryPressureMonitor (new actor): wraps DispatchSourceMemoryPressure + os_proc_available_memory; started at app launch.
  • RowWindow (new): bounded sliding buffer (default 200 rows) replacing the unbounded [[String?]] @State arrays in views.
  • DataBrowserViewModel / QueryEditorViewModel (new, @Observable): own the RowWindow, drive the streaming, listen to memory pressure, call RowWindow.shrink(to:) on warning (100 rows) or critical (50 rows + cancel).
  • StreamingExporter (new): writes CSV / JSON / SQL to a FileHandle in O(1) memory regardless of result size.
  • RowDetailView: TabView (which pre-rendered 2-3 full rows on iPad split view) replaced with single rowContent + horizontal DragGesture for prev/next. Memory cost O(1) per row.

Test plan

Run on a real iPhone (not Simulator) to exercise iOS memory enforcement.

  • Compile clean: iOS app builds on Debug + Release. macOS app builds (no protocol regression).
  • MySQL streaming: open a 1M-row table on a 4 GB iPhone. Should stream first page in under 1 s, no Jetsam kill.
  • Wide cell: row with a 50 MB TEXT column. Cell shows truncated prefix + total byte count, no OOM.
  • BLOB: row with a 200 MB BLOB. Cell shows [BLOB 200 MB], no decode at fetch time.
  • Cancellation: in editor, run SELECT * FROM big_table. Tap Stop. Verify PQcancel / sqlite3_interrupt / mysql_use_result cleanup actually fires.
  • Memory pressure: Xcode Debug -> Simulate Memory Warning. DataBrowserViewModel.window should shrink and post a toast.
  • iPad split view: open browser in slide-over alongside Notes. RowDetailView no longer pre-renders adjacent rows.
  • Streaming export: 100k-row CSV via share sheet. Memory profile flat (~100 MB) instead of spiking to file size.
  • macOS regression: xcodebuild test -scheme TablePro -only-testing:TableProTests/MySQLCreateTableTests still passes (no behaviour change in macOS plugin path).
  • swiftlint lint --strict clean on every new file.
  • Banned-words / em-dash sweep clean.

What's NOT in scope (deferred)

  • iPad NavigationSplitView regular-size-class adaptation in ConnectionListView. The OOM root cause is fixed by streaming + windowing; the iPad split view is UX polish for a follow-up.
  • macOS app and plugins adopting executeStreaming. They keep using the legacy execute(query:) path via the protocol default. A future PR can switch them piecemeal.
  • Full Cell-aware UI (per-cell "Load full value" button in RowDetailView). The current legacyValues bridge surfaces wide cells with placeholder strings; the lazy-load button can ship in a follow-up that doesn't change the streaming layer.

Risk

  • Big diff (~1300 LOC). Mitigated by: backwards-compatible protocol default keeps macOS/plugins green; iOS view layer keeps existing UI shape and just rebrands the data source.
  • mysql_use_result semantics: requires draining all rows before the next query. The actor's endStream does this. Cancellation drains too.
  • PQsetSingleRowMode: must be called immediately after PQsendQuery, before any PQgetResult. Implementation matches that order.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin datlechin merged commit 24b1582 into main May 6, 2026
2 checks passed
@datlechin datlechin deleted the refactor/ios-streaming-data-architecture branch May 6, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant