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.
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:
- a record with one component per persistent property (plus paths, embeds and collections — below), in entity order;
public static final String REGION— the region name, for configuration and refresh events;public static final RegionBinding<K, V> BINDING— the region description a facade passes tosuper(…), carrying kind, TTL, indexes, domain and scope exactly as annotated;public static Snapshot from(Entity entity)— null-safe mapping, including the null-safe walks for everypath;- for static regions,
@MasterDataRegionon the record so the master-data starter preloads it from the entity's repository.
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(),
…);
}
}
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
| element | default | meaning |
|---|---|---|
| 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). |
| type | STATIC | RegionKind.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. |
| indexes | none | @SnapshotIndex(name, property, multiValued). A single-valued index maps one value to one entry; multiValued = true maps one value to many (answers listByIndex). |
| paths | none | @SnapshotPath(name, path) — flatten a value from a related entity with a null-safe getter walk ("alerts.eventConfiguration.id"). |
| embeds | none | @SnapshotEmbed(name, property) — include a related entity as its own (embedded) snapshot record. |
| collections | none | @SnapshotCollection(name, property) — include a @OneToMany as List<ChildSnapshot>; a null collection becomes an empty list. |
| embedded | false | The record is a building block for other snapshots, not a region of its own: no REGION, no BINDING, no @MasterDataRegion. |
| implement | none | Interfaces 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. |
| scope | none | @SnapshotScope(prefix | prefixProperty, property) — what each row belongs to, for AbstractDacCache: prefix = "PF:", property = "portfolioNumber" yields the scope PF:<number>. |
| perPrincipal | false | The 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
- Every region-owning
@CacheSnapshotstatesregion = "…"explicitly; onlyembedded = truesnapshots omit it. ttlmust parse as an ISO-8601 duration; the build fails with the offending value otherwise.- An embed or collection target must itself carry
@CacheSnapshot(normallyembedded = true). - A property named in an embed is emitted once, as the embed — not also as a flattened id column.
- Fields of a
@MappedSuperclass(audit columns) are left out — only the entity's own fields are read. Validation annotations are not copied. - A
@OneToMany/@ManyToManythat is not named incollectionsis left out; collections are the main cause of oversized entries, so they are opt-in. - Path walks are generated null-safe at every step; a missing link yields
null, never an exception. - The generated record never references the entity type in its components, so it serialises with the engine's plain JSON mapper.
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:
- Adding a field is safe. Old readers ignore it; new readers see
nullfor values written by old code until the entry is reloaded or expires. For static regions the next reload writes a complete new version. - Renaming, removing or retyping a field is breaking. Give the region a new name (a new set of keys) or reload it in the same deployment as the code change — and never do it on a region another application reads.
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.