Digging Deeper
View as MarkdownDrivers
On this page
Introduction
A driver is the part of a service that talks to the outside: the queue's database and redis, a disk's S3 bucket. The framework
ships the common ones. A package or an app brings its own by listing a factory on its service provider, and an environment
variable then selects it by name, like a built-in one:
import type { QueueDriverFactory } from '@marmeon/queue';
const sqs: QueueDriverFactory = ({ name, container, clock }) => ({
driver: new SqsQueueDriver(container.make(SqsConfig), clock),
queue: 'default',
retryAfterSeconds: 90,
});
export class SqsServiceProvider {
static config = [SqsConfig];
static queueDrivers = { sqs };
}QUEUE_DRIVER=sqsA package whose provider is found by marmeon discover brings its driver without any code in the app. An app lists the provider
in providers of defineApplication(), like any other.
Bringing a driver
A provider lists drivers in a static record, by name. The record's key tells which service the drivers belong to:
| Static | Service | Selected by |
|---|---|---|
queueDrivers | Queues | QUEUE_DRIVER |
failedJobDrivers | Queues | QUEUE_FAILED_DRIVER |
diskDrivers | File storage | driver of a disk in defineStorage() |
cacheDrivers | Cache | CACHE_DRIVER |
mailDrivers | Mail transports | MAIL_DRIVER |
A driver reads its own settings through a configuration of its own, which its provider lists in static config
(package development). The factory resolves what it needs from the container it is given.
The start stops, with a message that names the provider and the name, in these cases:
- The name is malformed. A name is lowercase letters, digits and dashes, and starts with a letter:
sqs,memory-x. - The name belongs to a built-in driver. The built-in names are reserved, so that no installed package silently replaces the
redisordatabasedriver and with it the way jobs are claimed or payloads are kept. - Two providers bring the same name. There is no "last one wins". Rename one of them.
- The environment selects a name nobody brought. The message lists the names there are, so a typo never ends up at another driver.
These checks run at every start, also when the driver is not selected. A provider that has no other content than a driver record is still a valid provider.
The factory itself runs when the driver is selected by the environment, at the start: a factory that throws, or returns a driver that lacks a required member, stops the start then. A driver nobody selects is built on first use and fails there. A factory that failed is not run again; later lookups get the same error.
A provider class that extends another inherits its static record: with both listed, the name appears twice and the start stops. Give the subclass a record of its own.
Contracts are stable
The interfaces a driver implements, such as QueueDriver, FailedJobStore and DiskDriver, are public API. From 1.0 on, a new method comes
only as an optional member, or with a default that keeps the old behaviour, so a driver written today keeps working. A member that
is optional says in its documentation what the framework does without it.
Queue drivers and failed-job stores
A queue driver implements QueueDriver and returns a QueueConnection from its factory: the driver, the connection's default
queue and retryAfterSeconds, the seconds before a claim may be taken again. A failed-job store implements FailedJobStore.
import type { FailedJobStoreFactory } from '@marmeon/queue';
const dynamo: FailedJobStoreFactory = ({ container }) => new DynamoFailedJobs(container.make(DynamoConfig));
export class DynamoServiceProvider {
static config = [DynamoConfig];
static failedJobDrivers = { dynamo };
}The driver is built once, at the start when QUEUE_DRIVER selects it, and on first use otherwise. A factory that returns
no driver, no queue name, or a retryAfterSeconds that is not a positive number stops the start. A custom connection is named like its
driver: dispatch to it with { connection: 'sqs' }, work it with queue:work --connection=sqs.
What a driver must guarantee:
- A claim is atomic. Two workers never hold one job.
ack,releaseandfailact only on the claim they are given, and returnfalsewhen the claim has expired and another worker holds the job. retryAfterSecondsis honoured.reservetakes a job again whose claim is older thanretryAfterSeconds, and never earlier. The framework checks every job'stimeoutplus the worker's--stop-timeoutagainst it whenqueue:workstarts, but only the driver can keep a claim from being taken too early. A driver that ignores it runs a job twice.- The payload is opaque. It arrives as the queue stores it. A job with
static encryptedis encrypted beforepush, so the driver sees only ciphertext and stores it as it is. The record inside carries a version the worker checks, never the driver. It is the JSON of aJobRecord(@marmeon/queue), a stored format: a driver writesavailableAtand leaves every other field as it is. releasewithrefundgives the attempt back. The next claim carries the sameattempts. A driver that ignores it uses up the attempts of every job held during a deploy or stopped with its worker, so a job with few tries fails.ack(job, next)queuesnextin the same atomic step.releasemay carryavailableAt.release(job, delaySeconds, { availableAt })gives the time the job can be claimed again (epoch ms). Write it, in epoch ms, into the stored payload'savailableAtfield, so a worker that cannot read the record yet counts how long it waited from there. A driver that ignores the option leaves the payload as it was; a job released with a long delay may then fail sooner than it should after a deploy.
What happens without an optional member:
| Member | Without it |
|---|---|
reserveAny | A worker without --queue goes round queues() and reserves from each queue itself. |
pushMany | queue.bulk() calls push for each job in turn; a failure midway leaves the earlier jobs queued. With it, the driver queues the jobs of one bulk() in one go, in the order given, as a unit where its store can (a database transaction; a Redis script runs without interleaving). |
counts | The devtools show the size of a queue, not its split into ready, delayed and reserved jobs. |
using | queue.using(trx) refuses with an error: the driver cannot write into a database transaction. |
joinsTransaction | A dispatch inside a transaction waits for the outermost commit, and is dropped on a rollback. The job never exists before the data it depends on does. |
FailedJobStore.prune | Required: queue:prune-failed calls it. |
using and joinsTransaction apply to drivers that keep jobs in the app's database. Any other driver leaves them out and gets
the safe behaviour in the table.
Disk drivers
A disk driver implements DiskDriver and is a DiskDriverFactory: it gets the options of the disk from defineStorage() and the
disk's name, the app root and the container, and returns the disk. A disk selects its driver by name, like the built-in
local, s3 and memory. The built-in drivers are configured in defineStorage(), not constructed — their classes are not
part of the public API; a driver of your own implements DiskDriver:
import type { DiskDriverFactory } from '@marmeon/storage';
declare module '@marmeon/storage' {
interface DiskDrivers {
gcs: { readonly bucket: string };
}
}
const gcs: DiskDriverFactory<{ bucket: string; visibility?: 'public' | 'private' }> = (options, { name, container }) =>
new GcsDisk(name, options, container.make(GcsConfig));
export class GcsServiceProvider {
static config = [GcsConfig];
static diskDrivers = { gcs };
}export const AppStorage = defineStorage({
default: 'media',
disks: { media: { driver: 'gcs', bucket: 'files' } }, // without `bucket` this is a type error
});Declaring the options in DiskDrivers is what makes defineStorage() check them; every disk has visibility and url on top.
A disk whose driver nobody brought stops the start with the disk's name and the drivers there are. The disks of a
driver are built at the start, so a factory that throws, or returns something that is not a DiskDriver (every member of the
contract is required), stops it then. The test fake replaces every disk with a memory disk, so a test never reaches a
provider's driver.
What the framework keeps for a disk of a provider's driver, whatever the driver does:
- Visibility is the configuration's. The returned disk must have the configured
nameandvisibility, or the start stops.url()on a private disk is refused by the framework and never reaches the driver, and a private disk with aurloption stops the start. temporaryUrl()gets whole seconds. The app writesexpiresInas seconds or aDuration; the framework turns it intoexpiresInSeconds, a whole number from 1 to 604 800 (seven days; a longer span throws aRangeErrorbefore the driver is called), before it calls the driver'stemporaryUrl(key, { expiresInSeconds, filename?, base? }). A driver's factory returns aDiskDriver: the members of a disk, with that signature.- A private file is reached only through a signed URL.
temporaryUrl()of a private disk must sign the URL and let it expire. That is the driver's job: the framework cannot tell a signature from a long string and refuses only an empty result. A driver that returns an unsigned URL publishes its private files. - The app serves nothing for it. Routes for public paths and signed local URLs exist for
localandmemorydisks only. Theurlof a provider's disk is an address the driver owns, such as a CDN. - Keys are checked before the driver sees them. Every key, and the directory of
list(), goes throughnormalizeKey()andnormalizeDirectory()first:.., absolute paths, backslashes, empty segments and control characters are refused withInvalidKeyError, as on the built-in disks. Both functions are exported for a driver that checks keys it builds itself. - The disk is held privately. The app gets a wrapper that delegates to the driver's disk; its
nameandvisibilityare the configured ones.
Every member of DiskDriver is required except close(), which the framework calls at shutdown when the disk has one — close a client
or a connection there.
Cache drivers
A cache driver is a pair: a CacheStore for the entries and a LockStore for the locks. The factory returns both, and neither
is optional, so there is no cache without atomic locks. cache.lock(), withoutOverlapping, onOneServer() and unique jobs
all take their locks from the locks half.
import type { CacheDriverFactory } from '@marmeon/cache';
const dynamo: CacheDriverFactory = ({ name, container, clock, prefix }) => {
const table = new DynamoTable(container.make(DynamoConfig), prefix);
return { store: new DynamoStore(table, clock), locks: new DynamoLocks(table, clock) };
};
export class DynamoServiceProvider {
static config = [DynamoConfig];
static cacheDrivers = { dynamo };
}CACHE_DRIVER=dynamoOne object may implement both interfaces and be returned as { store: both, locks: both }, as the built-in stores do. A driver
built from two services, such as entries in a fast store and locks in a store that is safe to share, returns two objects.
CACHE_PREFIX is already part of every key and lock name the driver is given. The prefix of the factory's context is for a
namespace of the driver's own, such as a table or an index.
The driver is built once, at the start, when CACHE_DRIVER selects it. At that point the framework also tries the locks once:
it takes a lock nobody else uses, checks that a second owner cannot take it and cannot free it, frees it with its own owner, and
checks that add does not overwrite a key. A driver that fails stops the start with what it did wrong, because a lock store that
lets two owners in would run a withoutOverlapping job twice.
What a store must guarantee:
addis atomic. Of two calls at once for one key, one writes. It writes only when the key is absent or expired.incrementis atomic. Two processes counting at once never lose a count. A missing, expired or non-integer key starts over atbywith the given expiry; an existing integer keeps its expiry, so a rate limit window does not restart with every hit.- Expiry is the store's.
readreturns no entry past itsexpires. The framework drops an entry that does come back expired, but an expired entry thataddstill treats as present blocks the next writer. clearremoves entries and never a lock.marmeon cache:clearmust not free what a running job holds. This is why locks are a separate interface: nothing inclearhas a lock to free.
What a lock store must guarantee:
lockis atomic. A lock goes to one owner. It is taken when it is free, expired, or already the caller's own.- Only the owner frees a lock.
unlock(name, owner)frees it only whileownerholds it, and an expired lock that another owner took over stays. The framework never calls it with an empty owner. - A lock expires. A holder that crashed cannot block everyone forever;
lockOwnerreturns nothing for an expired lock.
The framework hands the driver only text as keys, lock names and owners, answers true only when the driver returned exactly
true, and never serves an entry past its expiry. These are checks on top of the contract, not a replacement for it: whether a
lock is atomic across processes is the driver's to guarantee.
prune is optional on both halves. marmeon cache:prune calls each one that has it and adds up the counts; when neither has
it, the command says the entries expire on their own. A store that expires entries itself, as Redis does, leaves it out.
Mail transports
A mail transport implements MailTransport and is a MailDriverFactory: it gets the name it was registered under, the
container and a logger of the mail channel, and returns the transport. MAIL_DRIVER selects it by name,
like the built-in log, array and smtp:
import type { MailDriverFactory } from '@marmeon/mail';
const postmark: MailDriverFactory = ({ container }) => new PostmarkTransport(container.make(PostmarkConfig));
export class PostmarkServiceProvider {
static config = [PostmarkConfig];
static mailDrivers = { postmark };
}MAIL_DRIVER=postmarksend() resolves with { messageId }, the id the provider gave the mail, or { messageId: undefined } when it gave none.
mailer.send() hands it on as the messageId of the sent mail; it is what a delivery report from the provider refers to:
class PostmarkTransport implements MailTransport {
async send(message: MailMessage): Promise<MailReceipt> {
const response = await this.#client.post('/email', toPostmark(message));
return { messageId: response.MessageID };
}
}The transport is built at the start when MAIL_DRIVER selects it, so a factory that throws, or returns something without a
send(), stops the start. A MAIL_DRIVER nobody brought stops it too, with the names there are. A transport may have a close(): the framework calls it at
shutdown, to close connections it holds. Mails that are queued reach the
same transport in the worker.
A transport a package brings is responsible for its own route to the provider. The framework's MAIL_SECURE and MAIL_REQUIRE_TLS
apply to smtp only: a transport of your own sends over TLS, checks the certificate of the provider and keeps its credentials out
of the messages it throws and logs. Read your own settings from a configuration of your own, listed in static config.