# Cache invalidation on metadata change

When a PM saves a change in BACK — adds a column to a form, updates a
permission, registers a new module — the framework drops the cached
interpretation of the old metadata. **The cache-clear is synchronous
in the BACK process via Spring's `@CacheEvict`**, NOT a JMS fan-out.
A separate JMS path with similarly-named classes exists for a
different purpose (base-data merge); the two are easy to confuse and
this page calls them out explicitly.

## Two paths, one trigger — what's actually different

```mermaid
flowchart TB
    classDef ok fill:#e6f4ea,stroke:#34a853
    classDef notcache fill:#fce8e6,stroke:#ea4335

    PM[PM clicks 保存 in BACK]:::ok
    SAVE["BusinessBaseServiceImpl<br/>add/update/deleteBusinessData"]
    EVICT["BusinessCleanRedisData.delCleanRedisData<br/>→ CleanRedisServiceImpl<br/>18 cache regions for gdsmodule"]:::ok
    REDIS[("Redis<br/>(shared across nodes)")]:::ok
    DB[("MySQL<br/>row written")]:::ok

    PM --> SAVE
    SAVE --> DB
    SAVE -- "synchronous,<br/>same transaction" --> EVICT
    EVICT --> REDIS
    REDIS -. "next read on<br/>any node sees fresh" .-> ANY[Other nodes]:::ok

    SAVE -. "publishes 'gds module changed'" .-> AMQ([ActiveMQ])
    AMQ --> CGM["ConsumerChangeGdsModuleThread<br/>(xlyErpJmsConsumer)"]:::notcache
    CGM -- "calls<br/>PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL<br/>base-data merge<br/>NOT cache")]:::notcache

    classDef title font-weight:bold
```

The **green path** is what every metadata-change-then-page-reload flow
actually rides. The **red path** is what readers expect to be cache
invalidation because of the queue's name (`CHANGE_GDS_MODULE`) and
consumer-thread class — but it isn't. It does a per-tenant→base-data
merge via stored procedure. **Neither path depends on the other.**

## The actual cache-invalidation path (synchronous, in-process)

```
PM saves in BACK
    │
    ▼
BACK controller (e.g. /business/addUpdateDelBusinessData) calls
BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData
    │
    ▼
Save service calls businessCleanRedisData.delCleanRedisData(...)
    (e.g., BusinessBaseServiceImpl.java:1122, 1224, 1375, 1441, 1597, 1677)
    │
    ▼
BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<sTable>, ...)
    dispatches to one of the named cleaners on CleanRedisServiceImpl
    │
    ▼
CleanRedisServiceImpl.cleanRedisByTableNameGdsModle()  (or similar)
    fires @CacheEvict against a fixed list of named cache regions
    │
    ▼
Spring CacheManager evicts the named entries
    │
    ▼
Next /business/getModelBysId call re-reads from DB and re-populates
    the cache.
```

The cleaner methods are in
`xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java`.
A representative one — invoked when `gdsmodule` rows change — evicts
18 cache regions in a single call:

```
@CacheEvict(value = {
    "getGdsmoduleTree", "getGdsmoduleList", "getModuleTreePro",
    "getSysjurisdictionTreePro", "getsDisplayTypeAll",
    "businessBaseServiceGetMenuList", "getBuMenu", "getMenu",
    "getsAuthsId", "businessCommonServicegetModulelistAll",
    "gdsmoduleById", "getSaveProName", "businessParameterGetParameter",
    "getPrcName", "getKpiModelByUser", "getUserByFromId",
    "getUserByActionId", "getModuleTreeProAll"
}, allEntries = true)
public void cleanRedisByTableNameGdsModle() { … }
```

Other table-named cleaners on the same class evict the regions
relevant to `gdsconfigformmaster`, `gdsconfigformslave`,
`gdsconfigtbmaster`, `gdsformconst`, `gdsjurisdiction`,
`gdsconfigcharmaster`, login-info, billnosetting, kpimaster,
`SysSystemSettings`, etc.

## What the JMS `CHANGE_GDS_MODULE` queue actually does (NOT cache-bust)

The framework has a JMS queue
`P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` and a consumer thread
`ConsumerChangeGdsModuleThread`, both of which sound like they should
be doing cache invalidation — but they don't.
`ConsumerChangeGdsModuleThread.run()` resolves a
`changeGdsModuleService` bean (`ChangeGdsModuleServiceImpl`) and calls
`changeTableData(sGdsModuleId, sJobId)`, which invokes the stored
procedure `PRO_ERPMERGEBASEGDSMODULE` (via `proDao.proErpMergeBaseGdsModule`,
mapped in `ProMapper.xml`). That proc consolidates per-tenant
`gdsmodule` rows into a flattened "base" lookup table — a base-data
merge job, not a cache evict. A `grep` of `xlyErpJmsConsumer/` for
`@CacheEvict` or `cleanRedis*` returns zero hits — the consumer side
clears nothing in Redis.

The same goes for the other 23 `ERP_JMS_ACTIVEMQ_*` queues in
[`P2pQueue.java`](../../api-reference/messaging.md): each one drives a
domain-specific base-data merge or fan-out work item, not cache
invalidation.

## Cross-node cache coherence — Redis-backed, confirmed

`@EnableCaching` is on `EntryApplicationBoot.java:22` and
`ApiApplicationBoot.java:24`. **No custom `CacheManager` bean is
declared anywhere in the in-scope source** (no `RedisCacheManager`,
no `@Bean CacheManager`-returning method, no `implements CacheManager`,
no `spring.cache.*` property in any `application*.yml`). With
`spring-boot-starter-data-redis` present and no other cache provider
on the classpath (no Caffeine, EhCache, Hazelcast, JCache; the
`shiro-ehcache` jar in `xlyFlow` is for Shiro's own session cache,
not Spring Cache), Spring Boot 2.2.5 auto-configures
**`RedisCacheManager`**.

**Empirically verified** against the live dev Redis at
`118.178.19.35:16379` (database 0): 233 of 267 keys use Spring Cache's
default `<cacheName>::<key>` separator. Sample key shape matching the
`@Cacheable` annotation on `getFormconstData` at
`BusinessGdsconfigformsServiceImpl.java:189-190` (default key derived
from all params):

```
businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111}
gdsmoduleById::gdsmoduleById_<sBrandsId>_<sSubsidiaryId>_<sLanguage>
```

So `@CacheEvict` on any node clears the shared Redis store and the next
read on every node sees the eviction. Cross-node coherence works
through Redis, not through JMS.

## When you change metadata directly via SQL

Inserts/updates done through MyBatis or BACK trigger
`businessCleanRedisData.delCleanRedisData*`. Raw `UPDATE gdsmodule SET …`
against the DB does **not** trigger any cleaner. The cache will serve
stale metadata until either:

1. The cache TTL expires (check the cache config for the actual TTL).
2. A bounce of the application servers (one bounce suffices since the
   cache is Redis-backed and shared — see above).
3. A manual call to one of the
   `BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<table>, …)`
   methods is invoked from inside the application — once, on any
   node, since it clears the shared Redis store.

## Drawbacks of this design

The synchronous `@CacheEvict`-during-save model is operationally
simple and (with Redis backing) genuinely cross-node coherent. It is
also fragile in ways worth naming:

- **Two systems with confusingly similar names.** The JMS path
  `CHANGE_GDS_MODULE` + `ConsumerChangeGdsModuleThread` *sounds*
  like it should be cache invalidation but isn't. This page exists
  partly because that conflation is a recurring source of bugs and
  reader confusion. A renaming pass (proc and queue → e.g.
  `MERGE_BASE_GDS_MODULE`) would help, but isn't free.
- **Eviction is in the same transaction as the write.** If the
  Redis call fails mid-save, the row commits but the cache stays
  stale. The framework does not detect or recover from this; a
  Redis outage during save silently corrupts the cache for
  affected rows until TTL expiry.
- **Eviction is "all or nothing per cache region".** Most
  `@CacheEvict` annotations on `CleanRedisServiceImpl` use
  `allEntries=true`, which dumps the entire cache region rather
  than the affected key. Heavy save throughput causes high
  cache-miss rates immediately afterwards — fine for small
  metadata caches, expensive when dropping a region with thousands
  of entries.
- **No invalidation budget / batching.** Bulk metadata changes
  (e.g., editing 100 form fields) trigger 100 `@CacheEvict` fires,
  each one round-tripping to Redis. There is no mechanism to
  coalesce evictions into one batch.
- **Direct DB writes bypass everything.** Any tooling that touches
  the schema outside `BusinessBaseServiceImpl` — including database
  admin scripts, `script/客户/` overrides applied via `mysql`
  command line, and Channel-2 SQL replacements — leaves the cache
  stale until manually invalidated. This is a real operational
  hazard for the deployment pattern xly actually uses.

## Common bug: the cache is the bug

When something looks like "I changed it but the page still shows the
old value", check (in this order):

1. Did the change actually commit? (Confirm with `SELECT` against the DB.)
2. Did the change go through a path that invokes
   `BusinessCleanRedisData`? (Direct DB writes or controllers that
   bypass `BusinessBaseServiceImpl` won't.)
3. Was Redis reachable when the save committed? A failed eviction
   does not roll back the save.
4. Is the change in a cache region that's evicted by the table that
   was written? `CleanRedisServiceImpl` maps writes to specific
   regions; an unmapped table will not invalidate its readers.

The five-key composite returned by
[`getModelBysId` in Slice 1](../../slices/01-hello-world.md)
re-runs from the cache; understanding which layer is stale is the key
to the bug.
