Minimalistic, composable cache and database layering inspired by CPU caches
MIT License
Simple and minimal database/request/cache layering.
Scaly facilitates layered data access APIs through the use of async generator functions. Yes, those really exist.
$ npm install --save scaly
Inspired by CPU caching, applications can build out data access layers for various data sources (memory caches, Redis/Etcd connections, MongoDB/MySQL/etc. persistent stores) and facilitate the propagation of data back up the chain in the event of misses.
That's a lot of buzzwords - here's an example problem that Scaly solves:
Your code might look something like this:
const db = /* ... */;
const redis = /* ... */;
const lru = new LRU({ttl: 10 /*seconds*/});
async function getFirstNameFromMongo(eid) {
return db.collections('employees').findOne({eid}, {first_name: 1});
}
async function getFirstNameFromRedis(eid) {
return redis.get(`employee:${eid}:first_name`);
}
async function setFirstNameInRedis(eid, username) {
return redis.setex(`employee:${eid}:first_name`, 3600, username); // expire in an hour
}
function getFirstNameFromMemcache(eid) {
return lru.get(`employee:${eid}:first_name`, username);
}
function setFirstNameInMemcache(eid, username) {
lru.set(`employee:${eid}:first_name`, username);
}
export async function getFirstName(eid) {
const memcache_value = getFirstNameFromMemcache(eid);
if (memcache_value !== undefined) return memcache_value;
const redis_value = await getFirstNameFromRedis(eid);
if (redis_value !== undefined) {
setFirstNameInMemcache(eid, redis_value);
return redis_value;
}
const mongo_value = await getFirstNameFromMongo(eid);
if (mongo_value !== undefined) {
setFirstNameInMemcache(eid, redis_value);
await setFirstNameInRedis(eid, redis_value);
return mongo_value;
}
return undefined; // or error? see notes below about why this is tricky.
}
That's a lot of code. Here are some problems:
setUsernameInMemcache()
duplicate callset/getUsernameInXXX()
Along with a host of other issues.
Scaly helps alleviate these issues:
const scaly = require('scaly');
const db = /* ... */;
const redis = /* ... */;
const lru = new LRU({ttl: 10 /*seconds*/});
const mongoLayer = {
async *getFirstNameByEid(eid) {
const value = await db.collection('employees').findOne({eid}, {first_name: 1});
return value || yield 'EID not found'; // error message
}
};
const redisLayer = {
async *getFirstNameByEid(eid) {
const key = `employee:${eid}:first_name`;
return (await redis.get(key)) || redis.setex(key, 3600, yield);
}
};
const memcacheLayer = {
async *getFirstNameByEid(eid) {
const key = `employee:${eid}:first_name`;
return lru.get(key) || lru.set(key, yield);
}
}
export default scaly(
memcacheLayer, // Hit LRU first ...
redisLayer, // ... followed by Redis ...
mongoLayer // ... followed by MongoDB.
);
const DB = require('./db');
// ...
try {
/*
Calling this for the first time will hit the LRU, then Redis, then MongoDB.
Calling this again within the hour (but after 10 seconds) will hit the LRU, and then Redis.
Calling this again within 10 seconds will only hit the LRU.
*/
const [ok, result] = await DB.getFirstNameByEid(1234);
if (ok) {
console.log('Hello,', result);
} else {
console.error('Invalid EID:', result); // `result` holds the error result returned by the mongoLayer
}
} catch (err) {
console.error('internal error:', err.stack);
}
So, what's happening here?
getFirstNameByEid(eid)
method).async *foo
(note the *
). This allows bothawait
and yield
keywords - the former useful for application developers, and the latterreturn
s the result.yield
expression, whichyield
will not return if an error is yield
ed or throw
n by another layer.return
in this case (the return value is ignored anyway).return;
or return undefined;
.return undefined
's, an error is thrown - allyield err;
(whereerr
is anything your application needs - a string, an Error
object, or something else).throw
statement.throw
.scaly(layer1, layer2, layerN)
returns a new object with all of the API methods between all layers.
This means that if layer1
has a getFoo()
API method, and layer2
has a getBar()
method, the
resulting object will have both getFoo()
and getBar()
methods.
Layers that do not have a method implementation are simply skipped. This means if you wanted
to add a setFirstNameForEid(eid, firstName)
method only for MongoDB, adding it to the mongodbLayer
object is enough for Scaly to add it to the resulting DB
object - calling the method will only hit
the MongoDB layer, as you'd expect.
Finally, Scaly wraps all API call results in an array result: [ok: Boolean, result: any]
.
If a method yielded a recoverable error via yield err;
, the Scaly API call will return [false, err]
.
Likewise, if the API method returns a successful result via return res;
, then the Scaly API call
will return [true, res]
.
Any thrown errors are uncaught by Scaly - hence why they should be reserved for really exceptional errors
and not any that can be generated by the user (in which case, they should be catch
ed by the API method and
converted to a yield err
).
Copyright © 2020-2022, Josh Junon. Released under the MIT License.