Static data
Use a static region for a small reference table that can be loaded as one complete snapshot — countries, status codes, event configurations, booking-centre mappings. The whole table is preloaded and versioned; readers switch to a new version only after a later reload succeeds.
Concept
A static region has no TTL. Its life is a sequence of versions: the leader pod loads the
whole table into {root}:{region}:v7:…, then flips {root}:{region}:current
to v7. Every pod attaches to the current version, fills its L1 from it and serves reads
from L1 from then on. A later reload writes v8 next to v7 and flips the
pointer; nobody ever reads a half-written table.
A pod that has no version yet — a brand-new deployment before the leader has loaded, or a pod that cannot reach Redis — has a region that is not loaded. The facade treats such a region as absent and runs its source fallback; it never answers from an empty version.
Declaring the region
From a JPA entity
One annotation on the entity; the master-data starter finds the entity's Spring Data repository,
loads all rows, maps them with the generated from(…) and builds the indexes. No region
bean, no loader:
@Entity
@CacheSnapshot(region = "bookingCentreMapping", implement = BookingCentreMappingKeys.class,
indexes = {
@SnapshotIndex(name = "byEventBookingCentreKey", property = "eventBookingCentreKey"),
@SnapshotIndex(name = "byConfigAndCentre", property = "configAndCentreKey")})
public class BookingCentreMapping { … }
From any other source
When the rows do not come from a repository, override loader() with a
CacheLoader whose loadAll() returns the whole table. The live initializer's
product catalog is such a region:
@Component
public class ProductCache extends AbstractCache<Long, ProductSnapshot> {
static final RegionBinding<Long, ProductSnapshot> BINDING = RegionBinding
.staticRegion("products", Long.class, ProductSnapshot.class)
.index("byCode", ProductSnapshot::code)
.multiValuedIndex("byCategoryId", ProductSnapshot::categoryId)
.build();
private final ProductDirectory directory;
public ProductCache(CacheSupport support, ProductDirectory directory) {
super(BINDING, support);
this.directory = directory;
}
@Override
protected Optional<CacheLoader<Long, ProductSnapshot>> loader() {
return Optional.of(new ProductLoader(directory)); // loadAll() returns the table
}
@Override
protected Optional<ProductSnapshot> loadFromSource(Long id) {
return directory.byId(id);
}
@Override
protected List<ProductSnapshot> loadFromSourceByIndex(String index, Object value) {
return switch (index) {
case "byCode" -> directory.byCode((String) value).stream().toList();
case "byCategoryId" -> directory.all().values().stream()
.filter(p -> value.equals(p.categoryId()))
.toList();
default -> List.of();
};
}
public Optional<ProductSnapshot> byCode(String code) { return byIndex("byCode", code); }
public List<ProductSnapshot> byCategory(Long categoryId) { return listByIndex("byCategoryId", categoryId); }
}
Reading with a fallback
Every read method on a static facade should have a source branch. The facade already runs
loadFromSource/loadFromSourceByIndex when the region is absent, so with a
JPA entity you can either override those hooks or — the shorter idiom used across indc-core — chain
the repository with .or(…) as in EventConfigurationCache:
public List<EventSegmentsMappingSnapshot> byEventConfigId(Long eventConfigurationId) {
return listByIndex("byEventConfigId", eventConfigurationId);
}
@Override
protected List<EventSegmentsMappingSnapshot> loadFromSourceByIndex(String index, Object value) {
if (!"byEventConfigId".equals(index)) {
return List.of();
}
return repository.findByEventConfigurationId((Long) value).stream()
.map(EventSegmentsMappingSnapshot::from)
.toList();
}
public List<EventTemplateSnapshot> byCriteria(String eventType, String bookingCentre, LanguageCode language,
EventChannel alertChannel, TemplateType templateType, Segment segment) {
return listByIndex("byCriteria",
EventTemplateKeys.criteriaKey(eventType, bookingCentre, language, alertChannel, templateType, segment))
.stream()
.sorted(Comparator.comparing(EventTemplateSnapshot::id))
.toList();
}
A region that returns no entries for an index value while loaded is a real "not found" — the table was read in full, and the value is not there. The fallback runs only when the region is absent, never on an empty index result, so a static facade does not hit the database for legitimately missing keys.
What happens at startup
- The engine starts and registers every region. Each static region checks its contract key and reads
current. - If
currentexists, every pod attaches to that version and is ready immediately — a restart does not reload the world. - If there is no version, or
startup: reload-if-staleis set and the version is older thanmax-snapshot-age, the leader loads the table and publishes a new version. Non-leader pods wait for the pointer and attach; until then their facades serve from source. - Once attached, a pod fills L1 lazily (or eagerly, per region config) from L2.
dac:
cache:
refresh-cron: "0 */15 * * * *" # leader reloads every static region on this schedule
regions:
eventConfig:
startup: reload-if-stale # ATTACH (default) | RELOAD_IF_STALE
max-snapshot-age: PT30M
l1-max-size: 5000
Without refresh-cron or startup: reload-if-stale, a static region is reloaded only by an operator call or a refresh event. Database changes made while nobody triggers a reload stay invisible. Set at least one of them in every environment — the indc-core chart wires both as Octopus variables (Configuration).
Keeping it fresh
| mechanism | use it when | what happens |
|---|---|---|
Scheduled reload — dac.cache.refresh-cron | always, as the repair path for missed events | the leader reloads every versioned region and flips the pointer |
Reload at startup — startup: reload-if-stale + max-snapshot-age | deployments that may follow a long outage | the leader reloads if the current version is older than the age |
Operator endpoint — POST /api/admin/master-data-cache/reload[/{region}], GET …/versions | after a manual data fix | reload all or one region now; versions lists what is live |
| Refresh event — a message on the refresh stream with region and key | another service or batch job owns the write | one pod reloads that key into the current version; every other pod drops its L1 copy |
facade.refresh() | the owning application changed the table itself | same as the operator endpoint, from code |
A static region is never patched with put(); the call is ignored with a warning.
Replace the version instead.
Tuning
| property (under dac.cache.regions.<region>) | meaning |
|---|---|
| l1-max-size | Entries the in-pod L1 holds. For a small table, at least the table size so every read is a memory hit. |
| l1-ttl | Optional expiry of L1 entries; normally unset for static data (L1 is replaced on version flip). |
| startup | attach or reload-if-stale. |
| max-snapshot-age | Threshold for reload-if-stale. |
The seven master-data regions indc-core runs — eventConfig, eventTemplate,
eventGroup, alerts, alertChannelMapping,
bookingCentreMapping, eventSegmentsMapping — are listed with their facades in
the regions reference.
Complete example
The full static walk-through — properties, entity, repository, generated snapshot, facade, service, controller — is on the complete code examples page, and runs live as the product catalog in the live initializer.