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 13 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.
Co-authored-by: John "codefu" McDole <john@mcdole.org>
|
|
||
| /// Atomically inserts multiple [entries] into [subcacheName] in a single batch API call | ||
| /// if and only if their [VersionedCacheEntry.revisionId] is strictly greater than any | ||
| /// existing cached revision for that key. | ||
| Future<void> insertVersioned( | ||
| String subcacheName, | ||
| List<VersionedCacheEntry> entries, | ||
| ); | ||
|
|
There was a problem hiding this comment.
Question on the description: is it insert All or Nothing if any extra's revision id is not greater than?
| orderedPresubmit: OrderedPresubmit.defaultInstance, | ||
| dynamicTestSuppression: false, | ||
| geminiModel: 'gemini-3-flash-preview', | ||
| taskCachingEnabled: true, |
There was a problem hiding this comment.
As a matter of hygiene (cocoon roles out early in the AM) - I think this flag should be turned off by default, then enabled in the config.yaml when someone is online. this includes all the places where its defaulted to "true".
| /// See [TaskCacheService] for full architectural documentation and caching invariant flowcharts. | ||
| mixin FirestoreQueries { | ||
| CacheService? get cache => null; | ||
| Config? get config => null; |
There was a problem hiding this comment.
config should probably not be null - everywhere else it appears to be set. Maybe this is just a firestore.dart quirk?
| ); | ||
| } | ||
|
|
||
| // For tasks, therer is no reason to _not_ order this way. |
There was a problem hiding this comment.
does your agent hate comments or something? Why was this removed?
There was a problem hiding this comment.
(hint: my agent hates comments it didn't write)
| List<Task>? tasks; | ||
| final docIds = await _taskCache!.getTaskIdsForCommit(commitSha); | ||
| if (docIds != null && docIds.isNotEmpty) { | ||
| final lookupResult = await _taskCache!.getTaskPayloads(docIds); |
There was a problem hiding this comment.
this can and will return null. null! is not good.
| } else if (transform.increment case final inc?) { | ||
| if (field?.integerValue case final oldVal?) { | ||
| final newVal = int.parse(oldVal) + int.parse(inc.integerValue!); | ||
| fields[transform.fieldPath!] = Value(integerValue: newVal.toString()); |
| @@ -1213,10 +1213,7 @@ class LuciBuildService { | |||
| task.resetAsRetry(); | |||
There was a problem hiding this comment.
this changes the document name (_attempt_2?).
Not sure how valid this is, but agent says: "The new retry document ID is never added to tasks_by_commit_ids/$commitSha in Redis. When firestore.dart:354-420 is called, it reads the commit set from Redis (which only contains attempt 1), finds the cached payload, and completely ignores attempt 2 until the 12-hour TTL expire"
vs:
if (createdTasks.isNotEmpty) {
await _firestore.updateCacheForCreatedTasks(createdTasks);
}| final firestore = await FirestoreService.from(const GoogleAuthProvider()); | ||
| final firestore = await FirestoreService.from( | ||
| const GoogleAuthProvider(), | ||
| cache: cache, |
There was a problem hiding this comment.
We should probably pass config here?
| ]); | ||
| await _firestore.cacheTaskPayloads(tasksToUpdate); | ||
| if (createdTasks.isNotEmpty) { | ||
| await _firestore.updateCacheForCreatedTasks(createdTasks); |
There was a problem hiding this comment.
just passing through: "Because createdTasks is a subset of tasksToUpdate, and updateCacheForCreatedTasks internally calls cacheTaskPayloads, those created tasks' payloads are serialized and sent to Redis twice in immediate succession."
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):