Services and events

Give each service a database of its own, and let other services follow its writes as events.

A database per service

Run one tenant node and give each service its own tenant: a separate file that no other service can read. A token then decides which collections and rows each caller may touch.

Details

Each service keeps its own data and tells the others what changed. fenecdb gives a service a database of its own on a shared node, a token that reaches only that, and the writes on its disk as a stream of events — which is what an outbox table and a relay are usually built for.

A tenant node (fenec-server --dir) keeps a file per tenant, and a tenant is named by the path, /t/<tenant>/ — never by the query, so one service cannot read another's rows. Give each service its tenant, and a router (fenec-shard) in front of the nodes when there are several: see Tenants and sharding.

Within a database, a JSON Web Token held to a policy gives each caller the collections, and the rows, its role may read or write (Scoped access). A service that only reads can be pointed at a replica, which refuses every write (Replication), or hold a token whose policy grants read alone.

Every write as an event

Turn on --cdc and every write that reaches disk becomes an event a consumer can read. You do not need an outbox table: a batch's rows are announced together.

fenec-server --file orders.fenec --http 127.0.0.1:8080 --cdc

# a consumer the server keeps the cursor of, made once
curl -X POST http://127.0.0.1:8080/_changes/consumers/billing
Details

An outbox table exists because a service must change its data and announce the change together, or not at all. A fenecdb /batch is one record on disk, so the rows it writes are announced together already: GET /_changes hands over every write once it is on disk, the documents with it (Change data capture). The collection is its own outbox. Where the event should say something the rows do not — a command, an intent — write it into a collection of its own in the same /batch, and it comes in the same record.

A consumer in Python:

from fenecdb import Client

db = Client("http://127.0.0.1:8080", token="...")
for batch in db.follow("billing"):        # committed when the loop comes back
    for change in batch:
        if change["collection"] == "orders" and change["op"] == "put":
            bill(change["doc"])           # an exception: the batch is read again

On to Kafka, or anywhere

The relay passes a consumer's events on, as JSON lines or to a webhook. Pipe the lines into Kafka with any tool that writes to a topic.

python -m fenecdb.relay http://127.0.0.1:8080 --consumer kafka \
  | kcat -P -b broker:9092 -t orders
Details

python -m fenecdb.relay reads a consumer's writes and hands them on — to standard output, one JSON object a line, or to a webhook — and moves the cursor only once they are out. Kafka takes them through any tool that writes lines to a topic, as above; a webhook:

python -m fenecdb.relay http://127.0.0.1:8080 --consumer search \
  --to https://indexer.internal/hook

Delivery and replay

Each event arrives at least once, in order within one database. Make your handler safe to run twice; a consumer that falls too far behind starts over from a fresh read.

Details
  • At least once. A consumer that crashes between reading and committing reads those writes again. Key what you do by seq, or by the collection and id: a put carries the whole document, so applying one twice leaves what applying it once did.
  • In order, one database at a time. seq is the database's change counter; a batch's writes come together. There is no order across tenants.
  • Kept for a while, not for ever. The server keeps as many writes as --replication-buffer holds, 64 MiB unless given. A consumer further behind is answered 410, never handed writes with a gap in the middle. To start over: take the last write on disk (Fenec-Next of GET /_changes with no since), read the collections whole, then go on from that number — a write read in both is applied twice, which a put by id makes harmless.
  • What is on disk. A write the server lost in a crash was never handed to anyone, as it was never sent to a replica.
  • Reading one's own writes. A service that writes on the primary and reads on a replica sends the write's Fenec-Seq back as Fenec-After, and the replica answers once it holds the write (Reading your own writes).
  • Retries. A service that sends a write again after a timeout sends it with an Idempotency-Key, and a write that landed is answered as before rather than made twice (Retrying a write).

What it is not

It is not a message broker. To keep events for a long time or share them with many teams, relay them into one.

Details

A consumer reads one database's writes; there are no topics, partitions or retention of their own, and a server keeps its writes in memory up to the buffer. For fan-out to many teams, a long history or events that outlive the database, relay them into a broker and let it keep them.