Skip to content

Commit dbbbfe7

Browse files
committed
Persistent photo cache for chat attachments (#510)
<!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> ### Add design for persistent photo cache for chat attachments in [persistent-photo-cache.md](https://github.com/xmtplabs/convos-ios/pull/510/files#diff-f896f93be70e32de591559d1156d767f50777e5c8597e913f4a3e4afe8d0d15c) Introduce a planning document detailing a two-tier disk storage model, `ImageCache` API changes with `ImageStorageTier`, new cache/remove methods accepting a storage tier, and cleanup behavior on conversation delete/explode in [persistent-photo-cache.md](https://github.com/xmtplabs/convos-ios/pull/510/files#diff-f896f93be70e32de591559d1156d767f50777e5c8597e913f4a3e4afe8d0d15c). #### 📍Where to Start Start with the design overview in [persistent-photo-cache.md](https://github.com/xmtplabs/convos-ios/pull/510/files#diff-f896f93be70e32de591559d1156d767f50777e5c8597e913f4a3e4afe8d0d15c). <!-- Macroscope's review summary starts here --> <sup><a href="https://app.macroscope.com">Macroscope</a> summarized a86fbd5.</sup> <!-- Macroscope's review summary ends here --> <!-- Macroscope's pull request summary ends here -->
1 parent d97ac69 commit dbbbfe7

1 file changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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

Comments
 (0)