// `mctl model cache` (← goctl's `model … --cache`): the cache-aside layer in front
// of a table's CRUD.
//
// A cache is not a second copy of the truth: it holds what a read already produced,
// and the write path deletes rather than updates, because a deleted key cannot be
// stale. That is the whole of what is generated here — read-through on `find`,
// invalidate on `write` and on `evict` — and it is generated for one table at a time
// so a service can cache the two tables it actually reads hot.
//
// The port is generic (`Cache[T]`) rather than JSON-shaped: a cache holds the record
// the table produced, so nothing is encoded on the way in or out, and an
// in-memory implementation is a `Map` — which is what the generated `memory_cache`
// is, and what a test drives the layer with.
//
// A broken cache never fails a request: a miss falls back to the table, and a failed
// `set` or `del` is swallowed. A cache that can take a service down is worse than no
// cache at all.
///|
/// The prelude every cached layer shares: the failure a cache may report, the
/// three-call port, and an in-memory implementation to run or test against.
fn cache_prelude() -> String {
"// Code generated by moonctl. DO NOT EDIT.\n" +
"//\n" +
"// `mctl model cache`: cache-aside CRUD for the tables of a spec, over the\n" +
"// `Cache[T]` port below. The record types are the ones `mctl model` generates for\n" +
"// the same spec, so this file goes in the same package as `_model.mbt`.\n" +
"//\n" +
"// Nothing here needs an import: the port is generic, so a cache holds records\n" +
"// rather than the text they serialise to.\n" +
"\n" +
"///|\n/// The one failure a cache reports. A cache failure is not a service failure: the\n/// generated layer falls back to the table when a read misses, and swallows a failed\n/// write, so this surfaces only where a caller wants to see it.\npub(all) suberror CacheError {\n CacheFailed(why~ : String)\n}\n" +
"\n" +
"///|\n/// A cache of `T` by key: three calls, which every store — Redis, Memcached, a\n/// `Map` — reduces to. `get` answers `None` for a key that is not there, and a\n/// `raise` only when the cache itself is broken.\npub(all) struct Cache[T] {\n get : (String) -> T? raise CacheError\n set : (String, T) -> Unit raise CacheError\n del : (String) -> Unit raise CacheError\n}\n" +
"\n" +
"///|\n/// An in-memory cache: what a test drives the layer with, and what a single-process\n/// service may use as it is.\npub fn[T] memory_cache() -> Cache[T] {\n let store : Map[String, T] = Map([])\n {\n get: key => store.get(key),\n set: (key, value) => {\n store[key] = value\n },\n del: key => {\n ignore(store.remove(key))\n },\n }\n}\n\n"
}
///|
/// The key type of a table: its key field's type, without the `?` an optional field
/// carries — a key a cache is looked up by is never absent.
fn cache_key_type(f : @api.Field) -> String {
let t = @text.trim(f.type_)
if @text.ends_with(t, "?") && t.length() > 1 {
t[0:t.length() - 1].to_owned()
} else {
t
}
}
///|
/// One table's cache-aside layer: the key function, the read-through `find`, the
/// invalidating `write`, and `evict`.
fn emit_cached(
name : String,
key : @api.Field,
table : String,
vp : String,
) -> String {
let kv = key.mbt_name()
let kt = cache_key_type(key)
"///|\n/// The key a `" +
name +
"` record is cached under: the table and its `" +
kv +
"`.\npub fn " +
vp +
"_cache_key(" +
kv +
" : " +
kt +
") -> String {\n " +
@text.quote(table + ":") +
" + " +
kv +
".to_string()\n}\n\n" +
"///|\n/// `" +
name +
"` by `" +
kv +
"`, through the cache: a hit answers without touching `load`, and a\n/// miss calls it and caches what came back. A cache that fails on the read is\n/// treated as a miss, so a broken cache costs latency rather than availability.\npub fn " +
vp +
"_find_cached(\n cache : Cache[" +
name +
"],\n " +
kv +
" : " +
kt +
",\n load : (" +
kt +
") -> " +
name +
"? raise CacheError,\n) -> " +
name +
"? raise CacheError {\n let key = " +
vp +
"_cache_key(" +
kv +
")\n let hit : " +
name +
"? = (cache.get)(key) catch { _ => None }\n match hit {\n Some(record) => Some(record)\n None =>\n match load(" +
kv +
") {\n Some(record) => {\n (cache.set)(key, record) catch { _ => () }\n Some(record)\n }\n None => None\n }\n }\n}\n\n" +
"///|\n/// Write a `" +
name +
"` through to `save`, then drop the cached copy: a deleted key cannot be\n/// stale, whereas an updated one can.\npub fn " +
vp +
"_write_cached(\n cache : Cache[" +
name +
"],\n record : " +
name +
",\n save : (" +
name +
") -> Unit raise CacheError,\n) -> Unit raise CacheError {\n save(record)\n (cache.del)(" +
vp +
"_cache_key(record." +
kv +
")) catch { _ => () }\n}\n\n" +
"///|\n/// Delete a `" +
name +
"` through to `remove`, then drop the cached copy.\npub fn " +
vp +
"_evict_cached(\n cache : Cache[" +
name +
"],\n " +
kv +
" : " +
kt +
",\n remove : (" +
kt +
") -> Unit raise CacheError,\n) -> Unit raise CacheError {\n remove(" +
kv +
")\n (cache.del)(" +
vp +
"_cache_key(" +
kv +
")) catch { _ => () }\n}\n\n"
}
///|
/// Generate the cached CRUD layer for every table of `spec`: for a `type` block with
/// a field to look records up by, the key function plus read-through `find`,
/// invalidating `write`, and `evict`. A block with no single-valued field has no key
/// to cache under, so it gets no layer and the file says why.
///
/// The output needs no import and no driver: the record types it names are the ones
/// `mctl model` generates for the same spec.
pub fn generate_cached(spec : @api.Spec) -> String {
let mut out = cache_prelude()
let mut wrote = false
for t in spec.types {
let fields = @api.flat_fields(t, spec.types)
if fields.length() == 0 {
continue
}
match key_field(fields) {
None => ()
Some(key) => {
out = out +
emit_cached(t.name, key, table_name(t.name), @text.to_snake(t.name))
wrote = true
}
}
}
if wrote == false {
out = out +
"// No `type` block of this spec has a field a record could be looked up by, so\n// there is nothing to cache.\n"
}
out
}
///|
/// The file a cached layer lands in: `greet` → `greet_cache.mbt`, beside the
/// `_model.mbt` whose records it caches.
pub fn cache_file_name(service : String) -> String {
(if service == "" { "model" } else { @text.to_snake(service) }) + "_cache.mbt"
}