ReadViewSDK/.planning/phases/08-pagination-quality-cache-performance/08-03-PLAN.md

16 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
08-pagination-quality-cache-performance 03 execute 2
08-01
08-02
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
true
QUAL-01
QUAL-04
truths artifacts key_links
Builder records per-chapter render time and paginate time
Builder records total build time
Builder records cache hit/miss per chapter
Performance samples are accessible via lastBuildPerformanceSamples
Cache integration: builder accepts optional cache parameter and skips build on hit
Cache miss triggers full build and stores result
Console logs show [PERF] summary with timing data
path provides contains
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift RDEPUBTextPerformanceSample struct and RDEPUBTextPerformanceSampler class struct RDEPUBTextPerformanceSample
path provides contains
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Cache integration and performance instrumentation in build() lastBuildPerformanceSamples
from to via pattern
RDEPUBTextBookBuilder.swift RDEPUBTextBookCache.swift cache.load(key:) / cache.save(_🔑) cache.load|cache.save
from to via pattern
RDEPUBTextBookBuilder.swift RDEPUBTextPerformanceSampler.swift sampler.record(sample) sampler.record
Create performance sampling infrastructure and integrate cache + performance instrumentation into RDEPUBTextBookBuilder.build(). This adds timing measurements for render/paginate/build operations and wires the cache from Plan 01 so that repeated builds with the same parameters skip the full pipeline.

Purpose: QUAL-04 requires stable diagnostic/sampling data to ensure no degradation. QUAL-01 requires cache integration to avoid redundant pagination. This plan brings both into the builder.

Output: New RDEPUBTextPerformanceSampler.swift; modified RDEPUBTextBookBuilder.swift with cache integration and timing instrumentation.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-pagination-quality-cache-performance/08-CONTEXT.md @.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md @.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md @.planning/phases/08-pagination-quality-cache-performance/08-01-SUMMARY.md @.planning/phases/08-pagination-quality-cache-performance/08-02-SUMMARY.md @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift ```swift public init(renderer: RDEPUBTextRenderer) { self.renderer = renderer }

public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) }


<!-- Existing diagnostics properties. From RDEPUBTextBookBuilder.swift lines 98-99 -->
```swift
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
public func build(
    parser: RDEPUBParser,
    publication: RDEPUBPublication,
    pageSize: CGSize,
    style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook
public final class RDEPUBTextBookCache {
    public init(subdirectory: String = "RDEPUBTextBookCache")
    public func cacheKey(bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize) -> String
    public func load(key: String) -> RDEPUBTextBook?
    public func save(_ book: RDEPUBTextBook, key: String)
    public func invalidateAll()
    public var schemaVersion: Int
}
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
    public var kind: RDEPUBTextResourceReferenceKind
    public var chapterHref: String
    // ...
    public init(kind: ..., chapterHref: ..., ...) { ... }
}
extension NSAttributedString {
    func rd_paginatedFrames(
        size: CGSize,
        fragmentOffsets: [String: Int] = [:],
        config: RDEPUBTextLayoutConfig = .default
    ) -> [RDEPUBTextLayoutFrame]
}
Task 1: Create RDEPUBTextPerformanceSampler with timing sample struct and recording API Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Create a new file `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`.

RDEPUBTextPerformanceSample struct (per QUAL-04, D-04):

public struct RDEPUBTextPerformanceSample: Equatable {
    public var chapterHref: String
    public var renderDuration: TimeInterval    // DTCoreText render time
    public var paginateDuration: TimeInterval  // CoreText pagination time
    public var pageCount: Int
    public var attributedStringLength: Int
    public var cacheHit: Bool

    public init(
        chapterHref: String,
        renderDuration: TimeInterval,
        paginateDuration: TimeInterval,
        pageCount: Int,
        attributedStringLength: Int,
        cacheHit: Bool
    ) { ... }
}

Follow the exact pattern from RDEPUBTextResourceReferenceDiagnostic at RDEPUBTextRenderer.swift lines 90-113: public struct, Equatable, explicit public init with all properties.

RDEPUBTextPerformanceSampler class:

public final class RDEPUBTextPerformanceSampler {
    public private(set) var samples: [RDEPUBTextPerformanceSample] = []
    public private(set) var totalBuildDuration: TimeInterval = 0

    public init() {}

    public func record(_ sample: RDEPUBTextPerformanceSample) {
        samples.append(sample)
        print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
    }

    public func summary() -> String {
        let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
        let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
        let hitCount = samples.filter(\.cacheHit).count
        return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
    }

    public func reset() {
        samples.removeAll()
        totalBuildDuration = 0
    }

    private func formatMS(_ duration: TimeInterval) -> String {
        String(format: "%.0fms", duration * 1000)
    }
}

Console output pattern (follows existing [EPUB] style in the codebase):

  • Each record() call prints a per-chapter [PERF] line
  • summary() returns a formatted string (caller prints it)
  • Use milliseconds (multiply TimeInterval by 1000) for human-readable output xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 <acceptance_criteria>
    • File Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift exists
    • RDEPUBTextPerformanceSample is a public struct with Equatable conformance
    • Properties: chapterHref, renderDuration, paginateDuration, pageCount, attributedStringLength, cacheHit
    • RDEPUBTextPerformanceSampler is a public final class
    • record(_:) method appends sample and prints [PERF] line
    • summary() returns formatted string with totals and cache hit rate
    • reset() clears samples and totalBuildDuration
    • totalBuildDuration is a public property
    • Build succeeds </acceptance_criteria> RDEPUBTextPerformanceSampler.swift contains performance sample struct and sampler class with record/summary/reset API and [PERF] console logging. Build succeeds.
Task 2: Integrate cache and performance sampling into RDEPUBTextBookBuilder.build() Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift Modify `RDEPUBTextBookBuilder.swift` to integrate the cache (from Plan 01) and performance sampling (Task 1).

Step 1: Add new stored properties (per D-01, D-04)

After the existing lastBuildPaginationDiagnostics property (line 99), add:

public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)

Step 2: Add cache and sampler to init (per D-01)

Update the initializer to accept optional cache:

private let cache: RDEPUBTextBookCache?
private let sampler: RDEPUBTextPerformanceSampler

public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
    self.renderer = renderer
    self.cache = cache
    self.sampler = RDEPUBTextPerformanceSampler()
}

public convenience init() {
    self.init(renderer: RDEPUBDTCoreTextRenderer())
}

The cache parameter defaults to nil for backward compatibility. The sampler is always created (lightweight, no cost until samples recorded).

Step 3: Add cache key helper

Add a private helper method to generate the cache key from build parameters:

private func makeCacheKey(
    bookID: String,
    pageSize: CGSize,
    style: RDEPUBTextRenderStyle
) -> String? {
    guard let cache else { return nil }
    return cache.cacheKey(
        bookID: bookID,
        fontSize: style.font.pointSize,
        lineHeightMultiple: style.lineSpacing,
        contentInsets: .zero,  // or derive from pageSize insets if available
        pageSize: pageSize
    )
}

Note: The bookID needs to come from the publication. Use publication.metadata.identifier ?? publication.metadata.title ?? "unknown" as the bookID. If no identifier is available, return nil to skip caching.

Step 4: Instrument the build() method (per QUAL-04, QUAL-01)

At the start of build(), after the existing lastBuildResourceDiagnostics = [] / lastBuildPaginationDiagnostics = [] lines:

  1. Reset sampler: sampler.reset(), lastBuildCacheStats = (0, 0)
  2. Start build timer: let buildStart = CFAbsoluteTimeGetCurrent()
  3. Cache lookup: If cache is available, generate key and try cache.load(key:). If hit, set lastBuildCacheStats.hits += 1, print [Cache] HIT, and return the cached book immediately.
  4. Cache miss: If cache miss, proceed with existing build loop.

Inside the for loop, around each chapter's render + paginate:

  1. Render timing: Wrap renderer.renderChapter(request:) (line 158) with CFAbsoluteTimeGetCurrent() before and after
  2. Paginate timing: Wrap content.rd_paginatedFrames(size:) (line 196) with CFAbsoluteTimeGetCurrent() before and after
  3. Record sample: After getting layoutFrames, record:
    sampler.record(RDEPUBTextPerformanceSample(
        chapterHref: item.href,
        renderDuration: renderDuration,
        paginateDuration: paginateDuration,
        pageCount: effectiveFrames.count,
        attributedStringLength: content.length,
        cacheHit: false
    ))
    
  4. Miss counter: lastBuildCacheStats.misses += 1

After the loop, before returning:

  1. Set total build duration: sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
  2. Save to cache: If cache available and key was generated, cache.save(book, key: cacheKey)
  3. Print summary: print(sampler.summary())
  4. Store samples: lastBuildPerformanceSamples = sampler.samples

Step 5: Update build() method signature — keep the existing signature unchanged. The cache is accessed via the stored property, not a method parameter.

Important implementation notes:

  • All timing uses CFAbsoluteTimeGetCurrent() (no external deps, per D-04)
  • Timing happens on the same queue as the build (DispatchQueue.global(qos: .userInitiated)), not dispatched to main
  • The publication.metadata.identifier or equivalent needs to be accessible — check the RDEPUBPublication type for an identifier field
  • If publication has no identifier, skip cache (return nil from makeCacheKey) xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 <acceptance_criteria>
    • RDEPUBTextBookBuilder has lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] property
    • RDEPUBTextBookBuilder has lastBuildCacheStats: (hits: Int, misses: Int) property
    • RDEPUBTextBookBuilder init accepts optional cache: RDEPUBTextBookCache? parameter (defaults to nil)
    • build() method contains CFAbsoluteTimeGetCurrent() timing calls for render and paginate
    • build() method calls cache.load(key:) before the build loop when cache is available
    • build() method calls cache.save(_:key:) after the build loop when cache is available
    • build() method calls sampler.record() for each chapter with timing data
    • build() method prints sampler.summary() at the end
    • Existing convenience init() still works (cache defaults to nil)
    • Build succeeds with no regressions </acceptance_criteria> RDEPUBTextBookBuilder.build() is instrumented with per-chapter render/paginate timing, cache hit/miss logging, and performance sample recording. Cache integration skips build on hit and stores result on miss. Build succeeds.

<threat_model>

Trust Boundaries

Boundary Description
builder → cache Builder writes to cache after successful build; corrupted cache could return invalid RDEPUBTextBook on next load

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation
T-08-04 Tampering Cache integration in builder accept Cache is optional (nil default); NSKeyedArchiver deserialization fails safely returning nil
T-08-SC Tampering npm/pip/cargo installs mitigate No external packages installed in this phase
</threat_model>
- `xcodebuild build -scheme ReadViewDemo` succeeds - Builder init accepts optional cache parameter - build() method has CFAbsoluteTimeGetCurrent timing instrumentation - Cache load/save calls are present in build() flow - Performance samples accessible via lastBuildPerformanceSamples - Console shows [PERF] summary and [Cache] HIT/MISS logs

<success_criteria>

  • Per-chapter render and paginate durations are recorded
  • Total build time is captured
  • Cache hit skips the entire build loop
  • Cache miss runs full build and stores result
  • Performance summary printed to console after each build
  • No functional regression: book opens, paginates, and displays correctly </success_criteria>
Create `.planning/phases/08-pagination-quality-cache-performance/08-03-SUMMARY.md` when done