Skip to main content

The Cache Manager and Service Layer

The Flyte Cache Service is structured into two primary layers: a thin service layer that handles transport and request validation, and a manager layer that implements the core business logic for cache persistence and distributed coordination.

Architecture Overview​

The Cache Service follows a layered architecture to separate API concerns from business logic:

  1. Service Layer (CacheService): Acts as the entry point for ConnectRPC requests. It validates incoming messages and translates multi-tenant identifiers (Project/Domain) into internal scoped keys.
  2. Manager Layer (Manager): Orchestrates cache operations. It manages the lifecycle of cache entries and coordinates "reservations" to prevent multiple workers from computing the same result simultaneously.
  3. Repository Layer: Handles the actual persistence of cache metadata and reservations to a database (e.g., PostgreSQL).

The Service Layer​

When you send a request to the Cache Service, the CacheService class in cache_service/service/service.go is responsible for the initial handling. It uses the validatableRequest interface to ensure that all incoming protobuf messages are valid before processing.

Key Scoping​

Flyte ensures that cache entries are isolated by project and domain. The service layer automatically transforms a user-provided key into a "scoped key" using the scopedKey function:

func scopedKey(key string, id *cacheservicev2.Identifier) string {
return fmt.Sprintf("%s-%s-%s", id.GetProject(), id.GetDomain(), key)
}

This means if you request a cache entry for key my-task-output in project flytesnacks and domain development, the internal key used by the Manager will be flytesnacks-development-my-task-output.

Request Validation​

The service layer enforces strict validation on all requests. It defines a validatableRequest interface that matches the Validate() method generated by protoc-gen-validate:

type validatableRequest interface {
Validate() error
}

func validateRequest(msg validatableRequest) error {
if err := msg.Validate(); err != nil {
return connect.NewError(connect.CodeInvalidArgument, err)
}
return nil
}

The Manager Layer​

The Manager class in cache_service/manager/manager.go owns the core behavior of the service. It does not store the actual data blobs (which remain in object storage); instead, it stores the OutputURI and associated Metadata.

Retrieving and Storing Entries​

When the Manager retrieves an entry, it returns a CacheEntry struct:

type CacheEntry struct {
OutputURI string
Metadata *cacheservicepb.Metadata
}

The Put operation handles metadata merging. If an entry already exists, the Manager ensures that the CreatedAt timestamp is preserved while updating the LastUpdatedAt field via the mergeMetadata helper:

func mergeMetadata(existing *models.CachedOutput, request *cacheservicepb.Metadata, now time.Time) *cacheservicepb.Metadata {
// ... logic to preserve CreatedAt from existing record ...
metadata.LastUpdatedAt = timestamppb.New(now)
return metadata
}

Distributed Coordination with Reservations​

To prevent a "thundering herd" problem—where multiple workers attempt to compute and cache the same result at once—Flyte uses a reservation system.

How Reservations Work​

When a worker experiences a cache miss, it attempts to "reserve" the right to populate that cache entry. The Manager.GetOrExtendReservation method coordinates this:

  1. Claiming: A worker requests a reservation with an OwnerID.
  2. Heartbeating: The reservation is valid for a specific duration. The worker must periodically "extend" the reservation to keep it.
  3. Exclusion: If another worker tries to claim the same key while a valid reservation exists, the Manager returns the current owner's information instead of granting a new reservation.
func (m *Manager) GetOrExtendReservation(ctx context.Context, request *cacheservicepb.GetOrExtendReservationRequest, now time.Time) (*cacheservicepb.Reservation, error) {
// ...
reservationKey := fmt.Sprintf("%s:%s", reservationPrefix, request.GetKey())
// ...
if err == nil {
if err := m.reservations.UpdateIfExpiredOrOwned(ctx, reservation, now); err != nil {
if repositoryerrors.IsReservationNotClaimable(err) {
// Another caller still owns the reservation
current, getErr := m.reservations.Get(ctx, reservationKey)
return reservationFromModel(current), nil
}
}
}
// ...
}

Reservations are stored with a reservation: prefix to distinguish them from the actual cache entries.

Configuration​

The behavior of the Manager is tuned via the cacheconfig.Config struct. Key parameters include:

  • HeartbeatGracePeriodMultiplier: (Default: 3) The number of heartbeat intervals that can pass before a reservation is considered expired. For example, if the heartbeat interval is 10s and the multiplier is 3, the reservation expires after 30s of inactivity.
  • MaxReservationHeartbeatInterval: (Default: 10s) The maximum duration a worker can request for a single heartbeat interval.

These values are initialized in the Manager constructor:

func New(cfg *cacheconfig.Config, outputs interfaces.CachedOutputRepo, reservations interfaces.ReservationRepo) *Manager {
maxHeartbeat := cfg.MaxReservationHeartbeat.Duration
if maxHeartbeat <= 0 {
maxHeartbeat = 10 * time.Second
}

graceMultiplier := cfg.HeartbeatGracePeriodMultiplier
if graceMultiplier <= 0 {
graceMultiplier = 3
}

return &Manager{
outputs: outputs,
reservations: reservations,
heartbeatGracePeriodMultiplier: graceMultiplier,
maxReservationHeartbeatInterval: maxHeartbeat,
}
}