Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

670 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Fila

A message broker that makes fair scheduling and per-key throttling first-class primitives.

Status: Design, not working code. This describes the system being built.

The problem

Every existing broker delivers messages in FIFO order. When multiple tenants, customers, or workload types share a queue, a single noisy producer can starve everyone else. Rate limiting is pushed to the consumer — which means the consumer has to fetch a message, check the limit, and re-enqueue it. That wastes work and adds latency.

Fila moves scheduling decisions into the broker:

  • Deficit Round Robin (DRR) fair scheduling — each fairness key gets its fair share of delivery bandwidth. No tenant starves another.
  • Token bucket throttling — per-key rate limits enforced at the broker, before delivery. Consumers only receive messages that are ready to process.
  • Lua rules engineon_enqueue and on_failure hooks let you define scheduling policy in user-supplied Lua scripts, for the cases where static configuration isn't enough.
  • Zero wasted work — consumers never receive a message they can't act on.

Key concepts

ConceptWhat it does
Fairness keysMessages are grouped by a fairness_key. The DRR scheduler gives each group its fair share of delivery bandwidth, in proportion to its weight.
ThrottlingToken bucket rate limiters keyed by throttle_keys. The broker holds messages until tokens are available.
Lua hookson_enqueue derives fairness key, weight and throttle keys. on_failure decides retry vs. dead-letter. Both are optional.
Dead letter queueMessages that exhaust retries move to <queue>.dlq. Redrive moves them back.
Runtime configKey-value pairs, readable from Lua via fila.get(key). Change behavior without restarting.
LeasesDelivered messages are leased for a visibility timeout. Unacked leases expire and the message is redelivered.

See docs/concepts.md for the model in depth and docs/lua-patterns.md for hook recipes.


API design

The client is Rust. One connection, from which you extract capability handles.

Capabilities mirror permissions

The broker enforces three ACL kinds: produce, consume, admin. The client hands out exactly three handles, named the same:

let client = FilaClient::connect("localhost:5555").await?;
client.producer()// enqueue
client.consumer()// subscribe, ack, nack, extend lease
client.admin()// queues, config, redrive, API keys, ACLs

This is not decoration. A handle is the unit you pass into the code that needs it: a worker gets a Consumer, so it cannot enqueue or delete a queue. If you needed .producer(), the connection's credentials need produce on that queue. The API shape teaches the permission model instead of restating it in prose.

The broker enforces permissions regardless of which handle you hold — extraction is what makes a privileged call site visible in the code that makes it.

Producing

The common case carries no ceremony:

let producer = client.producer();let id = producer.enqueue("orders",b"payload").await?;

Everything beyond that is opt-in, on a message builder:

let id = producer.send(Message::new("orders", payload).header("tenant","acme").fairness_key("acme")// direct — Lua is not required.weight(3).throttle_key("provider:stripe").delay(Duration::from_secs(30))// deliver no earlier than).await?;

fairness_key is a value you set, not something you reach through a script. Lua is there for policy you can't express as a value — deriving a key from payload size, consulting runtime config, computing a weight — and not as the price of entry to the feature the broker exists for.

Batching is first-class, because the wire protocol is batch-native:

let ids = producer.send_batch(messages).await?;

Consuming

let consumer = client.consumer();letmut orders = consumer.subscribe("orders").await?;whileletSome(delivery) = orders.next().await{let delivery = delivery?;println!("{} attempt {}", delivery.fairness_key(), delivery.attempt());handle(delivery.payload())?;
delivery.ack().await?;}

A Delivery knows its own queue and ID, so acking does not restate them. The old ack(queue, message_id: &str) made you carry two strings back to a call that already had both.

The full set of things you can do with a delivery:

delivery.ack().await?;// done
delivery.nack("downstream timeout").await?;// failed; on_failure decides
delivery.retry_after(Duration::from_secs(60)).await?;// failed; retry no sooner than
delivery.extend_lease(Duration::from_secs(60)).await?;// still working

retry_after and extend_lease are both load-bearing:

  • retry_after sets the backoff explicitly. A client holding a Retry-After from a rate-limited upstream knows the right delay in a way the broker cannot. Without backoff, one failing dependency turns into a hot retry loop.
  • extend_lease keeps a long job's lease alive. Otherwise any work outlasting the queue's visibility timeout is simply unprocessable.

Bounding in-flight work

A subscription grants the broker delivery credit. The broker spends one credit per message and stops at zero, so a slow consumer cannot be buried:

letmut orders = consumer
.subscribe("orders").prefetch(100)// at most 100 unacked at a time.await?;

Unset means unlimited, which is right for a consumer that acks immediately. The credit is replenished as you ack.

Flow control belongs in the protocol rather than in the socket. Throttling by pausing TCP reads slows the connection without telling the broker anything, so it keeps producing work with nowhere to put it.

Batch acking, and more than one subscription per connection:

consumer.ack_all(&deliveries).await?;let orders = consumer.subscribe("orders").await?;let billing = consumer.subscribe("billing").await?;// concurrent, one connection

The protocol multiplexes on request ID, so subscriptions are independent.

Administering

let admin = client.admin();
admin.create_queue(QueueSpec::new("orders").visibility_timeout(Duration::from_secs(30)).on_enqueue(script).on_failure(script)).await?;
admin.delete_queue("orders").await?;
admin.list_queues().await?;
admin.queue_stats("orders").await?;// depth, in-flight, per-key fairness + throttle
admin.set_config("throttle.provider:stripe","100,200").await?;
admin.get_config("throttle.provider:stripe").await?;
admin.list_config("throttle.").await?;
admin.redrive("orders.dlq",100).await?;

Auth and ACLs are the same handle:

let key = admin.create_api_key(ApiKeySpec::new("ci")).await?;// key.secret is returned exactly once — the broker stores only its hash
admin.set_acl(&key.key_id,&[Permission::produce("orders.*"),Permission::consume("orders.eu"),]).await?;
admin.get_acl(&key.key_id).await?;
admin.revoke_api_key(&key.key_id).await?;

Permission is typed — produce / consume / admin — so an invalid kind is unrepresentable rather than a string the broker rejects at runtime.

Administration belongs in the SDK. Anything reachable only by shelling out to the CLI is unreachable from a test, a deploy script, or an operator tool.

Errors

Every operation returns only the errors it can actually produce. There is no god enum in which enqueue can fail with MessageNotFound.

match producer.enqueue("orders", payload).await{Ok(id) => ...,Err(EnqueueError::QueueNotFound(q)) => ...,Err(EnqueueError::Status(StatusError::Forbidden(_))) => ...,Err(EnqueueError::Status(e)) => ...,}

Each type carries its own domain variants plus a shared StatusError for the transport- and server-level failures common to everything. Mapping from a wire error code to an error type is an exhaustive match, so a new code added to the protocol fails to compile until it is handled.

Identifiers

Message IDs are UUIDv7 — time-ordered, so they sort by insertion. They travel the wire as 16 bytes, not as a 36-character string.


Configuration design

Three layers, each with a different lifetime and a different audience.

LayerSet byChangesReadable from Lua
Filefila.tomloperator, at build/deployrestartno
Environmentorchestrator, per deploymentrestartno
Runtime storeoperator or admin API, liveimmediatelyyes, via fila.get(key)

Precedence: environment overrides file. The runtime store is a separate namespace — it holds policy values, not boot parameters, and it is the only layer Lua can see.

Boot configuration

fila.toml, read from the working directory or /etc/fila/fila.toml. Every setting has a default; the broker runs with no config file at all.

[server]
listen_addr = "0.0.0.0:5555"
[storage]
data_dir = "data"
[scheduler]
quantum = 1000# DRR deficit granted per weight unit, per round
[queue]
visibility_timeout = "30s"# default lease duration; per-queue override at creation
[lua]
default_timeout = "10ms"memory_limit = "8MB"circuit_breaker_threshold = 3
[auth]
enabled = falsebootstrap_apikey = ""# first credential; can mint real keys, then remove
[tls]
cert_file = ""key_file = ""client_ca_file = ""# set to require mTLS
[telemetry]
otlp_endpoint = ""# empty disables export

Two conventions worth holding to:

  • Durations are strings with units ("30s", "10ms"), not bare integers with the unit hidden in the field name. visibility_timeout_ms = 30000 puts the unit in the identifier, where it cannot be changed without renaming the field.
  • Sizes are strings with units ("8MB"), for the same reason.

Every key is overridable by environment variable, upper-cased and prefixed: [scheduler] quantumFILA_SCHEDULER_QUANTUM.

Runtime configuration

A flat key-value store, mutable while the broker runs, readable from Lua hooks. This is where operational policy lives — the values you want to change at 3am without a deploy.

admin.set_config("throttle.provider:stripe","100,200").await?;
functionon_enqueue(msg)
localregion=fila.get("routing.default_region") or"us"return { fairness_key=msg.headers["tenant"] ..":" ..region }
end

Throttle rates are configured here rather than at queue creation, because a rate limit is a property of the resource being protected, not of the queue. Any queue whose messages carry throttle_key = "provider:stripe" shares that one bucket.

Namespacing by prefix is a convention the tooling relies on — list_config("throttle.") returns every rate limit — so keep it.


CLI

fila is a thin client over the same SDK — it has no privileged access and no operations the SDK lacks.

fila queue create <name> Create a queue
fila queue delete <name> Delete a queue
fila queue list List queues
fila queue inspect <name> Depth, in-flight, per-key fairness and throttle state
fila config set <key> <value> Set a runtime config key
fila config get <key> Read a runtime config key
fila config list [--prefix p] List runtime config
fila redrive <dlq> --count N Move messages from a DLQ back to its parent
fila auth create --name <n> Mint an API key
fila auth revoke <key-id> Revoke an API key
fila auth acl set <key-id> ... Replace a key's permissions
fila auth acl get <key-id> Show a key's permissions

--addr selects a broker (default localhost:5555); --api-key authenticates.

Documentation

There are exactly two contracts, and they are the two things worth writing down.

ContractDocumentAudience
Wire formatprotocol.mdanyone implementing a client
SDK surfacerustdoc, generated from sourceanyone using the Rust client

Everything else is explanation, not contract:

DocumentWhat it covers
concepts.mdFairness keys, DRR, throttling, leases, dead-lettering
configuration.mdThe three config layers, every key, reserved prefixes
lua-patterns.mdCopy-paste on_enqueue and on_failure hooks
tutorials.mdGuided walkthroughs of the three core use cases
sdk-examples.mdWorked Rust examples beyond the tutorials
cluster-scaling.mdRaft-per-queue clustering and leader routing
benchmarks.mdWhat is measured, why, and the targets
compatibility.mdVersioning and compatibility policy

There is deliberately no hand-written API reference. A binary protocol and a single SDK need no language-neutral contract document, and a hand-maintained restatement of a type signature only drifts from it.

Architecture

A single-threaded scheduler core with multi-threaded I/O. The scheduler loop processes commands from a channel and makes every scheduling decision without locks. Protocol handlers and consumer delivery run on the async runtime's thread pool and reach the scheduler through bounded channels.

Messages are persisted to an embedded key-value store behind a storage trait, so the engine is a choice rather than an assumption. Crash recovery runs at startup.

The wire protocol is a hand-rolled binary protocol, specified in docs/protocol.md. It is batch-native, multiplexes concurrent requests over one connection, and is the only transport — there is no gRPC.

The client is sans-io

The client splits in two, and this is a structural constraint rather than a preference:

A core with no I/O. A state machine over bytes — feed it what arrived, ask it what to send. It owns the codec, request-ID correlation, handshake and capability negotiation, leader-redirect handling, delivery-credit accounting, and shard discovery and merge. No sockets, no TLS, no async runtime, no timers it owns.

An I/O shell. Opens connections, does TLS, pumps bytes, and presents the host language's native idiom.

Fila ships one client today, in Rust. The goal is several, and the reason to draw the line here is that the two halves have opposite properties. The core is the part that is hard to get right and identical everywhere; the shell is the part that should look different in every language, because idiomatic is the whole point of a native SDK.

Reimplementing the core per language is how five SDKs end up with five different subtle bugs in credit accounting. Sharing an async client across languages fails a different way: bridging one language's runtime into another's is worst exactly where the value is, on long-lived server-push streams. Sans-io avoids both — the shared part is pure functions over bytes, which every language can call, and the I/O stays native.

The cost is honest: sans-io is harder to write than a straightforward async client, and the Rust SDK pays it for SDKs that do not exist yet. It is worth paying only because retrofitting it later is a rewrite, not a refactor.

License

AGPLv3

About

A message broker where fair scheduling and per-key throttling are first-class primitives.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors