397 lines
16 KiB
Markdown
397 lines
16 KiB
Markdown
---
|
|
phase: 08-pagination-quality-cache-performance
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 08-01
|
|
- 08-02
|
|
files_modified:
|
|
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
|
|
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
autonomous: true
|
|
requirements:
|
|
- QUAL-01
|
|
- QUAL-04
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift"
|
|
provides: "RDEPUBTextPerformanceSample struct and RDEPUBTextPerformanceSampler class"
|
|
contains: "struct RDEPUBTextPerformanceSample"
|
|
- path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift"
|
|
provides: "Cache integration and performance instrumentation in build()"
|
|
contains: "lastBuildPerformanceSamples"
|
|
key_links:
|
|
- from: "RDEPUBTextBookBuilder.swift"
|
|
to: "RDEPUBTextBookCache.swift"
|
|
via: "cache.load(key:) / cache.save(_:key:)"
|
|
pattern: "cache\\.load\\|cache\\.save"
|
|
- from: "RDEPUBTextBookBuilder.swift"
|
|
to: "RDEPUBTextPerformanceSampler.swift"
|
|
via: "sampler.record(sample)"
|
|
pattern: "sampler\\.record"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
<!-- Builder init pattern. From RDEPUBTextBookBuilder.swift lines 101-107 -->
|
|
```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] = []
|
|
```
|
|
|
|
<!-- Build method signature. From RDEPUBTextBookBuilder.swift lines 132-137 -->
|
|
```swift
|
|
public func build(
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle
|
|
) throws -> RDEPUBTextBook
|
|
```
|
|
|
|
<!-- Cache public API (from Plan 01) -->
|
|
```swift
|
|
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
|
|
}
|
|
```
|
|
|
|
<!-- Performance measurement pattern (standard iOS) -->
|
|
```swift
|
|
let start = CFAbsoluteTimeGetCurrent()
|
|
// ... operation ...
|
|
let duration = CFAbsoluteTimeGetCurrent() - start
|
|
```
|
|
|
|
<!-- Existing struct pattern for diagnostics. From RDEPUBTextRenderer.swift lines 90-113 -->
|
|
```swift
|
|
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
|
public var kind: RDEPUBTextResourceReferenceKind
|
|
public var chapterHref: String
|
|
// ...
|
|
public init(kind: ..., chapterHref: ..., ...) { ... }
|
|
}
|
|
```
|
|
|
|
<!-- PaginationSupport extension (from Plan 02, Task 1 — updated to pass config) -->
|
|
```swift
|
|
extension NSAttributedString {
|
|
func rd_paginatedFrames(
|
|
size: CGSize,
|
|
fragmentOffsets: [String: Int] = [:],
|
|
config: RDEPUBTextLayoutConfig = .default
|
|
) -> [RDEPUBTextLayoutFrame]
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create RDEPUBTextPerformanceSampler with timing sample struct and recording API</name>
|
|
<files>Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift</files>
|
|
<read_first>
|
|
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
|
|
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
</read_first>
|
|
<action>
|
|
Create a new file `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`.
|
|
|
|
**RDEPUBTextPerformanceSample struct** (per QUAL-04, D-04):
|
|
|
|
```swift
|
|
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**:
|
|
|
|
```swift
|
|
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
|
|
</action>
|
|
<verify>
|
|
<automated>xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<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>
|
|
<done>
|
|
RDEPUBTextPerformanceSampler.swift contains performance sample struct and sampler class with record/summary/reset API and [PERF] console logging. Build succeeds.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Integrate cache and performance sampling into RDEPUBTextBookBuilder.build()</name>
|
|
<files>Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
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:
|
|
|
|
```swift
|
|
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:
|
|
|
|
```swift
|
|
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:
|
|
|
|
```swift
|
|
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:
|
|
```swift
|
|
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)
|
|
</action>
|
|
<verify>
|
|
<automated>xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<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>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/08-pagination-quality-cache-performance/08-03-SUMMARY.md` when done
|
|
</output>
|