refactor(ios): back HTMLPreviewManager with SQLiteKVCache#484
Draft
jkmassel wants to merge 1 commit intojkmassel/url-cache-sqlitefrom
Draft
refactor(ios): back HTMLPreviewManager with SQLiteKVCache#484jkmassel wants to merge 1 commit intojkmassel/url-cache-sqlitefrom
jkmassel wants to merge 1 commit intojkmassel/url-cache-sqlitefrom
Conversation
1f40e86 to
232aba6
Compare
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.
232aba6 to
671b279
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #483.
Summary
HTMLPreviewManager's backing store fromURLCachetoSQLiteKVCache(introduced in feat(ios): introduce SQLiteKVCache — a persistent KV blob cache #477, also used byEditorURLCachein refactor(ios): back EditorURLCache with SQLiteKVCache #483).EditorViewController.deleteAllData()so it doesn't unlinkhtmlpreview.sqliteunder the live SQLite handle — see "deleteAllData() interaction" below.clearCache()and makes the static onenonisolated+ sync — see "One process-wide instance" below.makeCache(), thepreview://synthetic-URL key wrapping, and theCachedURLResponseround-trip — allURLCacheceremony thatSQLiteKVCachedoesn't need.What's stored
"content-viewportWidth-templateHash"string —SQLiteKVCachehashes keys with SHA-256 internally before binding, so wrapping in another digest at the call site is redundant.nowat write time — used bySQLiteKVCachefor oldest-first evictionData()— preview entries don't need side-channel stateURLCachepath)The backing file lives at
~/Library/Caches/GutenbergKit/htmlpreview.sqlite, sibling to the per-siteEditorURLCachedirectories (<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
SQLiteKVCacherequires one owning instance per backing file.HTMLPreviewManageris created lazily perEditorViewController, so two coexisting instances would have shared a single backing directory under the oldURLCachedesign (and silently raced); underSQLiteKVCachethat'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-scopestatic letwould: the cache is process-wide singleton state, not a property of any one manager. Per-template isolation still holds because the cache key folds intemplateHash— different themes never collide.Side effect: the per-instance
clearCache()becomes redundant. With one shared backing store,instance.clearCache()andSelf.clearCache()had identical semantics — calling either nukes every instance's cached previews.gh search codeacross thewordpress-mobileorg confirmed the only call site for either form isDemo-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 nownonisolatedand synchronous (wasasync). Its body just callspreviewCache.clear(), which has no actor requirement; theasyncwas vestigial. The demo's only call site is updated accordingly.Off-main writes
HTMLPreviewManageris@MainActor; the oldURLCache.storeCachedResponseran synchronously on the main thread, and the naiveSQLiteKVCacheswap inherited the same property. Fixed by:cache.putinTask.detached— the closure runs on the cooperative pool, not main.The HEIC encode stays on main because
UIImageisn'tSendable— only the resultingData+keycross 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 bytry?. The lower-levelSQLiteKVCache.putalready 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()interactionpreviewCachekeeps a SQLite connection (and its WAL/SHM file handles) open for the lifetime of the process. The previousdeleteAllData()didremoveItem(at: defaultCacheRoot), which used to be a no-op against the preview cache (the old path was outsidedefaultCacheRoot) but would now unlinkhtmlpreview.sqliteunder the live handle, leaving the process writing to an orphaned inode until termination.Fixed by:
HTMLPreviewManager.clearCache()) before any file removal.defaultCacheRootselectively, preservinghtmlpreview.sqlite,htmlpreview.sqlite-wal, andhtmlpreview.sqlite-shm.Per-site
EditorURLCachesubdirectories are still removed wholesale. Their open-handle issue is the existing PR #483 surface — separate concern, out of scope here.Migration
Pre-existing
URLCachefiles 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-packagesucceeds (iOS Simulator target compiles the#if canImport(UIKit)-gated file).make test-swift-packagepasses (full iOS Simulator suite).make test-swift-librarypasses on macOS host (898 tests / 46 suites —HTMLPreviewManageritself is UIKit-gated and not exercised here).