|
| 1 | +# Persistent Photo Cache |
| 2 | + |
| 3 | +> **Status**: Draft |
| 4 | +> **Author**: @jarod |
| 5 | +> **Created**: 2026-02-24 |
| 6 | +
|
| 7 | +## Problem |
| 8 | + |
| 9 | +Chat photo attachments expire from S3 after 30 days and are never renewed (ADR 008). The local disk cache is the only surviving copy after expiration. However, the current `ImageCache` caps disk storage at 500MB with LRU eviction, meaning old photos are silently and permanently lost once evicted. |
| 10 | + |
| 11 | +Additionally, when a conversation is exploded or deleted, the cached photos remain on disk as orphans — wasting storage indefinitely since nothing cleans them up. |
| 12 | + |
| 13 | +## Goals |
| 14 | + |
| 15 | +1. Chat photo attachments must not be evicted by size-based cache pressure |
| 16 | +2. Exploded/deleted conversation photos must be cleaned up from disk |
| 17 | +3. Re-fetchable images (avatars, group images) should still be subject to cache limits |
| 18 | +4. Minimal changes to the existing `ImageCache` API surface |
| 19 | + |
| 20 | +## Design |
| 21 | + |
| 22 | +### Two-tier disk storage |
| 23 | + |
| 24 | +Split disk-cached images into two pools based on whether they can be re-fetched: |
| 25 | + |
| 26 | +| Pool | Location | Eviction | Contents | |
| 27 | +|------|----------|----------|----------| |
| 28 | +| **Persistent** | `Application Support/PhotoStore/` | Only on conversation delete/explode | Chat photo attachments | |
| 29 | +| **Cache** | `Caches/ImageCache/` (existing) | LRU at 500MB cap | Avatars, group images, QR codes, other re-fetchable images | |
| 30 | + |
| 31 | +`Application Support` is backed up by the system and not subject to iOS cache eviction under storage pressure. `Caches` can be purged by iOS at any time, which is fine for re-fetchable images. |
| 32 | + |
| 33 | +### How it works |
| 34 | + |
| 35 | +**Saving photos**: When a chat photo attachment is cached (via `OutgoingMessageWriter` or `RemoteAttachmentLoader`), it is written to the persistent pool instead of the evictable cache pool. The identifier is the attachment key (either the local URL string for outgoing, or the stored remote attachment JSON hash for incoming). |
| 36 | + |
| 37 | +**Reading photos**: `ImageCache` checks memory first, then persistent pool, then cache pool, then network. No API change needed — callers still use `image(for:)` and `imageAsync(for:)` by identifier. |
| 38 | + |
| 39 | +**Cleanup on explode/delete**: When `cleanupInboxData` removes conversations from the DB, it also collects the attachment keys for those conversations' messages and deletes the corresponding files from the persistent pool. |
| 40 | + |
| 41 | +### API changes |
| 42 | + |
| 43 | +Add a new parameter to distinguish storage tier: |
| 44 | + |
| 45 | +```swift |
| 46 | +// New enum |
| 47 | +public enum ImageStorageTier: Sendable { |
| 48 | + case cache // LRU-evictable (avatars, group images) |
| 49 | + case persistent // only deleted explicitly (chat photos) |
| 50 | +} |
| 51 | + |
| 52 | +// New method on ImageCacheProtocol |
| 53 | +func cacheImage(_ image: ImageType, for identifier: String, storageTier: ImageStorageTier) |
| 54 | +func cacheData(_ data: Data, for identifier: String, storageTier: ImageStorageTier) |
| 55 | +func removeImages(for identifiers: [String], storageTier: ImageStorageTier) |
| 56 | +``` |
| 57 | + |
| 58 | +Existing methods default to `.cache` tier for backward compatibility. |
| 59 | + |
| 60 | +### Cleanup on conversation delete |
| 61 | + |
| 62 | +Add a method to `ImageCache`: |
| 63 | + |
| 64 | +```swift |
| 65 | +func removeAttachments(forConversationId conversationId: String, attachmentKeys: [String]) |
| 66 | +``` |
| 67 | + |
| 68 | +This deletes the persistent files for the given keys. Called from `cleanupInboxData` in `InboxStateMachine` after querying the attachment keys from `DBMessage` before deleting the message rows. |
| 69 | + |
| 70 | +The query to collect attachment keys before deletion: |
| 71 | + |
| 72 | +```swift |
| 73 | +let attachmentKeys = try DBMessage |
| 74 | + .filter(DBMessage.Columns.conversationId == conversationId) |
| 75 | + .filter(DBMessage.Columns.attachmentKey != nil) |
| 76 | + .fetchAll(db) |
| 77 | + .compactMap { $0.attachmentKey } |
| 78 | +``` |
| 79 | + |
| 80 | +### What about "Delete All Data"? |
| 81 | + |
| 82 | +The app's "Delete All Data" flow already calls `cleanupInboxData` for each inbox, which will now clean up persistent photos as part of that flow. As a safety net, `removeAllPersistentImages()` can wipe the entire `PhotoStore` directory. |
| 83 | + |
| 84 | +## Scope |
| 85 | + |
| 86 | +### In scope |
| 87 | +- Persistent storage pool for chat attachments |
| 88 | +- Cleanup hook in conversation delete/explode flow |
| 89 | + |
| 90 | +### Out of scope |
| 91 | +- Migration of existing cached attachments (photos have not shipped to the App Store yet, so internal builds do not need migration) |
| 92 | +- Re-upload recovery for expired assets with local cache (future enhancement noted in ADR 008) |
| 93 | +- Changing the 30-day S3 lifecycle policy |
| 94 | +- Compression or deduplication of persistent photos |
| 95 | + |
| 96 | +## Risks |
| 97 | + |
| 98 | +- **Disk usage growth**: Persistent photos accumulate without a size cap. Mitigated by cleanup on delete/explode, and by the fact that S3 lifecycle means the app only retains what was cached during the 30-day window. |
| 99 | + |
| 100 | +## Related |
| 101 | + |
| 102 | +- [ADR 008: Asset Lifecycle and Renewal](../adr/008-asset-lifecycle-and-renewal.md) |
| 103 | +- [ADR 009: Encrypted Conversation Images](../adr/009-encrypted-conversation-images.md) |
| 104 | +- `ConvosCore/Sources/ConvosCore/Image Cache/ImageCache.swift` |
| 105 | +- `ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift` (`cleanupInboxData`) |
| 106 | +- `ConvosCore/Sources/ConvosCore/Assets/ExpiredAssetRecoveryHandler.swift` |
0 commit comments