Skip to content

refactor(ios): back HTMLPreviewManager with SQLiteKVCache#484

Draft
jkmassel wants to merge 1 commit intojkmassel/url-cache-sqlitefrom
jkmassel/html-preview-cache
Draft

refactor(ios): back HTMLPreviewManager with SQLiteKVCache#484
jkmassel wants to merge 1 commit intojkmassel/url-cache-sqlitefrom
jkmassel/html-preview-cache

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

@jkmassel jkmassel commented Apr 30, 2026

Stacked on #483.

Summary

  • Swaps HTMLPreviewManager's backing store from URLCache to SQLiteKVCache (introduced in feat(ios): introduce SQLiteKVCache — a persistent KV blob cache #477, also used by EditorURLCache in refactor(ios): back EditorURLCache with SQLiteKVCache #483).
  • Moves the SQLite write off the main actor — see "Off-main writes" below.
  • Reworks EditorViewController.deleteAllData() so it doesn't unlink htmlpreview.sqlite under the live SQLite handle — see "deleteAllData() interaction" below.
  • Removes the public per-instance clearCache() and makes the static one nonisolated + sync — see "One process-wide instance" below.
  • Drops makeCache(), the preview:// synthetic-URL key wrapping, and the CachedURLResponse round-trip — all URLCache ceremony that SQLiteKVCache doesn't need.

What's stored

field source
key raw "content-viewportWidth-templateHash" string — SQLiteKVCache hashes keys with SHA-256 internally before binding, so wrapping in another digest at the call site is redundant
storageDate .now at write time — used by SQLiteKVCache for oldest-first eviction
metadata empty Data() — preview entries don't need side-channel state
value HEIC-encoded image bytes (unchanged from the prior URLCache path)

The backing file lives at ~/Library/Caches/GutenbergKit/htmlpreview.sqlite, sibling to the per-site EditorURLCache directories (<siteId>/editorurlcache.sqlite) under the same root — the layout makes the global-vs-per-site distinction visible at the filesystem level.

One process-wide instance

SQLiteKVCache requires one owning instance per backing file. HTMLPreviewManager is created lazily per EditorViewController, so two coexisting instances would have shared a single backing directory under the old URLCache design (and silently raced); under SQLiteKVCache that's undefined behavior.

Fixed by holding the store at file scope rather than per-instance — private let previewCache = SQLiteKVCache(...) lives outside the class. Reads more honestly than a class-scope static let would: the cache is process-wide singleton state, not a property of any one manager. Per-template isolation still holds because the cache key folds in templateHash — different themes never collide.

Side effect: the per-instance clearCache() becomes redundant. With one shared backing store, instance.clearCache() and Self.clearCache() had identical semantics — calling either nukes every instance's cached previews. gh search code across the wordpress-mobile org confirmed the only call site for either form is Demo-iOS/Sources/Views/DebugSettingsView.swift, which already uses the static API. Dropped the instance method entirely rather than leave behind a signature that implies per-instance scoping.

The static clearCache() is also now nonisolated and synchronous (was async). Its body just calls previewCache.clear(), which has no actor requirement; the async was vestigial. The demo's only call site is updated accordingly.

Off-main writes

HTMLPreviewManager is @MainActor; the old URLCache.storeCachedResponse ran synchronously on the main thread, and the naive SQLiteKVCache swap inherited the same property. Fixed by:

  • Putting the cache at file scope so it has no actor isolation — the detached task can reach it directly.
  • Wrapping the cache.put in Task.detached — the closure runs on the cooperative pool, not main.

The HEIC encode stays on main because UIImage isn't Sendable — only the resulting Data + key cross into the detached task. Cache reads stay on main as the cache-hit fast path; SQLite reads on flash are sub-millisecond and going async would add a continuation hop on every render.

Write failures are logged via OSLog (subsystem: "GutenbergKit", category: "html-preview") instead of being swallowed by try?. The lower-level SQLiteKVCache.put already logs the SQLite-level error code; this adds the call-site context.

Minor side effect: a render arriving immediately after another identical render completes can now race the detached write and re-render. The window is small (the time to encode HEIC + commit one SQLite row), and pattern previews aren't typically requested back-to-back. Not worth defending against with an in-memory shadow.

deleteAllData() interaction

previewCache keeps a SQLite connection (and its WAL/SHM file handles) open for the lifetime of the process. The previous deleteAllData() did removeItem(at: defaultCacheRoot), which used to be a no-op against the preview cache (the old path was outside defaultCacheRoot) but would now unlink htmlpreview.sqlite under the live handle, leaving the process writing to an orphaned inode until termination.

Fixed by:

  • Clearing the preview cache rows through the live handle (HTMLPreviewManager.clearCache()) before any file removal.
  • Sweeping defaultCacheRoot selectively, preserving htmlpreview.sqlite, htmlpreview.sqlite-wal, and htmlpreview.sqlite-shm.

Per-site EditorURLCache subdirectories are still removed wholesale. Their open-handle issue is the existing PR #483 surface — separate concern, out of scope here.

Migration

Pre-existing URLCache files at ~/Library/Caches/gbk-html-preview-cache/ are left in place — dead weight until the OS reclaims the cache directory. Existing entries cache-miss on first read after upgrade and re-render. Same trade-off PR #483 makes; a one-shot cleanup on init is possible but not worth the complexity.

Test plan

  • make build-swift-package succeeds (iOS Simulator target compiles the #if canImport(UIKit)-gated file).
  • make test-swift-package passes (full iOS Simulator suite).
  • make test-swift-library passes on macOS host (898 tests / 46 suites — HTMLPreviewManager itself is UIKit-gated and not exercised here).
  • Manual: open the demo app's debug menu → "Clear Preview Cache" — confirm pattern previews regenerate on next render.
  • Manual: open the demo app's debug menu → "Clear Editor Data" — confirm preview cache rebuilds correctly afterward and no SQLite errors appear in OSLog.

@github-actions github-actions Bot added the [Type] Task Issues or PRs that have been broken down into an individual action to take label Apr 30, 2026
@jkmassel jkmassel marked this pull request as draft April 30, 2026 18:05
@jkmassel jkmassel force-pushed the jkmassel/html-preview-cache branch 6 times, most recently from 1f40e86 to 232aba6 Compare April 30, 2026 23:17
Mirrors the EditorURLCache change in #483: swap the per-instance
URLCache for SQLiteKVCache, using the same single-process-wide cache
pattern.

The cache lives at file scope rather than per-instance because
SQLiteKVCache's "one instance per backing file" contract would
otherwise be violated as soon as a second EditorViewController lazy-
creates its HTMLPreviewManager. Per-template isolation still holds
via the existing (content, viewportWidth, templateHash) key, so
different themes don't see each other's entries. File-scope reads
more honestly than a class-scope `static let` would: the cache is
process-wide singleton state, not a property of any one manager.

The cache key is the raw "content-viewportWidth-templateHash" string
— SQLiteKVCache hashes keys with SHA-256 internally before binding,
so an additional SHA-1 wrapper at the call site was redundant. The
file lives at ~/Library/Caches/GutenbergKit/htmlpreview.sqlite,
sibling to the per-site EditorURLCache directories under the same
root.

The SQLite write moved off the main actor: the put runs in
`Task.detached`, and `previewCache` lives at file scope so the
detached closure can reach it without any actor isolation. The HEIC
encode stays on main because `UIImage` isn't `Sendable`, but the disk
I/O — which the old `URLCache.storeCachedResponse` also ran
synchronously on main — no longer blocks the editor. Reads stay on
main as the cache-hit fast path.

EditorViewController.deleteAllData() now flushes the preview cache
through the live handle and preserves htmlpreview.sqlite (+ its WAL
sidecars) when sweeping defaultCacheRoot. Removing those files out
from under the static SQLiteKVCache connection would leave the
process writing to an unlinked inode until termination.
HTMLPreviewManager.clearCache() is `nonisolated` and synchronous so
deleteAllData can call it without an await hop.

Also drops the public per-instance clearCache(). With a process-wide
backing store its semantics collapsed onto the static method's, and
`gh search code` across wordpress-mobile turned up no callers for the
instance form anywhere in the org — only the demo app's
DebugSettingsView, which already uses the static API. Better to
remove the method than leave behind a signature that implies per-
instance scoping.

Migration: pre-existing URLCache files in ~/Library/Caches/
gbk-html-preview-cache/ are left in place — dead weight until the OS
reclaims the cache directory. Existing entries cache-miss on first
read after upgrade and re-render.
@jkmassel jkmassel force-pushed the jkmassel/html-preview-cache branch from 232aba6 to 671b279 Compare May 1, 2026 05:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Task Issues or PRs that have been broken down into an individual action to take

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants