Skip to main content

User Defined Cacheable Filters

Cache-friendly Filters

Basic Concepts

In the User defined Filters documentation, we introduced that custom global filters need to implement the Filter/KFilter interface.

However, ordinary filters defined using this interface are not cache-friendly.

Taking the Book entity as an example, if a cache-unfriendly global filter is set for it, it will cause all the following filter-sensitive properties

  • Association properties targeting Book, such as BookStore.books, Author.books

  • Calculated properties relying on the above association properties, such as BookStore.avgPrice, BookStore.newestBooks

to become uncacheable.

Jimmer uses the CacheableFilter/KCacheableFilter interface to define cache-friendly filters:

CacheableFilter.java
package org.babyfish.jimmer.sql.filter;

import org.babyfish.jimmer.sql.ast.table.Props;
import org.babyfish.jimmer.sql.event.EntityEvent;

import java.util.SortedMap;

public interface CacheableFilter<P extends Props> extends Filter<P> {

SortedMap<String, Object> getParameters();

boolean isAffectedBy(EntityEvent<?> e);
}

This interface inherits from Filter/KFilter and adds two new methods:

  • getParameters: The sub key fragment contributed by this filter for multi-view cache.

  • isAffectedBy: Accepts an event that the filtered entity is modified, and judges whether the filtering fields depended on by the current filter are changed.

info

An entity type allows being processed by multiple global filters:

  • If any one of them is cache-unfriendly, it will cause all filter-sensitive properties to become uncacheable.

    Therefore, these global filters must either all be cache-unfriendly Filter/KFilter, or all be cache-friendly CacheableFilter/KCacheableFilter. Mixing them together makes no sense.

    If such meaningless mixing occurs accidentally, Jimmer will tell why cache is abandoned.

  • When all global filters are cache-friendly, the data returned by the getParameters() method of all CacheableFilter/KCacheableFilter objects is merged together as the SubKey of the multi-view cache.

    For example, if an entity is processed by two global filters at the same time. One is the filter implied by logical delete, denoted as a; the other one is a user-defined filter, denoted as b.

    Assume

    • a's getParameters() returns {"logicalDeleted":false}

    • b's getParameters() returns {"tenant":"a"}

    Then the final SubKey in multi-view cache will be

    {"logicalDeleted":false,"tenant":"a"}

Define Cache-friendly Filters

In the User-defined Filters documentation, we defined a super type TenantAware for entities. Let's review its code again:

TenantAware.java
@MappedSuperclass
public interface TenantAware {

String tenant();
}

Any entity type that needs to support multiple tenants can inherit TenantAware, such as Book:

Book.java
@Entity
public interface Book extends TenantAware {

...code omitted...
}

Assume there is an object of type TenantProvider in the Spring context. Its Java method get() and Kotlin property tenant are used to extract the tenant from the identity information of the current operator. Define the following filter:

@Component
public class TenantFilter implements CacheableFilter<TenantAwareProps> {

private final TenantProvider tenantProvider;

public TenantFilter(TenantProvider tenantProvider) {
this.tenantProvider = tenantProvider;
}

@Override
public void filter(FilterArgs<TenantAwareProps> args) {
String tenant = tenantProvider.get();
if (tenant != null) {
args.where(args.getTable().tenant().eq(tenant));
}
}

@Override
public SortedMap<String, Object> getParameters() {
String tenant = tenantProvider.get();
if (tenant == null) {
return null;
}
SortedMap<String, Object> map = new TreeMap<>();
map.put("tenant", tenant);
return map;
}

@Override
public boolean isAffectedBy(EntityEvent<?> e) {
return e.isChanged(TenantAwareProps.TENANT)
}
}

Enable Multi-view Cache

Simple Approach

@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
return new CacheFactory() {

@Override
public Cache<?, ?> createObjectCache(@NotNull ImmutableType type) {
...code omitted...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
...code omitted...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.BOOKS.unwrap() ||
prop == AuthorProps.BOOKS.unwrap()
prop,
Duration.ofMinutes(5),
Duration.ofHours(5)
);
}

@Override
public Cache<?, ?> createResolverCache(ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.AVG_PRICE.unwrap() ||
prop == BookStoreProps.NEWEST_BOOKS.unwrap()
prop,
Duration.ofMinutes(1),
Duration.ofHours(1)
);
}

private <K, V> Cache<K, V> createPropCache(
boolean isMultiviewCache,
ImmutableProp prop,
Duration caffeineDuration,
Duration redisDuration
) {
if (isMultiView) {
return new ChainCacheBuilder<K, V>()
.add(
CaffeineHashBinder
.forProp(prop)
.maximumSize(128)
.duration(caffeineDuration)
.build()
)
.add(
RedisHashBinder
.forProp(prop)
.redis(connectionFactory)
.objectMapper(objectMapper)
.duration(redisDuration)
.build()
)
.build();
}

return new ChainCacheBuilder<>()
.add(
CaffeineValueBinder
.forObject(type)
.maximumSize(512)
.duration(caffeineDuration)
.build()
)
.add(
RedisValueBinder
.forProp(prop)
.redis(connectionFactory)
.objectMapper(objectMapper)
.duration(redisDuration)
.build()
)
.build();
}
};
}

The RedisHashBinder class in the above code is a very important implementation that utilizes Redis' support for multi-view cache. The underlying storage structure corresponds to Redis Hashes, i.e. nested Hash structures.


Cache Style Is Multi-view Abstract API Built-in Impl

Cache with self-loading

(usually first-level cache technologies like Guava, Caffeine)

Single-view

LoadingBinder<K, V>

CaffeineValueBinder<K, V>

Multi-view

LoadingBinder<K, V>.Parameterized<K, V>

None

Cache without self-loading

(usually second-level cache technologies like Redis)

Single-view

SimpleBinder<K, V>

RedisValueBinder<K, V>

Multi-view

SimpleBinder.Parameterized<K, V>

RedisHashBinder<K, V>

RedisHashBinder<K, V>

Better Approach

In the above code, the createAssociatedIdListCache method judges the prop parameter to decide whether to build multi-view cache or single-view cache. However,

tip

For association properties, whether multi-view cache needs to be built can be determined solely by whether the target entity is filtered. Jimmer provides better support for this.

Developers only need to replace the super interface CacheFactory/KCacheFactory with the super class AbstractCacheFactory/AbstractKCacheFactory to inherit a member called getFilterState/filterState which can help us determine whether to build multi-view cache.

@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
return new AbstractCacheFactory() {

@Override
public Cache<?, ?> createObjectCache(@NotNull ImmutableType type) {
...code omitted...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
getFilterState().isAffectedBy(prop.getTargetType()),
prop,
Duration.ofMinutes(5),
Duration.ofHours(5)
);
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
getFilterState().isAffectedBy(prop.getTargetType()),
prop,
Duration.ofMinutes(5),
Duration.ofHours(5)
);
}

@Override
public Cache<?, ?> createResolverCache(ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.AVG_PRICE.unwrap() ||
prop == BookStoreProps.NEWEST_BOOKS.unwrap()
prop,
Duration.ofSeconds(1),
Duration.ofHours(24)
);
}

private <K, V> Cache<K, V> createPropCache(
boolean isMultiviewCache,
ImmutableProp prop,
Duration duration
) {
...code omitted...
}
};
}
info

Unfortunately, this method can only simplify the construction of association caches, i.e. simplify the createAssociatedIdCache and createAssociatedIdListCache methods.

For calculated properties, since the framework is unaware of the internal logic used by user-defined calculated properties, it cannot simplify them. Users need to decide whether to build multi-view caches based on their own business characteristics.

SubKey of Calculated Properties

We have defined the getParameters method in TenantFilter. All affected association properties will automatically specify SubKey for their association caches.

However, unfortunately, due to the introduction of user-defined calculation rules that the framework cannot understand, developers must manually specify SubKey for the TransientResolver implementation of calculated properties.

BookStoreAvgPriceResolver.java
@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {

private final JSqlClient sqlClient;

@Override
public Ref<SortedMap<String, Object>> getParameterMapRef() {
return sqlClient
.getFilters()
.getTargetParameterMapRef(BookStoreProps.BOOKS);
}

...code omitted...
}

Obviously, the calculated property BookStore.avgPrice is actually determined by the association property BookStore.books and changes with it.

Therefore, whatever SubKey the association property BookStore.books specifies for the multi-view cache system in the current invocation context, the calculated property BookStore.avgPrice should specify the same one.

note

BookStore.avgPrice is also affected by Book.price. It changes when Book.price changes.

However, Book.price is a non-associative property of the object, so it must be irrelevant to the multi-view cache system. The getParameterMapRef method does not need to consider it here.

Usage

Now that we have made the association property BookStore.books and the calculated property BookStore.avgPrice support multi-view caching, let's use object fetchers to query them:

BookStoreTable table = Tables.BOOK_STORE_TABLE;
List<BookStore> stores = sqlClient
.createQuery(table)
.select(
table.fetch(
Fetchers.BOOK_STORE_FETCHER
.allScalarFields()
.books(
Fetchers.BOOK_FETCHER
.allScalarFields()
)
.avgPrice()
)
)
.execute();
System.out.println(stores);

Execute with one tenant identity

Assume the current tenant name is a, the execution process is as follows:

  • First step: Query aggregate root

    First query the aggregate root object by executing the following SQL:

    select 
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.WEBSITE
    from BOOK_STORE tb_1_

    Here the query in the code is implemented to get some BookStore objects. Such objects directly queried by the user are called aggregate root objects.

  • Second step: Query BookStore.books in ❶ through association cache

    The above code will get a series of aggregate root objects. If using the official example data, it will get two aggregate root objects with ID of 1 and 2 for BOOK_STORE.

    Jimmer first looks up the data from Redis. The keys looked up are BookStore.books-1 and BookStore.books-2.

    Assume the data for these keys cannot be found in Redis:

    127.0.0.1:6379> keys BookStore.books-*
    (empty array)

    So the following SQL is executed to complete the associated property BookStore.books:

    SQL: select,
    tb_1_.STORE_ID,
    tb_1_.ID
    from BOOK tb_1_
    where
    tb_1_.STORE_ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    // highlight-next-line
    tb_1_.TENANT = ? /* a */
    order by
    tb_1_.NAME asc,
    tb_1_.EDITION desc
    info

    The filter condition tb_1_.TENANT = 'a' comes from the user filter TenantFilter.

    Jimmer will put the query result into Redis. So we can view the data from Redis:

    127.0.0.1:6379> keys BookStore.books-*
    1) "BookStore.books-2"
    2) "BookStore.books-1"

    127.0.0.1:6379> hgetall BookStore.books-1
    1) "{\"tenant\":\"a\"}"
    2) "[5,3,1,9,7]"

    127.0.0.1:6379> hgetall BookStore.books-2
    1) "{\"tenant\":\"a\"}"
    2) "[11]"
    info

    Jimmer uses Redis Hash for multi-perspective cache. So hgetall instead of get is needed.

    Redis Hash is a nested KV structure:

    • The outer Redis Key, e.g. BookStore.books-1 and BookStore.books-2, is no different from single-perspective cache.

    • The inner Hash Key, also called SubKey in Jimmer, is provided by global filters.

      Here, {"tenant":"a"} is provided by TenantProvider, indicating the cached value is not the id set of all associated objects, but the id set of associated objects visible to tenant a.

    tip

    Undoubtedly, executing the above Java/Kotlin code again with the same tenant identity before data expiration in Redis, it will directly return associated data from Redis without generating related SQL.

  • Third step: Convert id set to associated objects

    In the previous step we got the id set of associated objects corresponding to associated property BookStore.books, representing the associated objects visible to tenant a.

    Now we can use the object cache of Book to convert the Book id set into Book object set.

    This step is very simple without further discussion.

  • Fourth step: Query BookStore.avgPrice in ❷ through computation cache

    The above code will get a series of aggregate root objects. If using the official example data, it will get two aggregate root objects with ID of 1 and 2 for BOOK_STORE.

    Jimmer first looks up the data from Redis. The keys looked up are BookStore.avgPrice-1 and BookStore.avgPrice-2.

    Assume the data for these keys cannot be found in Redis:

    127.0.0.1:6379> keys BookStore.avgPrice-*
    (empty array)

    So the following SQL is executed to compute the calculation property:

    select
    tb_1_.ID,
    avg(tb_2_.PRICE)
    from BOOK_STORE tb_1_
    left join BOOK tb_2_
    on tb_1_.ID = tb_2_.STORE_ID
    where
    tb_1_.ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    // highlight-next-line
    tb_1_.TENANT = ? /* a */
    group by
    tb_1_.ID
    info

    The filter condition tb_1_.TENANT = 'a' comes from the user filter TenantFilter.

    Jimmer will put the query result into Redis. So we can view the data from Redis:

    127.0.0.1:6379> keys BookStore.avgPrice-*
    1) "BookStore.avgPrice-2"
    2) "BookStore.avgPrice-1"

    127.0.0.1:6379> hgetall BookStore.avgPrice-1
    1) "{\"tenant\":\"a\"}"
    2) "53.1"

    127.0.0.1:6379> hgetall BookStore.avgPrice-2
    1) "{\"tenant\":\"a\"}"
    2) "81"
    info

    Jimmer uses Redis Hash for multi-perspective cache. So hgetall instead of get is needed.

    Redis Hash is a nested KV structure:

    • The outer Redis Key, e.g. BookStore.avgPrice-1 and BookStore.avgPrice-2, is no different from single-perspective cache.

    • The inner Hash Key, also called SubKey in Jimmer, is provided by global filters.

      Here, {"tenant": "a"} is provided by TenantProvider, indicating the cached value is not the average price of all associated objects, but the average price of associated objects visible to tenant a.

    tip

    Undoubtedly, executing the above Java/Kotlin code again with the same tenant identity before data expiration in Redis, it will directly return associated data from Redis without generating related SQL.

Finally, Jimmer concatenates the results of the 4 steps and returns them to the user.

[
{
"id":2,
"name":"MANNING",
"website":null,
"books":[
{
"id":11,
"name":"GraphQL in Action",
"edition":2,
"price":81
}
],
"avgPrice":81
},
{
"id":1,
"name":"O'REILLY",
"website":null,
"books":[
{
"id":5,
"name":"Effective TypeScript",
"edition":2,
"price":69
},
{
"id":3,
...omitted...
},
{
"id":1,
...omitted...
},
{
"id":9,
...omitted...
},
{
"id":7,
...omitted...
}
],
"avgPrice":53.1
}
]

Execute repeatedly with multiple tenant identities

The query execution process with tenant a has been discussed. Similarly, we can execute multiple times using different tenant identities to leave cache data in Redis from the following perspectives:

  • tenant = null
  • tenant = "a"
  • tenant = "b"
info

For the official example, TenantProvider is implemented based on HTTP request header and has

support. It's easy to execute three times with three different user identities.

Among them, tenant = null corresponds to the unauthorized/logout state in swagger UI.

Open redis-cli, we can verify the data in Redis:

127.0.0.1:6379> keys BookStore.books-*
1) "BookStore.books-2"
2) "BookStore.books-1"

127.0.0.1:6379> hgetall BookStore.books-1
1) "{\"tenant\":\"b\"}"
2) "[6,4,2,8]"
3) "{\"tenant\":\"a\"}"
4) "[5,3,1,9,7]"
5) "{}"
6) "[6,5,4,3,2,1,9,8,7]"

127.0.0.1:6379> hgetall BookStore.books-2
1) "{\"tenant\":\"b\"}"
2) "[12,10]"
3) "{\"tenant\":\"a\"}"
4) "[11]"
5) "{}"
6) "[12,11,10]"

127.0.0.1:6379> keys BookStore.avgPrice-*
1) "BookStore.avgPrice-2"
2) "BookStore.avgPrice-1"

127.0.0.1:6379> hgetall BookStore.avgPrice-1
1) "{\"tenant\":\"b\"}"
2) "65.25"
3) "{\"tenant\":\"a\"}"
4) "53.1"
5) "{}"
6) "58.500000"

127.0.0.1:6379> hgetall BookStore.avgPrice-2
1) "{\"tenant\":\"b\"}"
2) "80"
3) "{\"tenant\":\"a\"}"
4) "81"
5) "{}"
6) "80.333333"
tip

Readers can take a close look at these redis-cli commands and easily find that the data of sub key {"tenant":"a"} merged with the data of sub key {"tenant":"b"} is exactly the data of SubKey {}.

The data returned to the user in the 3 calls is:

[
{
"id":2,
"name":"MANNING",
"website":null,
"books":[
{
"id":12,
"name":"GraphQL in Action",
"edition":3,
"price":80,
},
{
"id":11,
...omitted...
},
{
"id":10,
...omitted...
}
],
"avgPrice":80.333333
},
{
"id":1,
"name":"O'REILLY",
"website":null,
"books":[
{
"id":6,
"name":"Effective TypeScript",
"edition":3,
"price":88
},
{
"id":5,
...omitted...
},
{
"id":4,
...omitted...
},
{
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51
},
{
"id":2,
...omitted...
},
{
"id":1,
...omitted...
},
{
"id":9,
"name":"Programming TypeScript",
"edition":3,
"price":48
},
{
"id":8,
...omitted...
},
{
"id":7,
...omitted...
}
],
"avgPrice":58.5
}
]

Cache Invalidation

Now let's modify the property Book.tenant of the Book object with id 6 from "b" to "a".

Since Book-6 belongs to BookStore-1, it is foreseeable that the multi-view caches corresponding to the properties BookStore.books-1 and BookStore.avgPrice-1 will definitely be invalidated.

  • If BinLog trigger is enabled, modifying the database in any way can lead to Jimmer's cache consistency involvement. For example, directly executing the following SQL in SQL IDE:

    update BOOK
    set TENANT = 'a'
    where ID = 6;
  • If only Transaction trigger is enabled, the database must be modified using Jimmer's API:

    sqlClient.save(
    Immutables.createBook(draft -> {
    draft.setId(6L);
    draft.setTenant("a");
    })
    );

No matter which way above is used to modify the data, you will see the following log output:

Delete data from redis: [Book-6] ❶
Delete data from redis: [Author.books-3] ❷
Delete data from redis: [BookStore.books-1] ❸
Delete data from redis: [BookStore.avgPrice-1] ❹
  • ❶ Update object cache of modified entity

  • ❷ Any association property targeting Book must be affected, of course including Author.books

    According to existing database data, the affected Author object id is 3

  • ❸ Any association property targeting Book must be affected, of course including BookStore.books

    According to existing database data, the affected BookStore object id is 1

  • ❹ The calculated cache BookStore.avgPrice of BookStore object with id 1 is also affected. This is the most amazing characteristic.

    Although the framework is unaware of the calculation rule used by users in calculated properties, in the Calculated Cache documentation, we discussed the following code in the BookStoreAvgPriceResolver class:

    @EventListener
    public void onAssociationChange(AssociationEvent e) {
    if (sqlClient.getCaches().isAffectedBy(e) &&
    e.isChanged(BookStoreProps.BOOKS)
    ) {
    ...code omitted...
    }
    }

    If you have forgotten the specific logic of this code, you can review the Calculated Cache documentation. Just focus on the highlighted line. Here, this calculated property cares about changes to the association property BookStore.books.

    tip

    Modifying the association field between tables is not the only way to trigger association change events. Modifying the filtered field in associated objects that affects global filters, like TENANT here, can also trigger association change events.

    This is a very important characteristic of Jimmer's trigger mechanism!

    It is obvious that ❸ has already sensed the change of association property BookStore.books, so it will further lead to the invalidation of the calculated cache here.