Skip to content

Implement lock-free versioned (revisionId) and Set-based Redis caching for /tasks - #5103

Open
eyebrowsoffire wants to merge 13 commits into
flutter:mainfrom
eyebrowsoffire:tasks_cache
Open

Implement lock-free versioned (revisionId) and Set-based Redis caching for /tasks#5103
eyebrowsoffire wants to merge 13 commits into
flutter:mainfrom
eyebrowsoffire:tasks_cache

Conversation

@eyebrowsoffire

Copy link
Copy Markdown
Contributor

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).

…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).
@eyebrowsoffire eyebrowsoffire added the CICD Run CI/CD label Jul 10, 2026
@flutter-dashboard

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app_dart/lib/src/service/cache_service.dart
Comment thread app_dart/lib/src/service/cache_service.dart
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/cache_service.dart
Comment thread app_dart/lib/src/model/firestore/task.dart Outdated
@eyebrowsoffire
eyebrowsoffire marked this pull request as ready for review July 23, 2026 20:54
@eyebrowsoffire
eyebrowsoffire requested a review from jtmcdole July 24, 2026 00:14
@jtmcdole

jtmcdole commented Aug 7, 2026

Copy link
Copy Markdown
Member

re-looking at this now.

@jtmcdole jtmcdole left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app_dart/lib/src/service/cache_service.dart
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/firestore/task_cache_service.dart Outdated
Comment thread app_dart/lib/src/service/firestore/task_cache_service.dart Outdated
Comment thread app_dart/lib/src/service/cache_service.dart Outdated
Comment thread app_dart/lib/src/service/firestore.dart Outdated
Comment on lines 67 to +75

/// 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,
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does your agent hate comments or something? Why was this removed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newVal.toValue()

@@ -1213,10 +1213,7 @@ class LuciBuildService {
task.resetAsRetry();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably pass config here?

]);
await _firestore.cacheTaskPayloads(tasksToUpdate);
if (createdTasks.isNotEmpty) {
await _firestore.updateCacheForCreatedTasks(createdTasks);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants