Skip to content

Comments

feat(flags): add CachedFeaturesRepository with Redis caching#26123

Closed
eunjae-lee wants to merge 27 commits intomainfrom
devin/cached-features-repository-1766399596
Closed

feat(flags): add CachedFeaturesRepository with Redis caching#26123
eunjae-lee wants to merge 27 commits intomainfrom
devin/cached-features-repository-1766399596

Conversation

@eunjae-lee
Copy link
Contributor

@eunjae-lee eunjae-lee commented Dec 22, 2025

What does this PR do?

Adds a Redis caching layer for FeaturesRepository using the Proxy Pattern, similar to BillingServiceCachingProxy. The CachedFeaturesRepository wraps the existing repository and transparently caches read operations with zero code changes to consumers.

Key Features

  • Composite cache keys: userId/teamId + featureIds (e.g., features:userFeatureStates:123:emails,insights)
  • Schema validation: Zod schemas prevent corrupted cache data from being returned
  • Smart key normalization: Consistent keys regardless of featureIds order
  • Selective caching: Batch methods cached, single feature checks delegate to repository
  • Comprehensive tests: 39 unit tests with 100% coverage of caching logic
  • DI support: Full moduleLoader pattern integration following fix: enable DI for FeatureOptInService #26061 conventions

Updates since last revision

  • Reverted FeatureOptInService to use direct FeaturesRepository instead of CachedFeaturesRepository to avoid read-after-write consistency issues (the service performs mutations that don't invalidate caches)
  • CachedFeaturesRepository remains available via getCachedFeaturesRepository() for read-only use cases
  • Updated FeatureOptInService class to use IFeaturesRepository interface for flexibility

Caching Strategy

Cached operations (24-hour TTL):

  • getUserFeatureStates - Composite key: userId + featureIds
  • getTeamsFeatureStates - Composite key per team: teamId + featureIds
  • getAllFeatures, checkIfFeatureIsEnabledGlobally, getTeamsWithFeatureEnabled
  • getUserAutoOptIn, getTeamsAutoOptIn - Exact invalidation on mutation

Not cached (always delegate to repository):

  • checkIfUserHasFeature, checkIfUserHasFeatureNonHierarchical - Hierarchical resolution requires cross-entity logic
  • checkIfTeamHasFeature - Single feature lookups inefficient with composite keys

Cache invalidation:

  • Feature states: TTL-only (no explicit invalidation) - accepts up to 24h staleness
  • Auto opt-in: Explicit invalidation on setUserAutoOptIn / setTeamAutoOptIn

Architecture

// packages/features/flags/cached-features.repository.ts
export type CachedFeaturesRepositoryDeps = {
  featuresRepository: IFeaturesRepository;
  redisService: IRedisService;
  options?: { cacheTtlMs?: number };
};

export class CachedFeaturesRepository implements IFeaturesRepository {
  constructor(deps: CachedFeaturesRepositoryDeps) { ... }
}

// DI usage via container
import { getCachedFeaturesRepository } from "@calcom/features/di/containers/CachedFeaturesRepository";
const repo = getCachedFeaturesRepository();

Important Notes

  • Eventual consistency: Feature state mutations don't invalidate composite key caches. Data may be stale for up to 24 hours after setUserFeatureState / setTeamFeatureState. This tradeoff simplifies cache invalidation logic.

  • Not wired to FeatureOptInService: Due to read-after-write consistency concerns, FeatureOptInService continues to use the direct FeaturesRepository. CachedFeaturesRepository is available for read-heavy use cases that can tolerate eventual consistency.

  • Single feature checks not cached: checkIfUserHasFeature* and checkIfTeamHasFeature always hit the database. If these are performance-critical, we can add per-feature caching in a follow-up.

Files Changed

Core implementation:

  • packages/features/flags/cached-features.repository.ts - Proxy implementation with DI-compatible constructor
  • packages/features/flags/features-cache-keys.ts - Cache key definitions + Zod schemas

DI wiring:

  • packages/features/di/modules/CachedFeaturesRepository.ts - moduleLoader with depsMap
  • packages/features/di/containers/CachedFeaturesRepository.ts - Container function
  • packages/features/flags/di/tokens.ts - DI tokens
  • packages/features/redis/di/redisModule.ts - Redis moduleLoader export

Service updates:

  • packages/features/feature-opt-in/services/FeatureOptInService.ts - Uses IFeaturesRepository interface

Tests:

  • packages/features/flags/__tests__/cached-features.repository.test.ts - 39 unit tests
  • packages/features/flags/__tests__/spy-features.repository.ts - Mock repository
  • packages/features/flags/__tests__/mock-redis.service.ts - In-memory Redis mock

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - internal caching implementation
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  1. Environment: Requires UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN environment variables for Redis caching to be active (falls back to NoopRedisService otherwise)

  2. Unit tests: Run yarn test packages/features/flags/__tests__/ - 39 tests covering all scenarios

  3. Type checks: Run yarn type-check:ci --force

Review Checklist

  • DI wiring correctness: Verify moduleLoader pattern follows fix: enable DI for FeatureOptInService #26061 conventions
  • Constructor signature: Confirm deps object pattern works with DI system
  • Eventual consistency tradeoff: Verify 24-hour stale cache window for feature states is acceptable for intended use cases
  • Zod validation performance: Confirm safeParse() on every cache read/write is acceptable
  • No unintended consumers: Verify CachedFeaturesRepository is not accidentally used where read-after-write consistency is needed

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have checked if my changes generate no new warnings

Link to Devin run: https://app.devin.ai/sessions/a6e8ab4bdb8b43cb96fad0904ca278b6
Requested by: eunjae@cal.com (@eunjae-lee)

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@vercel
Copy link

vercel bot commented Dec 22, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

4 Skipped Deployments
Project Deployment Review Updated (UTC)
api-v2 Ignored Ignored Preview Jan 8, 2026 10:51am
cal Ignored Ignored Jan 8, 2026 10:51am
cal-companion Ignored Ignored Preview Jan 8, 2026 10:51am
cal-eu Ignored Ignored Jan 8, 2026 10:51am

@eunjae-lee eunjae-lee force-pushed the feat/feature-opt-in-service branch from bc58bb0 to d075b06 Compare December 22, 2025 11:11
@eunjae-lee eunjae-lee force-pushed the devin/cached-features-repository-1766399596 branch from 57901a7 to 7446133 Compare December 22, 2025 13:21
@eunjae-lee eunjae-lee force-pushed the feat/feature-opt-in-service branch from d075b06 to 496dc13 Compare December 22, 2025 13:21
Copy link
Contributor Author

eunjae-lee commented Dec 22, 2025

This stack of pull requests is managed by Graphite. Learn more about stacking.

devin-ai-integration bot and others added 5 commits January 6, 2026 16:59
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
…+ featureIds)

- getUserFeatureStates now uses userId + featureIds as composite cache key
- getTeamsFeatureStates now uses teamId + featureIds as composite cache key per team
- Removed partial cache merging logic for simpler full hit/miss pattern
- Single feature checks (checkIfUserHasFeatureNonHierarchical, checkIfTeamHasFeature) delegate to repository
- Feature state mutations no longer invalidate cache (rely on TTL)
- Added normalizeFeatureIds to ensure consistent cache key generation
- Updated tests to reflect new caching behavior

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
Extract shared per-team cache resolution and align cache types with partial feature maps.

Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
- Add schema.safeParse validation before storing values in cache
- Throw error with cache key in message if validation fails
- Add 6 tests for invalid write scenarios

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
@eunjae-lee eunjae-lee marked this pull request as ready for review January 8, 2026 10:55
@graphite-app graphite-app bot added core area: core, team members only consumer labels Jan 8, 2026
@graphite-app graphite-app bot requested a review from a team January 8, 2026 10:56
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 12 files

eunjae-lee and others added 3 commits January 13, 2026 11:07
…duleLoader pattern

- Add Redis moduleLoader in packages/features/redis/di/redisModule.ts
- Add CachedFeaturesRepository tokens to packages/features/flags/di/tokens.ts
- Create moduleLoader for CachedFeaturesRepository with depsMap (featuresRepository + redisService)
- Create container function getCachedFeaturesRepository
- Update CachedFeaturesRepository constructor to accept deps object for DI compatibility
- Update tests to use new constructor signature

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
…tory

- Update FeatureOptInService module to use CachedFeaturesRepository instead of raw FeaturesRepository
- Update FeatureOptInService class to use IFeaturesRepository interface for flexibility

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/features/di/modules/FeatureOptInService.ts">

<violation number="1" location="packages/features/di/modules/FeatureOptInService.ts:5">
P1: Using CachedFeaturesRepository for FeatureOptInService creates read-after-write consistency issues. The service performs mutations (setUserFeatureState, setTeamFeatureState) that don't invalidate caches, then reads (getUserFeatureStates, getTeamsFeatureStates) that return stale cached data for up to 24 hours. Users will enable features but see them as disabled until cache expires.

Consider keeping FeatureOptInService on the direct FeaturesRepository, or implement cache invalidation for composite keys when mutations occur.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@github-actions
Copy link
Contributor

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session

Reverts FeatureOptInService to use FeaturesRepository instead of CachedFeaturesRepository
to avoid read-after-write consistency issues. The service performs mutations
(setUserFeatureState, setTeamFeatureState) that don't invalidate caches, which would
cause users to see stale data for up to 24 hours after enabling features.

CachedFeaturesRepository remains available for read-only use cases.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/features/di/modules/FeatureOptInService.ts">

<violation number="1">
P0: FeatureOptInService is wired to use FeaturesRepository instead of CachedFeaturesRepository, contradicting the PR's stated goal. This change bypasses the Redis caching layer entirely, defeating the purpose of this PR.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@github-actions
Copy link
Contributor

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session

Copy link
Contributor

@volnei volnei left a comment

Choose a reason for hiding this comment

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

@eunjae-lee since we're touching here, I think we need a better refactor on this...

  1. separate features.repository by global/user/team
  2. create a service to handle instead of handle it always from repositories
  3. create a wrapper that enable cache (memoize) as I think it should be responsibility of a service
  4. create a endpoint to enable faster clear cache (with a secret)
  5. create a CachedFeatureFlagService/Repository that fallback to database when redis is not available
  6. No more provider being accessed directly

@github-actions github-actions bot marked this pull request as draft January 13, 2026 10:45
@devin-ai-integration
Copy link
Contributor

Closing to start fresh with separate repositories as discussed in the review feedback.

devin-ai-integration bot added a commit that referenced this pull request Jan 13, 2026
…lags

This PR introduces 6 separate repository classes for feature flags:

Prisma Repositories (Database Layer):
- PrismaFeatureRepository: Global Feature table operations
- PrismaTeamFeatureRepository: TeamFeatures table operations
- PrismaUserFeatureRepository: UserFeatures table operations

Redis Repositories (Cache Layer):
- RedisFeatureRepository: Redis caching for global features
- RedisTeamFeatureRepository: Redis caching for team features
- RedisUserFeatureRepository: Redis caching for user features

This separation follows the feedback from PR #26123 to:
1. Separate features.repository by global/user/team
2. Enable a service layer to orchestrate these repositories
3. Support caching wrappers that can be added later

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
devin-ai-integration bot added a commit that referenced this pull request Jan 13, 2026
- Add moduleLoader export directly to packages/features/redis/di/redisModule.ts
  following the pattern from PR #26123
- Remove packages/features/di/modules/Redis.ts (was creating unnecessary indirection)
- Update Redis repository modules to import from redisModule.ts directly

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

consumer core area: core, team members only ❗️ migrations contains migration files ready-for-e2e size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants