Implement lock-free versioned (revisionId) and Set-based Redis caching for /tasks - #5103
Implement lock-free versioned (revisionId) and Set-based Redis caching for /tasks#5103eyebrowsoffire wants to merge 9 commits into
Conversation
…g for /tasks
To eliminate read bottlenecks against the Firestore /tasks (kTaskCollectionId = 'tasks') collection without distributed locking (tryLock), this change introduces an optimistic, multi-tier caching layer across Cocoon (Task, FirestoreQueries, and CacheService):
1. Lock-Free Versioned (revisionId) Payload Caching (tasks subcache):
- Every Task document contains a monotonically increasing revisionId integer.
- CacheService.insertVersioned(subcacheName, entries) executes an atomic check-and-set via Redis Lua script (EVAL) verifying entry.revisionId > cachedRevisionId before updating payloads.
- Chunked batching (batchSize = 20) guarantees <0.1ms Lua execution without starving concurrent readers (MGET).
2. Native Set-Based Commit Task Indexing (tasks_by_commit_ids):
- Replaces monolithic JSON array strings with native Redis Sets (SMEMBERS, SADD) via getSet, updateSet, and addToSetIfExists.
- Leverages two domain invariants:
1. Immutable commitSha: Task mutations (updateCacheForTaskMutations) never change a commit's task list membership and do not touch or invalidate tasks_by_commit_ids.
2. Monotonically Increasing Set: Task attempts only grow over time, so unioning IDs via SADD (updateCacheForCreatedTasks) safely converges without lock coordination.
3. Partial Cache Recovery (_queryTasksByCommitCached):
- When reading commit tasks, _queryTasksByCommitCached retrieves cached task IDs (SMEMBERS) and performs a batch lookup (MGET).
- If individual payloads are expired or missing (missingDocIds), _queryTasksByCommitCached selectively queries getDocument only for missing entries and merges them with foundTasks, eliminating redundant full-commit queries.
4. Modular & Explicit Cache Handlers (FirestoreQueries):
- _cacheTaskDocuments(tasks): Reusable helper for versioned Task payload insertions.
- _fetchAndCacheCommitTasks(commitSha): Reusable slow-path query helper shared across read-miss and task-creation-miss paths.
- updateCacheForCreatedTasks(tasks) and updateCacheForTaskMutations(writes): Explicit domain handlers replacing generic ad-hoc cache invalidations.
- _subcacheRecentTasksIds and _queryRecentTasksByNameCached removed in favor of _cacheTaskDocuments warming on query execution (~65 lines simplified).
|
This pull request is not mergeable in its current state, likely because of a merge conflict. Pre-submit CI jobs were not triggered. Pushing a new commit to this branch that resolves the issue will result in pre-submit jobs being scheduled. |
There was a problem hiding this comment.
Code Review
This pull request introduces a lock-free, versioned, and set-based caching strategy for Firestore tasks in Cocoon to resolve read bottlenecks. It adds optimistic concurrency tracking via a revisionId on tasks, introduces batch and set operations to the CacheService (with Redis and in-memory implementations), and integrates caching into FirestoreQueries. The review feedback highlights critical issues: a memory leak in Redis where revision IDs are stored in a non-expiring shared hash, incorrect caching behavior when tasks are deleted, and a performance bottleneck caused by fetching missing tasks sequentially in a loop instead of in parallel.
|
re-looking at this now. |
jtmcdole
left a comment
There was a problem hiding this comment.
This change is still virtually too larger for me to review alone (~1500 lines). I'm not a redis or lua expert, so I had to fall back on some gemini prompts to try and tease out.
| List<VersionedCacheEntry> entries, | ||
| ) async { | ||
| if (entries.isEmpty) return; | ||
| const insertVersionedScript = ''' |
There was a problem hiding this comment.
I am not a LUA person, so I'm totally falling back on Gemini here: keys (tasks/$docId) are passed as the KEYS arguments to EVAL. However, inside insertVersionedScript, the script dynamically constructs and accesses revision keys via Lua string concatenation.
Redis specification dictates that ALL keys accessed by a Lua script MUST be explicitly declared in the KEYS array.
I think we ok if we have only one redis instance? But the robot says "just pass revKey along with key in KEYS, and then use {tasks/$docId}.
Again, I'm not the expert here.
| return values.map((value) { | ||
| if (value == null) return null; | ||
| return base64.decode(value as String); | ||
| }).toList(); |
There was a problem hiding this comment.
| return values.map((value) { | |
| if (value == null) return null; | |
| return base64.decode(value as String); | |
| }).toList(); | |
| return [ | |
| for (final value in values) | |
| value == null ? null : base64.decode(value as String) | |
| ]; |
| local numKeys = tonumber(ARGV[1]) | ||
| for i = 1, numKeys do | ||
| local key = KEYS[i] | ||
| local val = ARGV[1 + (i - 1) * 3 + 1] |
There was a problem hiding this comment.
agian, not a lua person, but should (i - 1) * 3 be pulled out as a local index?
| if (values is! List || values.isEmpty) { | ||
| return const {}; | ||
| } | ||
| return values.map((e) => e.toString()).toSet(); |
There was a problem hiding this comment.
| return values.map((e) => e.toString()).toSet(); | |
| return {for (var e in values) '$e'}; |
| /// Evicts a deleted task payload from Redis. | ||
| Future<void> evictTaskPayload(String docId) async { | ||
| if (!isEnabled) return; | ||
| await cache.purge('tasks', docId); |
There was a problem hiding this comment.
Gemini suggests this poisons the cache because:
When a task document is deleted from Firestore,
evictTaskPayload(docId)purgestasks/$docIdandrevisions/tasks/$docIdfrom Redis. However, it does not removedocIdfromtasks_by_commit_ids/$commitSha.
| if (taskIds.isEmpty || !isEnabled) return false; | ||
| var allAdded = true; | ||
| for (final taskId in taskIds) { | ||
| final added = await cache.addToSetIfExists( |
There was a problem hiding this comment.
we easily have tasks.length > 100; so we're calling redis 100 times?
The current "addToSetIfExists" only returns true if the set exists, not if the key was added (missing), so you could do something like:
if redis.call("exists", KEYS[1]) == 1 then
redis.call("sadd", KEYS[1], unpack(ARGV, 2))
return 1
end
return 0| await _runCommand( | ||
| (client) => client.send_object([ | ||
| 'EVAL', | ||
| insertVersionedScript, | ||
| keys.length.toString(), | ||
| ...keys, | ||
| ...args, | ||
| ]), | ||
| ); |
There was a problem hiding this comment.
Chunking good: +1
you should add this future to "List<Future> chunks" and then use one await chunks.wait;
| if (status != null) { | ||
| result = result.where((t) => t.status == status).toList(); | ||
| } | ||
| result.sort((a, b) => b.createTimestamp.compareTo(a.createTimestamp)); |
There was a problem hiding this comment.
if the timestamp is the same, maybe also compare against currentAttempt?
To eliminate read bottlenecks against the Firestore /tasks (kTaskCollectionId = 'tasks') collection without distributed locking (tryLock), this change introduces an optimistic, multi-tier caching layer across Cocoon (Task, FirestoreQueries, and CacheService):
Lock-Free Versioned (revisionId) Payload Caching (tasks subcache):
Native Set-Based Commit Task Indexing (tasks_by_commit_ids):
Partial Cache Recovery (_queryTasksByCommitCached):
Modular & Explicit Cache Handlers (FirestoreQueries):