mirror of
https://github.com/KumoCorp/kumomta.git
synced 2026-09-10 00:01:03 +00:00
324 lines
9.0 KiB
Markdown
324 lines
9.0 KiB
Markdown
---
|
|
tags:
|
|
- utility
|
|
- logging
|
|
- jsonl
|
|
---
|
|
|
|
# `kumo.jsonl.new_multi_tailer`
|
|
|
|
```lua
|
|
local tailer = kumo.jsonl.new_multi_tailer {
|
|
directory = '/path/to/logs',
|
|
consumers = {
|
|
{
|
|
name = 'deliveries',
|
|
checkpoint_name = 'cp-deliveries',
|
|
max_batch_size = 100,
|
|
max_batch_latency = '1s',
|
|
filter = function(record)
|
|
return record.type == 'Delivery'
|
|
end,
|
|
},
|
|
{
|
|
name = 'bounces',
|
|
checkpoint_name = 'cp-bounces',
|
|
max_batch_latency = '5s',
|
|
filter = function(record)
|
|
return record.type == 'Bounce'
|
|
end,
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
{{since('2026.04.09-ea3b2a9b')}}
|
|
|
|
Creates a multi-consumer log tailer that reads each segment file exactly once
|
|
and distributes records to multiple independent consumers. Each consumer has
|
|
its own filter, batch parameters, and optional checkpoint.
|
|
|
|
This is more efficient than running multiple single-consumer tailers over the
|
|
same directory, because decompression and JSON parsing happen once per record
|
|
regardless of how many consumers are configured.
|
|
|
|
## Top-level Configuration Parameters
|
|
|
|
### directory
|
|
|
|
*String.* Required. The directory containing the segment files to read.
|
|
|
|
### pattern
|
|
|
|
*String (glob).* Optional. A glob pattern to match segment files within
|
|
`directory`. Defaults to `"*"`.
|
|
|
|
### poll_watcher
|
|
|
|
*Duration string* (e.g., `"500ms"`). Optional. When set, uses a polling-based
|
|
filesystem watcher with the given interval instead of the platform's native
|
|
filesystem notification mechanism.
|
|
|
|
### tail
|
|
|
|
*Boolean.* Optional. When `true`, ignores any existing checkpoints and starts
|
|
reading from the most recent segment only. Defaults to `false`.
|
|
|
|
### consumers
|
|
|
|
*Array of consumer config tables.* Required. Each entry defines one consumer.
|
|
See [Consumer Configuration](#consumer-configuration) below.
|
|
|
|
## Consumer Configuration
|
|
|
|
Each entry in the `consumers` array is a table with the following fields.
|
|
|
|
### name
|
|
|
|
*String.* Required. A unique name for this consumer. Returned by
|
|
[`batch:consumer_name()`](LogBatch.md#batchconsumer_name) so that the caller
|
|
can route batches correctly.
|
|
|
|
### max_batch_size
|
|
|
|
*Integer.* Optional. Maximum number of records per batch for this consumer.
|
|
Defaults to `100`.
|
|
|
|
### max_batch_latency
|
|
|
|
*Duration string.* Optional. Maximum time to wait for a partial batch to fill
|
|
before yielding it. Defaults to `"1s"`.
|
|
|
|
Consumers with different latencies will have their batches yielded
|
|
independently: a consumer with a short latency does not delay a consumer with a
|
|
longer one.
|
|
|
|
### checkpoint_name
|
|
|
|
*String.* Optional. When set, enables checkpoint persistence for this consumer
|
|
stored as a hidden file `.<checkpoint_name>` inside `directory`. Each consumer
|
|
has its own independent checkpoint.
|
|
|
|
### filter
|
|
|
|
*Function.* Optional. A function that receives each parsed record as a lua
|
|
value and returns `true` to include it in this consumer's batch or `false` to
|
|
discard it. If the function raises an error, the error is propagated to the
|
|
caller.
|
|
|
|
## Methods
|
|
|
|
### tailer:batches
|
|
|
|
```lua
|
|
for batches in tailer:batches() do
|
|
for _, batch in ipairs(batches) do
|
|
print(batch:consumer_name())
|
|
end
|
|
end
|
|
```
|
|
|
|
Returns an iterator that yields a lua table (sequence) of
|
|
[`LogBatch`](LogBatch.md) objects on each call — one entry per consumer whose
|
|
batch is ready — or `nil` when the stream is exhausted.
|
|
|
|
A batch is ready when it is full (`max_batch_size` reached) or its
|
|
`max_batch_latency` has expired. Consumers are yielded independently: a slow
|
|
consumer does not prevent a fast consumer's batch from being delivered.
|
|
|
|
### tailer:close
|
|
|
|
```lua
|
|
tailer:close()
|
|
```
|
|
|
|
Signals the tailer to stop. Any pending or subsequent poll returns `nil`.
|
|
|
|
Note: `:close()` does **not** write a checkpoint. Call
|
|
[`batch:commit()`](LogBatch.md#batchcommit) on each batch after processing it
|
|
to advance that consumer's checkpoint.
|
|
|
|
## Example
|
|
|
|
```lua
|
|
local kumo = require 'kumo'
|
|
|
|
local tailer = kumo.jsonl.new_multi_tailer {
|
|
directory = '/var/log/kumomta',
|
|
pattern = '*.zst',
|
|
consumers = {
|
|
{
|
|
name = 'deliveries',
|
|
checkpoint_name = 'cp-del',
|
|
max_batch_size = 200,
|
|
max_batch_latency = '500ms',
|
|
filter = function(record)
|
|
return record.type == 'Delivery'
|
|
end,
|
|
},
|
|
{
|
|
name = 'bounces',
|
|
checkpoint_name = 'cp-bounce',
|
|
max_batch_latency = '5s',
|
|
filter = function(record)
|
|
return record.type == 'Bounce' or record.type == 'TransientFailure'
|
|
end,
|
|
},
|
|
},
|
|
}
|
|
|
|
for batches in tailer:batches() do
|
|
for _, batch in ipairs(batches) do
|
|
local name = batch:consumer_name()
|
|
for record in batch:iter_records() do
|
|
print(name .. ': ' .. record.id)
|
|
end
|
|
batch:commit()
|
|
end
|
|
end
|
|
|
|
tailer:close()
|
|
```
|
|
|
|
## Per-Customer Batched Webhook Example
|
|
|
|
This example models a multi-tenant scenario where each customer has their own
|
|
egress pool and their own HTTP endpoint to receive log events. A consumer is
|
|
configured for each customer, filtering on the `egress_pool` field of the
|
|
record. Each consumer has its own checkpoint, so progress is tracked
|
|
independently per customer, however, there is a tradeoff regarding
|
|
error handling if just one of those consumers has a persistent issue;
|
|
see the comments in the example below.
|
|
|
|
If you need fully independent per-customer retry behaviour with no risk of
|
|
dropping records, consider the alternative approach shown in
|
|
[Per-Customer Webhook Example with `main` Parameters](new_tailer.md#per-customer-webhook-example-with-main-parameters),
|
|
which runs one `kumo.jsonl.new_tailer` process per customer.
|
|
|
|
Save the script as e.g. `/path/to/webhook.lua` and run it as a standalone
|
|
script:
|
|
|
|
```console
|
|
$ /opt/kumomta/sbin/kumod --script --policy /path/to/webhook.lua
|
|
```
|
|
|
|
```lua
|
|
local kumo = require 'kumo'
|
|
|
|
kumo.on('main', function()
|
|
-- Map each customer (egress pool name) to their webhook endpoint.
|
|
local endpoints = {
|
|
['customer-a'] = 'http://customer-a.example.com/log',
|
|
['customer-b'] = 'http://customer-b.example.com/log',
|
|
['customer-c'] = 'http://customer-c.example.com/log',
|
|
}
|
|
|
|
local client = kumo.http.build_client {}
|
|
local max_retries = 5
|
|
local retry_delay_seconds = 2
|
|
|
|
-- Perform the HTTP POST to the given url and return the response.
|
|
local function post_batch(url, payload)
|
|
return client
|
|
:post(url)
|
|
:header('Content-Type', 'application/json')
|
|
:body(payload)
|
|
:send()
|
|
end
|
|
|
|
-- Encode the records as a JSON array, then post to url with retries.
|
|
-- On unrecoverable failure, logs the error and commits anyway so that
|
|
-- the other consumers can continue to make progress; this means the
|
|
-- batch is dropped with no retry for this customer.
|
|
--
|
|
-- This is an inherent trade-off with the multi-consumer tailer: all
|
|
-- consumers share a single read position, so a failure on one cannot
|
|
-- block the others without stalling all consumers.
|
|
--
|
|
-- If you need robust per-consumer delivery with independent retry
|
|
-- behaviour that never drops records and never blocks other consumers,
|
|
-- run a separate kumo.jsonl.new_tailer (single-consumer) instance per
|
|
-- customer instead.
|
|
local function process_batch(url, records)
|
|
local payload = kumo.serde.json_encode(records)
|
|
|
|
for attempt = 1, max_retries do
|
|
local response = post_batch(url, payload)
|
|
|
|
if response:status_is_success() then
|
|
return
|
|
end
|
|
|
|
local status = response:status_code()
|
|
local disposition = string.format(
|
|
'%d %s: %s',
|
|
status,
|
|
response:status_reason(),
|
|
response:text()
|
|
)
|
|
|
|
-- 429 Too Many Requests and 5xx server errors are transient
|
|
-- and worth retrying. Other 4xx errors are not.
|
|
local retryable = status == 429 or status >= 500
|
|
if not retryable or attempt == max_retries then
|
|
-- Drop this batch and commit so other consumers are not stalled.
|
|
kumo.log_error(
|
|
string.format(
|
|
'webhook post to %s failed (attempt %d/%d), dropping batch: %s',
|
|
url,
|
|
attempt,
|
|
max_retries,
|
|
disposition
|
|
)
|
|
)
|
|
return
|
|
end
|
|
|
|
-- Transient failure: wait before retrying.
|
|
kumo.log_error(
|
|
string.format(
|
|
'webhook post to %s failed (attempt %d/%d), retrying in %ds: %s',
|
|
url,
|
|
attempt,
|
|
max_retries,
|
|
retry_delay_seconds,
|
|
disposition
|
|
)
|
|
)
|
|
kumo.time.sleep(retry_delay_seconds)
|
|
end
|
|
end
|
|
|
|
-- Build one consumer per customer, filtering on egress_pool.
|
|
local consumers = {}
|
|
for name, _ in pairs(endpoints) do
|
|
local pool = name
|
|
table.insert(consumers, {
|
|
name = pool,
|
|
checkpoint_name = 'webhook-' .. pool,
|
|
max_batch_size = 100,
|
|
max_batch_latency = '1s',
|
|
filter = function(record)
|
|
return record.egress_pool == pool
|
|
end,
|
|
})
|
|
end
|
|
|
|
local tailer = kumo.jsonl.new_multi_tailer {
|
|
directory = '/var/log/kumomta',
|
|
consumers = consumers,
|
|
}
|
|
|
|
for batches in tailer:batches() do
|
|
for _, batch in ipairs(batches) do
|
|
local url = endpoints[batch:consumer_name()]
|
|
process_batch(url, batch:records())
|
|
batch:commit()
|
|
end
|
|
end
|
|
|
|
tailer:close()
|
|
client:close()
|
|
end)
|
|
```
|