Skip to content

Cache

Cache() keeps data that is slow to make, so the next request can read it fast.

It runs on dox_cache, which is built into dox_core.

Drivers

Driver Where the data is Shared between isolates Locks
file files in a folder (default storage/cache) yes, on the same machine no
memory a map inside the isolate no yes
redis a Redis server yes, also between machines yes
null nowhere. It stores nothing - no

Many isolates

Dox runs one isolate per CPU core. Each isolate has its own memory store. So a value you put in one isolate is missing in the others. Use redis when all isolates and servers must see the same data.

Config

A new project already has lib/config/cache.dart, with a file store and a memory store. CACHE_STORE in .env picks the default store (file when it is not set). To add a store, edit the file. This example adds a redis store:

lib/config/cache.dart
import 'package:dox_core/dox_core.dart';
import 'package:my_app/config/env.dart';

CacheConfig cache = CacheConfig(
  defaultStore: env.cacheStore,
  stores: <String, Map<String, dynamic>>{
    'file': <String, dynamic>{'driver': 'file', 'path': 'storage/cache'},
    'memory': <String, dynamic>{'driver': 'memory', 'max_size': 10000},
    'redis': <String, dynamic>{
      'driver': 'redis',
      'host': env.redisHost,
      'port': env.redisPort,
      'prefix': 'app_cache:',
    },
  },
);

Redis needs two keys that a new project does not have yet. Add them to lib/config/env.dart and run dart run build_runner build:

lib/config/env.dart
  String get redisHost => '127.0.0.1';

  int get redisPort => 6379;

lib/config/app.dart already passes it to AppConfig(cache: cache). Each store is made once per isolate, the first time you use it.

Store options

Driver Options
file path (default storage/cache), prefix
memory max_size: the most items. When it is full, the item that was used least recently is removed. No limit by default. Expired items are removed when read, and by a sweep after many writes (at least 1000). Also prefix.
redis host (default 127.0.0.1), port (default 6379), username, password, db (default 0), tls (true/false), pool_size (default 10), max_value_bytes (no limit by default), prefix

A redis store with no prefix gets <app name>_cache: from APP_NAME, like my_shop_cache:. So two apps on one Redis do not share keys. The other drivers use dox_cache:. You rarely need pool_size: the cache sends no blocking commands, so each isolate uses about one Redis connection.

Values are JSON

A value must be a JSON type: Map, List, String, num, bool or null. A Map comes back as Map<String, dynamic>. A List comes back as List<dynamic>.

Usage

// no duration: the value stays forever
await Cache().put('foo', 'bar');
await Cache().put('foo', 'bar', duration: Duration(minutes: 10));

await Cache().get('foo');             // 'bar', or null when missing
await Cache().get('foo', 'default');  // 'default' when missing
await Cache().has('foo');             // true
await Cache().forget('foo');          // remove one key
await Cache().forever('settings', <String, dynamic>{'theme': 'dark'});
await Cache().flush();                // remove all items of the store

await Cache().putMany(<String, Object?>{'a': 1, 'b': 2}); // many at once
await Cache().many(<String>['a', 'b', 'c']); // {a: 1, b: 2, c: null}

remember

remember reads the value from the cache. When it is missing, the function runs and its result is stored. The value is stored as JSON. Return maps, lists, strings and numbers, not models.

List<dynamic> posts = await Cache().remember(
  'posts',
  Duration(minutes: 5),
  () async => (await Post().all()).map((Post p) => p.toJson()).toList(),
);

rememberForever(key, fn) does the same with no expiry.

  • decode: turns the stored JSON back into your type on a hit, like decode: (j) => (j as List).cast<String>(). Without it, use JSON types like List<dynamic>.
  • lock: true lets only one caller on all isolates and servers run the function. The others wait for its value.

flexible(key, fresh, stale, fn) serves the value as it is for fresh. Then, until stale more has passed, it still serves the old value at once and runs fn once in the background:

Map<String, dynamic> stats = await Cache().flexible(
  'stats', Duration(minutes: 1), Duration(minutes: 5), loadStats,
);

getRaw

getRaw gives the stored JSON text as it is. Send it as a response with no decode and no encode:

String? json = await Cache().getRaw('posts');
if (json != null) {
  return response(json).contentType(ContentType.json);
}

A missing key gives null. A stored String comes back as JSON, with its quotes: "bar". The memory driver also keeps JSON text, so get decodes on every hit there too.

Another store

store(name) uses a store from the config instead of the default store:

await Cache().store('redis').put('foo', 'bar');
await Cache().store('memory').get('foo');

Tags

Put items under a tag. Then you can remove only the items of that tag:

await Cache().tag('users').put('u1', 'Ann');
await Cache().tag('users').flush(); // removes only the items of this tag

tags(['a', 'b']) uses many tags.

Locks

A lock makes sure only one request (or one server) runs some code at a time. The memory, file and redis stores support locks. The memory lock works inside one isolate only. The file lock works for all isolates on one machine. Use redis when many servers must share the lock.

CacheLock lock = Cache().store('redis').lock('report', Duration(seconds: 30));
if (await lock.get()) {
  try {
    await buildReport();
  } finally {
    await lock.release();
  }
}
  • get() tries once. It is true when you got the lock.
  • block(Duration(seconds: 5)) tries again and again until the time is over.
  • The lock is freed after its ttl, even if you never call release().
  • block(timeout, callback) runs the callback while it holds the lock, then frees it.
  • forceRelease() frees the lock, whoever holds it.

More methods

Method What it does
add(key, value, duration:) store only when the key is missing. true when it was stored
pull(key) get the value and remove it, in one step
increment(key, [by]) / decrement(key, [by]) change an integer. A missing key starts at 0
touch(key, duration:) give a key a new expiry. false when it is missing
repository the dox_cache CacheStore, for everything else

See dox_cache for custom drivers.

Tests

To use a memory store in tests, see Testing.