Skip to content

FINERACT-2968: Transaction-bound cache clearing and load global configs once per transaction - #6493

Open
alberto-art3ch wants to merge 1 commit into
apache:developfrom
openMF:FINERACT-2684/config-cache-round-trips
Open

alberto-art3ch wants to merge 1 commit into
apache:developfrom
openMF:FINERACT-2684/config-cache-round-trips

Conversation

@alberto-art3ch

@alberto-art3ch alberto-art3ch commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

Global configuration lookups (c_configuration by name) run on almost every request: ~17 single-row queries per
write request under load, the most executed statement of the platform. The configByName cache never hits: the
default TransactionBoundCacheManager clears a shared Ehcache instance on every transaction of every thread, and it keeps doing so even after switching to SINGLE_NODE via PUT /caches.

Changes, keeping the guarantee that every transaction starts from the database:

  • Transaction-bound cache entries are stored per thread (ThreadLocalCacheStore) and cleared only by that thread's transaction boundaries and by ThreadLocalContextUtil.reset() (request/job end); no cross-thread clearing or locking. TransactionBoundCacheManager moves to fineract-core next to RuntimeDelegatingCacheManager.
  • RuntimeDelegatingCacheManager forwards transaction hooks only while the transaction-bound manager is active, so the SINGLE_NODE cache is no longer wiped.
  • GlobalConfigurationRepositoryWrapper loads the whole table (76 rows) once per cache lifetime with a read-only
    query, keyed <tenant>::all, and evicts it on writes. Read callers get a fresh GlobalConfigurationPropertyData
    per call, so the cached entities can never be modified; the single mutating caller uses a dedicated for-update
    lookup. removeFromCache() no longer takes a property name (the whole snapshot is evicted).
  • CacheConfig registers the transaction-bound caches in Ehcache explicitly. Ehcache caches were created only for
    names found by the @Cacheable scan, so without this SINGLE_NODE would have no configByName cache once the annotation is replaced by programmatic access.

Note: ConfigurationDomainServiceJpa has had no @Cacheable since FINERACT-1724 (e9e31a8); it delegates to the wrapper, so there is a single configuration cache.

Measured on the first iteration of this PR (k6, PostgreSQL 18, 36k loans): configuration statements per write request
16.7 -> 5.8 (one 76-row round trip per transaction instead of one per property), write p95 27 -> 28 ms, read mix p95
86-105 -> 90 ms. No client-visible behaviour change.

Tests:

  • Unit: hook forwarding per active mode, per-thread isolation (thread B's afterBegin does not clear thread A),
    reset() drops the thread's store, once-per-transaction loading, eviction, returned data cannot alter the cache,
    Ehcache registration of the transaction-bound caches (fails without the CacheConfig change).
  • Integration: GlobalConfigurationTest switches to SINGLE_NODE via PUT /caches, reads, updates and checks the
    update is visible (no stale read), then restores NO_CACHE.

Out of scope (separate PRs): the datatable DDL error mapping and the jersey-micrometer dependency that were in the first iteration.

FINERACT-2684

Checklist

Please make sure these boxes are checked before submitting your pull request - thanks!

  • Write the commit message as per our guidelines
  • Acknowledge that we will not review PRs that are not passing the build ("green") - it is your responsibility to get a proposed PR to pass the build, not primarily the project's maintainers.
  • Create/update unit or integration tests for verifying the changes made.
  • Follow our coding conventions.
  • Add required Swagger annotation and update API documentation at fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm with details of any API changes
  • This PR must not be a "code dump". Large changes can be made in a branch, with assistance. Ask for help on the developer mailing list.
  • If merging this PR resolves a JIRA issue, I will mark that issue as resolved and set "Fix Version/s" appropriately.
  • I followed the AI Policy.

Your assigned reviewer(s) will follow our guidelines for code reviews.

@alberto-art3ch
alberto-art3ch marked this pull request as ready for review September 23, 2026 03:07
@alberto-art3ch
alberto-art3ch force-pushed the FINERACT-2684/config-cache-round-trips branch from 609f50d to dfcb6ae Compare September 23, 2026 04:32

@galovics galovics left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @alberto-art3ch, thanks for the PR. The per-thread store and forwarding the hooks only to the active manager are a nice fix. I'd like these addressed first:

  • DatatableWriteServiceImpl (updateDatatable catch): why is a swallowed DDL failure now a mapped error with a new code? That is a behaviour change with no test, and the description says "No client-visible behaviour change". Separate PR please.
  • jersey-micrometer in dependencies.gradle: unrelated to config caching and a new dependency deserves its own review. Same, please split out.
  • removeFromCache now evicts only <tenant>::all. ConfigurationDomainServiceJpa.getGlobalConfigurationPropertyData is still @Cacheable("configByName") keyed tenant + name, and the old @CacheEvict here used to clear it. Where is it evicted now? In SINGLE_NODE it looks stale until the TTL. How do the two caches interact, and is the new one redundant for the hottest caller? The propertyName argument is now only a log line, drop it.
  • The read path returns eclipselink.read-only entities shared across threads for the whole TTL, and nothing stops a later caller from calling a setter. Return toData(), or add a test that fails if someone does.
  • ThreadLocalContextUtil in core now depends on the cache package. Is reset() guaranteed at the end of every request and job thread, otherwise the map leaks?
  • Tests run on one thread while the claim is per-thread isolation: pls add a test that thread B's afterBegin does not clear thread A, one that reset() drops the store, and one integration test that switches to SINGLE_NODE via PUT /caches and stays cached.
  • Smaller: TransactionScopedCacheManager has a single implementation and RuntimeDelegatingCacheManager does an instanceof anyway, CONFIG_BY_NAME_CACHE_NAME is an alias of a constant in the wrapper, mock GlobalConfigurationProperty is avoidable.

@alberto-art3ch
alberto-art3ch force-pushed the FINERACT-2684/config-cache-round-trips branch from dfcb6ae to 61f9034 Compare October 19, 2026 22:06
@alberto-art3ch

Copy link
Copy Markdown
Contributor Author

Hi @alberto-art3ch, thanks for the PR. The per-thread store and forwarding the hooks only to the active manager are a nice fix. I'd like these addressed first:

  • DatatableWriteServiceImpl (updateDatatable catch): why is a swallowed DDL failure now a mapped error with a new code? That is a behaviour change with no test, and the description says "No client-visible behaviour change". Separate PR please.
  • jersey-micrometer in dependencies.gradle: unrelated to config caching and a new dependency deserves its own review. Same, please split out.
  • removeFromCache now evicts only <tenant>::all. ConfigurationDomainServiceJpa.getGlobalConfigurationPropertyData is still @Cacheable("configByName") keyed tenant + name, and the old @CacheEvict here used to clear it. Where is it evicted now? In SINGLE_NODE it looks stale until the TTL. How do the two caches interact, and is the new one redundant for the hottest caller? The propertyName argument is now only a log line, drop it.
  • The read path returns eclipselink.read-only entities shared across threads for the whole TTL, and nothing stops a later caller from calling a setter. Return toData(), or add a test that fails if someone does.
  • ThreadLocalContextUtil in core now depends on the cache package. Is reset() guaranteed at the end of every request and job thread, otherwise the map leaks?
  • Tests run on one thread while the claim is per-thread isolation: pls add a test that thread B's afterBegin does not clear thread A, one that reset() drops the store, and one integration test that switches to SINGLE_NODE via PUT /caches and stays cached.
  • Smaller: TransactionScopedCacheManager has a single implementation and RuntimeDelegatingCacheManager does an instanceof anyway, CONFIG_BY_NAME_CACHE_NAME is an alias of a constant in the wrapper, mock GlobalConfigurationProperty is avoidable.

Thanks @galovics, all addressed:

  • Datatable catch and jersey-micrometer: removed from this PR; they'll come as separate PRs.
  • configByName eviction: ConfigurationDomainServiceJpa no longer has @Cacheable — it was removed in e9e31a8 (FINERACT-1724, 2023) and the method just delegates to the wrapper, so there is a single cache and the wrapper's eviction covers it. Looking into it, though, I found a real problem in the opposite direction: CacheConfig only creates Ehcache caches for names found by the @Cacheable scan, so once the annotation was gone, SINGLE_NODE had no configByName cache and every lookup hit the DB. The transaction-bound caches are now registered explicitly, with a unit test against the real Ehcache that fails without the fix. propertyName dropped.
  • Shared entities: the read path now returns a fresh GlobalConfigurationPropertyData per call; cached entities never leave the wrapper. Test added.
  • reset(): every HTTP request thread resets at start and in finally (TenantAwareBasicAuthenticationFilter, OIDC/auth filters), and jobs/COB/batch workers reset too (21 call sites). Retention is bounded anyway: at most two small caches per thread, emptied on every transaction begin/completion.
  • Tests: two-thread isolation, reset() drops the store, and an IT that switches to SINGLE_NODE via PUT /caches, reads, updates, verifies no stale read, and restores NO_CACHE.
  • Smaller: TransactionBoundCacheManager moved to core and the interface removed (it existed only because of the module boundary); alias removed; real entities in the test.

@alberto-art3ch
alberto-art3ch force-pushed the FINERACT-2684/config-cache-round-trips branch from 61f9034 to 2d1913d Compare October 19, 2026 22:14
@alberto-art3ch alberto-art3ch changed the title FINERACT-2684: Transaction-bound cache clearing and load global configs once per transaction FINERACT-2968: Transaction-bound cache clearing and load global configs once per transaction Oct 19, 2026
@alberto-art3ch
alberto-art3ch force-pushed the FINERACT-2684/config-cache-round-trips branch from 2d1913d to 2ee1139 Compare October 20, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants