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.

On this page

    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();
    }
    Note

    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

    1. The engine starts and registers every region. Each static region checks its contract key and reads current.
    2. If current exists, every pod attaches to that version and is ready immediately — a restart does not reload the world.
    3. If there is no version, or startup: reload-if-stale is set and the version is older than max-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.
    4. 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
    Important

    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

    mechanismuse it whenwhat happens
    Scheduled reload — dac.cache.refresh-cronalways, as the repair path for missed eventsthe leader reloads every versioned region and flips the pointer
    Reload at startup — startup: reload-if-stale + max-snapshot-agedeployments that may follow a long outagethe leader reloads if the current version is older than the age
    Operator endpoint — POST /api/admin/master-data-cache/reload[/{region}], GET …/versionsafter a manual data fixreload all or one region now; versions lists what is live
    Refresh event — a message on the refresh stream with region and keyanother service or batch job owns the writeone pod reloads that key into the current version; every other pod drops its L1 copy
    facade.refresh()the owning application changed the table itselfsame 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-sizeEntries the in-pod L1 holds. For a small table, at least the table size so every read is a memory hit.
    l1-ttlOptional expiry of L1 entries; normally unset for static data (L1 is replaced on version flip).
    startupattach or reload-if-stale.
    max-snapshot-ageThreshold 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.