Skip to content

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

Draft
eyebrowsoffire wants to merge 3 commits into
flutter:mainfrom
eyebrowsoffire:tasks_cache
Draft

Implement lock-free versioned (revisionId) and Set-based Redis caching for /tasks#5103
eyebrowsoffire wants to merge 3 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
Comment thread app_dart/lib/src/service/firestore.dart Outdated

local revKey = "revisions/" .. key
local existingRev = tonumber(redis.call("get", revKey) or 0)
if rev > existingRev or (not redis.call("exists", key)) then

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.

Gemini suggests a truthy take for "if the incoming revision is newer, OR if the main key is missing entirely, write it.":

I don't know REDIS or Lua all that well, but it suggests that "key exists == 1" and that "not 0" is false.

        if rev > existingRev or redis.call("exists", key) == 0 then
          redis.call("set", key, val, "PX", ttl)
          redis.call("set", revKey, rev, "PX", ttl)
        end

Comment on lines +62 to +87
/// ### 1. Individual Task Payload Caching (`tasks` subcache)
/// - **Single Source of Truth**: Full [Task] document payloads are serialized to JSON (`_serializeTask`)
/// and stored in the `tasks` subcache keyed by exact document ID (`$commitSha_$taskName_$attempt`).
/// - **Optimistic Concurrency (`revisionId`)**: Every task document contains a monotonically increasing integer
/// `revisionId`. Updates and insertions use [CacheService.insertVersioned], which runs an atomic check-and-set
/// ensuring payload updates are only applied if `newRevisionId > cachedRevisionId`.
/// - **Chunked Batching**: Multi-task insertions (`_cacheTaskDocuments`) are chunked in batches of 20 (`batchSize = 20`)
/// via atomic Redis Lua (`EVAL`) scripts, guaranteeing execution in `<0.1ms` without starving concurrent readers.
///
/// ### 2. Commit Task List Set Caching (`tasks_by_commit_ids` subcache)
/// - **Native Set Operations**: Instead of storing monolithic serialized JSON arrays of task IDs, commit task indices
/// are stored in native Redis Sets (`SMEMBERS`, `SADD`), backed by two fundamental domain invariants:
/// 1. **Immutable `commitSha`**: A task's commit association never changes after creation. Therefore, status or field
/// mutations (`patchStatus`) never alter the membership of a commit's task list (`updateCacheForTaskMutations`
/// updates individual `tasks/$docId` entries in-place lock-free and never touches or invalidates `tasks_by_commit_ids`).
/// 2. **Monotonically Increasing Set**: Task attempts for a commit only grow over time. Adding task IDs via `SADD`
/// ([CacheService.updateSet], [CacheService.addToSetIfExists]) safely converges to database state without locks.
///
/// ### 3. Partial Cache Recovery (`_queryTasksByCommitCached`)
/// - When querying all tasks for a commit, we retrieve the cached Set of document IDs (`SMEMBERS`) and perform a batch
/// payload lookup across `tasks` (`MGET`).
/// - If any individual task entries have expired or are missing from `tasks`, successfully retrieved cached tasks
/// ([foundTasks]) are preserved immediately. Missing document IDs (`missingDocIds`) are queried individually via
/// [batchGetDocuments], inserted into `tasks` via `insertVersioned`, and combined with [foundTasks]—avoiding a full table query.
/// - If `tasks_by_commit_ids` is missing entirely (`docIds.isEmpty`), we execute a full query (`_fetchAndCacheCommitTasks`),
/// populate `tasks` for all items, and initialize the commit Set via [CacheService.updateSet].

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.

I think this could have used a mermaidjs sequence diagram or something. It was a lot to read and I'm fairly certain I understood about 25% of it via reading redis.io docs.

Comment on lines +96 to +97
return Uint8List.fromList(
utf8.encode(

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.

utf8.encode already returns a Uint8List: https://api.dart.dev/dart-convert/Utf8Codec/encode.html

}

static Task _deserializeTask(Uint8List data) {
final jsonMap = json.decode(utf8.decode(data)) as Map<String, dynamic>;

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.

inner matan: Map<String, Object?>

/// Caches the individual [Task] payloads into the `tasks` subcache.
Future<void> _cacheTaskDocuments(
List<Task> tasks, {
Duration ttl = const Duration(hours: 12),

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.

is const Duration(hours: 12) a number we every think we'll want to tweak or change on the fly? If so, consider adding it to the dynamic config with a default of 12 hours.

transaction: transaction,
);
final tasks = documents.map(Task.fromDocument).toList();
if (cacheResults && transaction == null && cache != 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.

I want a dynamic flag that overrides this and keeps the old path. That way we're talking about ~5 minutes to rollout or rollback.

Transaction? transaction,
}) async {
if (transaction == null && cache != null) {
return await _queryTasksByCommitCached(

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.

same here; caching shoudl be flagged in case we need to turn it off - we should set a date on when to remove the flag.

Comment on lines +569 to +572
if (_entries.length >= maxEntries &&
!_entries.containsKey(cacheKey)) {
_entries.remove(_entries.keys.first);
}

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 makes the third copy of this code; move to a new function and update the other two identical versions of this

Comment on lines +573 to +577
_entries[cacheKey] = _InMemoryCacheEntry(
entry.value,
DateTime.now().add(entry.ttl),
revisionId: entry.revisionId,
);

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.

Do you know if this cache service was trying to do an LRU cache? If so, shouldn't we remove and re-insert when read/writing to the cache so it updates in insertion order?

Comment on lines +143 to +155
static Uint8List _serializeTask(Task task) {
return Uint8List.fromList(
utf8.encode(
json.encode(Document(name: task.name, fields: task.fields).toJson()),
),
);
}

static Task _deserializeTask(Uint8List data) {
final jsonMap = json.decode(utf8.decode(data)) as Map<String, dynamic>;
return Task.fromDocument(Document.fromJson(jsonMap));
}

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.

These are duplicates in firestore.dart.

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