16 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-pagination-quality-cache-performance | 03 | execute | 2 |
|
|
true |
|
|
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.swiftexists RDEPUBTextPerformanceSampleis a public struct with Equatable conformance- Properties:
chapterHref,renderDuration,paginateDuration,pageCount,attributedStringLength,cacheHit RDEPUBTextPerformanceSampleris a public final classrecord(_:)method appends sample and prints[PERF]linesummary()returns formatted string with totals and cache hit ratereset()clears samples and totalBuildDurationtotalBuildDurationis 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.
- File
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:
- Reset sampler:
sampler.reset(),lastBuildCacheStats = (0, 0) - Start build timer:
let buildStart = CFAbsoluteTimeGetCurrent() - Cache lookup: If cache is available, generate key and try
cache.load(key:). If hit, setlastBuildCacheStats.hits += 1, print[Cache] HIT, and return the cached book immediately. - Cache miss: If cache miss, proceed with existing build loop.
Inside the for loop, around each chapter's render + paginate:
- Render timing: Wrap
renderer.renderChapter(request:)(line 158) withCFAbsoluteTimeGetCurrent()before and after - Paginate timing: Wrap
content.rd_paginatedFrames(size:)(line 196) withCFAbsoluteTimeGetCurrent()before and after - Record sample: After getting layoutFrames, record:
sampler.record(RDEPUBTextPerformanceSample( chapterHref: item.href, renderDuration: renderDuration, paginateDuration: paginateDuration, pageCount: effectiveFrames.count, attributedStringLength: content.length, cacheHit: false )) - Miss counter:
lastBuildCacheStats.misses += 1
After the loop, before returning:
- Set total build duration:
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart - Save to cache: If cache available and key was generated,
cache.save(book, key: cacheKey) - Print summary:
print(sampler.summary()) - 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.identifieror equivalent needs to be accessible — check theRDEPUBPublicationtype 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>
RDEPUBTextBookBuilderhaslastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]propertyRDEPUBTextBookBuilderhaslastBuildCacheStats: (hits: Int, misses: Int)propertyRDEPUBTextBookBuilderinit accepts optionalcache: RDEPUBTextBookCache?parameter (defaults to nil)build()method containsCFAbsoluteTimeGetCurrent()timing calls for render and paginatebuild()method callscache.load(key:)before the build loop when cache is availablebuild()method callscache.save(_:key:)after the build loop when cache is availablebuild()method callssampler.record()for each chapter with timing databuild()method printssampler.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> |
<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>