Snapshots & @CacheSnapshot

The cache stores snapshot records, never entities. Writing those records by hand for every entity is repetitive, so cache-snapshot-processor generates them at compile time from one annotation on the entity — the record, its from(entity) mapping, and the BINDING a facade needs.

On this page

    Concept

    Put @CacheSnapshot on a JPA entity and the build writes <Entity>Snapshot into target/generated-sources/annotations. You read the generated file; you never edit it. What it contains:

    Note

    BINDING is emitted only when cache-core is on the compile classpath, and never for embedded records. Old consumers that only use the master-data starter keep compiling unchanged.

    A static region

    @Entity
    @CacheSnapshot(region = "eventConfig",
            indexes = {
                    @SnapshotIndex(name = "byEventName", property = "eventName"),
                    @SnapshotIndex(name = "byEventType", property = "eventType")},
            paths = {
                    @SnapshotPath(name = "alertsId", path = "alerts.id"),
                    @SnapshotPath(name = "eventGroupId", path = "eventGroup.id")},
            embeds = {
                    @SnapshotEmbed(name = "alerts", property = "alerts"),
                    @SnapshotEmbed(name = "eventGroup", property = "eventGroup")})
    public class EventConfiguration { … }

    Generated (abridged):

    @MasterDataRegion(name = "eventConfig", entity = EventConfiguration.class, indexes = { … })
    public record EventConfigurationSnapshot(Long id, String eventName, String eventType, …,
            Long alertsId, Long eventGroupId, AlertsSnapshot alerts, EventGroupSnapshot eventGroup) {
    
        public static final String REGION = "eventConfig";
    
        public static final RegionBinding<Long, EventConfigurationSnapshot> BINDING =
                RegionBinding.staticRegion("eventConfig", Long.class, EventConfigurationSnapshot.class)
                        .index("byEventName", EventConfigurationSnapshot::eventName)
                        .index("byEventType", EventConfigurationSnapshot::eventType)
                        .build();
    
        public static EventConfigurationSnapshot from(EventConfiguration entity) { … }
    }

    A transactional region with nested data

    The userAlerts aggregate in indc-core shows every element the annotation offers. UserAlerts itself is embedded because the region's value is a wrapper record (UserAlertsByLogin) holding a list of these snapshots; its children are embedded too.

    @Entity
    @CacheSnapshot(type = RegionKind.TRANSACTIONAL, embedded = true,
            paths = {
                    @SnapshotPath(name = "eventConfigurationId", path = "alerts.eventConfiguration.id"),
                    @SnapshotPath(name = "eventGroupName", path = "alerts.eventConfiguration.eventGroup.groupName")},
            embeds = @SnapshotEmbed(name = "config", property = "userAlertConfig"),
            collections = {
                    @SnapshotCollection(name = "conditions", property = "userAlertConditions"),
                    @SnapshotCollection(name = "devices", property = "userAlertDevices")})
    public class UserAlerts {
        @Id private Long id;
        private String loginId;
        …
        @ManyToOne private Alerts alerts;
        @OneToOne  private UserAlertConfig userAlertConfig;
        @OneToMany private List<UserAlertCondition> userAlertConditions;
        @OneToMany private List<UserAlertDevice> userAlertDevices;
        @SnapshotIgnore private String validationHash;
    }
    
    @Entity @CacheSnapshot(embedded = true) public class UserAlertConfig { … }
    @Entity @CacheSnapshot(embedded = true) public class UserAlertCondition { … }
    @Entity @CacheSnapshot(embedded = true) public class UserAlertDevice { … }
    public record UserAlertsSnapshot(
            Long id, String userAlertName, String loginId, String alertChannel,
            LocalDate validStartDate, LocalDate validEndDate, boolean allDevices, Status status,
            Long alertsId,                              // @ManyToOne → its id, automatically
            Long eventConfigurationId,                  // path
            String eventGroupName,                      // path
            UserAlertConfigSnapshot config,             // embed
            List<UserAlertConditionSnapshot> conditions, // collection
            List<UserAlertDeviceSnapshot> devices        // collection
    ) {
        public static UserAlertsSnapshot from(UserAlerts entity) {
            if (entity == null) return null;
            return new UserAlertsSnapshot(
                    entity.getId(), …,
                    readAlertsId(entity), readEventConfigurationId(entity), readEventGroupName(entity),
                    UserAlertConfigSnapshot.from(entity.getUserAlertConfig()),
                    entity.getUserAlertConditions() == null ? List.of()
                            : entity.getUserAlertConditions().stream().map(UserAlertConditionSnapshot::from).toList(),
                    …);
        }
    }
    Tip

    from(entity) is called inside the loader's read-only transaction, so walking alerts.eventConfiguration.eventGroup and the two @OneToMany lists is safe even when they are lazy. Make sure the loader's query fetches what the mapping needs, or accept the extra selects on the load path — never on the read path.

    Annotation reference

    elementdefaultmeaning
    region""Region name. Always declare it explicitly on every region-owning snapshot — the region name is a contract other pods and applications see, so it must never be implicit. Only an embedded snapshot carries no region (the wrapper that owns the region names it).
    typeSTATICRegionKind.STATIC (preloaded, versioned, @MasterDataRegion emitted) or TRANSACTIONAL (per key, TTL).
    ttl""ISO-8601 duration such as PT5M or PT24H. Becomes BINDING's default L2 TTL; overridable by dac.cache.regions.<region>.l2-ttl.
    idProperty"id"The property used as the cache key.
    indexesnone@SnapshotIndex(name, property, multiValued). A single-valued index maps one value to one entry; multiValued = true maps one value to many (answers listByIndex).
    pathsnone@SnapshotPath(name, path) — flatten a value from a related entity with a null-safe getter walk ("alerts.eventConfiguration.id").
    embedsnone@SnapshotEmbed(name, property) — include a related entity as its own (embedded) snapshot record.
    collectionsnone@SnapshotCollection(name, property) — include a @OneToMany as List<ChildSnapshot>; a null collection becomes an empty list.
    embeddedfalseThe record is a building block for other snapshots, not a region of its own: no REGION, no BINDING, no @MasterDataRegion.
    implementnoneInterfaces the record implements — for typed key helpers such as EventTemplateKeys.
    domain""Puts the region in a dac domain: root dac:<domain>, flag dac.domains.<domain>.enabled.
    scopenone@SnapshotScope(prefix | prefixProperty, property) — what each row belongs to, for AbstractDacCache: prefix = "PF:", property = "portfolioNumber" yields the scope PF:<number>.
    perPrincipalfalseThe key is the caller's own id; AbstractDacCache serves a key only to the principal it equals.

    @SnapshotIgnore on a field or getter leaves that property out of the record. A @ManyToOne or @OneToOne relation that is neither embedded nor ignored is flattened to its id (alerts → alertsId) automatically.

    Rules the processor enforces

    Writing a snapshot by hand

    When the value is not a JPA entity — a DTO from an external system, a wrapper around a list — write the record yourself and build the RegionBinding in the facade. Keep to the same rules: a record, plain fields, nothing the readers do not need.

    public record UserDevicesSnapshot(String userId, List<UserDevice> devices) { }
    static final RegionBinding<String, UserDevicesSnapshot> BINDING = RegionBinding
            .transactional("userDevices", String.class, UserDevicesSnapshot.class)
            .ttl(Duration.ofMinutes(5))
            .localFallback(10_000)
            .build();

    Changing a snapshot safely

    Pods are redeployed one at a time, so for a while old and new code read the same Redis values, and other applications may share a region. The snapshot is therefore a contract:

    Every region also writes a contract fingerprint ({root}:{region}:contract) of its shape — key type, value type, kind and indexes — at startup. An application that declares the same region name with a different shape refuses to boot with a contract mismatch error rather than silently corrupting a shared region. TTLs and policies are tunable and deliberately outside the contract.