Page MenuHomePhabricator

[native][lib] Introduce media cache context
ClosedPublic

Authored by bartek on Mar 30 2023, 7:00 AM.
Tags
None
Referenced Files
Unknown Object (File)
Sat, Nov 9, 1:14 PM
Unknown Object (File)
Fri, Nov 8, 5:06 AM
Unknown Object (File)
Mon, Nov 4, 12:13 PM
Unknown Object (File)
Mon, Nov 4, 12:13 PM
Unknown Object (File)
Mon, Nov 4, 12:13 PM
Unknown Object (File)
Mon, Nov 4, 12:13 PM
Unknown Object (File)
Mon, Oct 28, 5:49 PM
Unknown Object (File)
Sun, Oct 27, 1:42 AM
Subscribers
None

Details

Summary

This diff introduces a new react context that addresses encrypted media caching. The cache has two layers:

  • in-memory for quickly mapping holder -> URI to cached resource. We cannot fully rely on it so it also reaches the underlying layer each time, but this operation is fast and avoids more costly operations.
  • persistent, based on filesystem, implemented in subsequent diff.

The cache automatically maintains its size and keeps it below the limit. I set it to 100MB but it can be adjusted if needed.

Public interface exposes only two methods: get and set.

Test Plan

This is tested together with subsequent diffs

Diff Detail

Repository
rCOMM Comm
Lint
Lint Not Applicable
Unit
Tests Not Applicable

Event Timeline

bartek held this revision as a draft.
bartek published this revision for review.Mar 30 2023, 9:30 AM

Please address the nits before landing. Defer to you on whether the race condition I highlighted in set should be addressed or not

lib/components/media-cache-provider.react.js
47 ↗(On Diff #24387)

Nit: I don't think we need let here... the two cachedFiles are separate logically, so I would use two separate variables for them

51 ↗(On Diff #24387)

Nit: personally I don't like seeing await inline like this... I worry that it is is an important keyword and whether it gets "hit" can be easy to miss in scenarios like this.

My preference is to always have await appear as the first keyword in the right hand side of an assignment, ie. like this:

[left hand side] = await [right hand side];

As an example, if we had if (test || await something), I would replace it with:

let doThing = test;
if (doThing) {
  doThing = await something;
}

My contention is that it's harder for the reader to miss the await, and more "clear" where it happens.

65 ↗(On Diff #24387)

Wondering what clients of MediaCacheProvider will be calling this. I would initially guess it would be "internal" (caching only occurs on .get), but I guess we want .set for the media upload scenario?

73 ↗(On Diff #24387)

It appears that we have another "can only run one at a time" scenario here. If multiple set calls are initiated at the same time, the second one would get an empty backupMemoryCache, which would cause the in-memory cache to be cleared after the end of execution

I guess this is not a huge deal because of the comments below (in-memory cache is not so important), but I wonder if it could be improved

This revision is now accepted and ready to land.Mar 30 2023, 3:39 PM
lib/components/media-cache-provider.react.js
65 ↗(On Diff #24387)

Ah this was a silly question, the usage is in D7254 but basically we need to set it after decrypting every time

lib/components/media-cache-provider.react.js
51 ↗(On Diff #24387)

yeah, makes sense

65 ↗(On Diff #24387)

basically we need to set it after decrypting every time

exactly - this is for saving items to cache

73 ↗(On Diff #24387)

Yeah, I had a similar scenario in mind while putting these comments. My first idea was to get rid of the memory cache, but I decided to give it "limited trust" and rebuild if needed

We could do simple promise-deduping here but it won't save us completely.
The "filtering instead of replacing" solution I proposed below (L86-89) would work here. Let me know if you think it's worth doing

Moved await to separate statement. Replaced let with consts

lib/components/media-cache-provider.react.js
73 ↗(On Diff #24387)

I might be missing something, but I don't understand how the "filtering instead of replacing" solution would solve the issue.

My concern goes like this:

  1. Two images are displayed on screen at the same time, triggering calls to set (and consequently to cleanupCacheIfNecessary) at around the same time.
  2. The first set call has to temporarily clear the uriCache. We can't avoid this with "filtering", since we won't know what to filter until after the persistence.cleanupOldFiles call.
  3. The second set call now backs up the in-memory cache, but it is an empty cache because the first cache cleared it.
  4. Both set calls proceed. At the end of the second call, if any files were deleted, it will reset uriCache to backupMemoryCache, which is an empty set.

I think the "filtering instead of replacing" solution won't help because it still needs the in-memory cache to be temporarily cleared while the cleanup is happening. (Correct me if I'm wrong!)

I can see two solutions to this:

  1. Make sure the files are removed from the in-memory cache before they are removed from disk
    • You could imagine passing a callback into persistence.cleanupOldFiles to achieve this
  2. Make sure only one cleanup can occur at-a-time. Two sub-options:
    • If a cleanup is already in progress, then simply skip cleanup. The second call will miss the cache, but won't wipe the cache accidentally
    • The second call could still do a cleanup, but it could be forced to wait for the first cleanup to conclude before proceeding

I think it's okay either way, but I would probably pass a callback to persistence.cleanupOldFiles that just does a single Map.delete, which cleanupOldFiles can call before removing the file on disk.

81 ↗(On Diff #24387)

Is there a risk that a currently-used cache file will be deleted by the cleanup call? I guess we can assume that the currently-used cache file will be in memory by the time the file is deleted, but we'll need to be careful to make sure we don't eg. get the file path, await something, and THEN display the file, as the file could've been deleted in the interim

I wonder if it would be better / more simple to just do cleanup when the app starts instead of having it done on every set. I don't think it's very important that the cache stays strictly below the limit... if it goes above it for some time until the next app start, it's probably okay

bartek added inline comments.
lib/components/media-cache-provider.react.js
73 ↗(On Diff #24387)
  • Regarding solution 2, I think we meant the same (but I called it inaccurately "deduping").
  • The callback solution would also work but it makes the flow less obvious. However I'm not opposed to that

Anyway, I believe that doing this during app startup instead (discussion below) would solve this for free

81 ↗(On Diff #24387)

Yes, there is always a risk (if there's a thread with lots of media loaded at a time). This should be rare because native image/video components have the media loaded into their memory when displaying (and on remount it'd be decrypted again if not in cache). Anyway I admit it can occur.

I wonder if it would be better / more simple to just do cleanup when the app starts instead of having it done on every set. I don't think it's very important that the cache stays strictly below the limit... if it goes above it for some time until the next app start, it's probably okay

Agree, that would also solve the above cleanup race condition problem. What's the best place to do this?

lib/components/media-cache-provider.react.js
81 ↗(On Diff #24387)

What's the best place to do this?

We probably want to block the app render on cleanup, right?

Hmmm... my first though was in NavigationHandler. We can block it from returning`LogInHandler`, which will keep the UI from "logging in" (we won't dismiss LoggedOutModal).

However, the app is rendered underneath LoggedOutModal... I'm not sure if there is a risk of an image being loaded in that scenario.

We could do it in SQLiteDataHandler instead – that way, we could make sure cleanup occurs before the upload data is in Redux, which would be a "stronger" guarantee. It feels strange to call image cache code from a component meant to handle SQLite data, but it might be the best place.

Do you know how long the call might take?

Reaccepting to unblock for now, in case you want to land and fix later

This revision is now accepted and ready to land.Mar 31 2023, 8:43 AM

We probably want to block the app render on cleanup, right?

Yes, chat messages shouldn't be rendered before cleanup (or at least cache reading should be disabled)

Do you know how long the call might take?

It should be pretty fast - a few ms

It feels strange to call image cache code from a component meant to handle SQLite data, but it might be the best place.

This looks like a similar situation to https://phab.comm.dev/D7254?id=24392#inline-47680

Yes, chat messages shouldn't be rendered before cleanup (or at least cache reading should be disabled)

I thought about this a bit. I think it's possible for a MessageList to be rendered BEFORE NavigationHandler dismisses LoggedOutModal. The concrete scenario is if the app is opened from a push notif being pressed.

It should be pretty fast - a few ms

Given it, I think we should take the safe route and block SQLiteDataHandler from merging SQLite data until the cache is cleared.

Removed cache cleanup from set method and replaced it with the evictCache method that will be explicitly called in SQLiteDataHandler two diffs later.

ashoat added inline comments.
lib/components/media-cache-provider.react.js
83 ↗(On Diff #24590)

Can't think of anything... maybe just log the error?

Log potential eviction failure