50 Commits
Author SHA1 Message Date
jcoffey-dev 54eaf5d3d6 PostgreSQL search: find words inside URLs and file names in body text
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Canceled after 4m49s
After #37, address fields on PostgreSQL are split into words as the
built-in index splits them, but language text (subject, body,
attachments) still goes straight to PostgreSQL's parser, which keeps a
URL, host, path or file name as tokens of its own:
"https://x.example/shipping-support/" becomes a url, a host and a
url_path, "invoice-2024.pdf" a file. So TEXT/BODY "shipping" missed
messages where the word appears only inside a link, while RocksDB and
the other built-in backends found them: 8 messages across a handful
of searches in the rehearsal.

On insert, language text is now indexed as it was, followed by the
word parts of each token that holds a URL separator (/ . @ : ? = & # _
% + ~ \), split with SpaceTokenizer as keyword_terms() splits addresses.
The parts go through the same text search configuration as the rest of
the text, so they are stemmed like the words around them. Plain words,
words that only carry punctuation ("end.", "(see") and hyphenated words
(the parser already splits those) add nothing, so text without links
is indexed exactly as before. Each part is added once per document.
On sample mail, the text vector of a short order notice with three
links grows from 546 to 716 bytes, a newsletter with 25 tracking links
from 5586 to 6430, and a plain letter not at all.

On search, a query word written as a URL, host, file or hyphenated word
also matches as its word parts, ORed with the query as written, so
"shipping-support" or "invoice-2024.pdf" match the new parts and
documents indexed before this change still match as they did.

Existing messages keep their old vectors until they are reindexed (the
reindexAccounts task); new and reindexed messages match at once.

store::search_tests gains test_url_word_search: five bodies, 19 body
searches for words found only in a URL path, query string, host or
file name, the tokens as written, plain words and non-matches, with the
same expected ids on every backend. It passes on RocksDB, SQLite,
MySQL and PostgreSQL; on main PostgreSQL fails at the first ("shipping"
finds [3], not [0, 3]). On PostgreSQL the suite then stops at the
account sort assertion (query.rs:689) exactly as it does on main.
2026-09-24 21:33:17 -07:00
jcoffey-dev 51b159a1a2 Merge pull request 'Tracers whose settings change start over on reload' (#50) from fix/tracer-live-reload into main
ci / fork-checks (push) Successful in 24s
ci / build (push) Successful in 34m49s
2026-09-25 03:57:52 +00:00
jcoffey-dev a891667149 Tracers whose settings change start over on reload
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 3m49s
A cluster rehearsal moved a Log tracer to another directory: the write
was reported x:settingsReload applied:true, but the tracer kept writing
to the old file until a restart. Telemetry::update only refreshed each
running tracer's events, level and lossiness; a tracer's own settings
(path, prefix, rotation, format, endpoint, headers, ...) stayed as built.

Each tracer now carries a hash of the registry object it was built
from, less the fields that change in place. The reload compares it with
the running tracer's: unchanged ones are updated in place as before,
changed ones are started over, new ones started and removed ones
stopped. Only tracers this server started are removed; upstream removed
every subscriber not in the settings, which also cut off live-tracing
streams on each reload.

Starting over is a swap in the collector, so no event is lost or
written twice: a subscriber registered under a running one's id
replaces it between two collection passes. The old one's batch is sent
first (what its full channel can't take moves to the new one), and
dropping it closes its channel, so its task writes what is queued and
ends. Per tracer kind:

- Log: a tracer started over on the same files (rotation or format
  changed) waits for the old one to finish, so lines don't interleave.
- Webhook: the task held a sender of its own channel for retries, so
  it never ended; retries now use a weak sender, and pending events are
  posted when the channel closes.
- OpenTelemetry: pending logs and spans are exported when the channel
  closes instead of dropped, and a span that was open across the swap
  is exported by the new tracer with the events it saw.
- Console and journal: nothing kept between batches.
- Trace history: built from the tracing store, which takes a restart,
  so it is never started over.

No kind needs a restart, so x:settingsReload doesn't gain one.

system::tracer_reload::tracer_reload_tests (new): a Log tracer created
over JMAP writes to its directory; its path is changed over JMAP while
2000 numbered events are emitted; after the reload, events land in the
new file and not the old one, each numbered event is in exactly one of
the two files, and a destroyed tracer writes nothing. On main the new
file never appears.
2026-09-24 20:52:46 -07:00
jcoffey-dev 59e631eded Merge pull request 'Every node records DMARC and TLS results for the aggregate reports' (#47) from fix/front-node-dmarc into main
ci / fork-checks (push) Successful in 1m27s
ci / build (push) Successful in 40m18s
2026-09-25 01:46:19 +00:00
jcoffey-dev 716800d681 Merge pull request 'Publish: accept tags on release/* branches for hotfix releases' (#48) from ci/publish-release-branches into main
ci / fork-checks (push) Successful in 19s
ci / build (push) Canceled after 7m17s
2026-09-25 01:38:56 +00:00
jcoffey-dev 4b85113262 Publish: accept tags on release/* branches for hotfix releases
ci / fork-checks (pull_request) Successful in 20s
ci / build (pull_request) Successful in 7m16s
The publish workflow only built a tag whose commit is on main. That keeps
every image tied to reviewed code, but it means production can only get a
fix together with everything that has landed on main since its release.

A tag on a release/* branch is now accepted too. A hotfix branch starts at
an earlier release tag, takes fixes through pull requests into it (so the
code is still reviewed and CI-tested before it is tagged), bumps
brand_version! and is tagged there. The tag must still equal
v<brand_version!>, and the step prints which branch it was found on.

A tag runs the workflow file from its own commit, so a hotfix branch that
starts before this change needs this commit cherry-picked onto it before
its tag is pushed.
2026-09-24 18:31:24 -07:00
jcoffey-dev 9cc9951428 Merge pull request 'Report reschedules keep the task queue readable' (#46) from fix/report-reschedule into main
ci / fork-checks (push) Successful in 32s
ci / build (push) Canceled after 8m20s
2026-09-25 01:30:35 +00:00
jcoffey-dev 5dde9793eb Every node records DMARC and TLS results for the aggregate reports
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 17m25s
The report scheduler dropped DMARC and TLS events on a node whose role
lacks outboundMta (upstream never started it there, so they sat in a
channel nobody read). Mail received on a front node therefore never
reached an aggregate report, which is meant to cover all of a domain's
inbound mail, whichever node received it. In rehearsal, five messages
received on port 25 on a front node were missing from every report.

- The report scheduler records on every node. Recording is a store write
  the nodes already share, so it needs nothing from the outbound MTA.
  Building and sending a report (the DmarcReport and TlsReport tasks) stay
  with outboundMta nodes, as the task manager already enforces.
- More nodes now append to one report at once. Appends already guard the
  report's versioned primary key; a write that loses now retries up to ten
  times after a short random pause, not three times at once.
- The node sending a report deletes it only if it is unchanged since it
  was read, and reads it again otherwise, so a record another node appends
  meanwhile goes out with the report instead of being deleted unsent.

Test: cluster::front_reports (PostgreSQL and MySQL). A front node's
results appear in the report the MTA node sends, alongside eight appended
at once from both nodes, and the front node never runs the report task.
It fails on main: the front node's results are never recorded.
2026-09-24 18:28:00 -07:00
jcoffey-dev 1a7859a8cc Report reschedules keep the task queue readable
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 4m0s
Setting deliverAt on an internal DMARC or TLS report wrote the new task
queue row with the report's object type (0x21, 0x6e) instead of the task
type (7, 8), and left the task row at its old due. The task manager's scan
failed on that row with store.data-corruption ("Failed to iterate over task
queue"), and because the error ended the whole scan, every task due after
the row stopped running on every node.

- reschedule_ops writes the new queue row through schedule_task_with_id, so
  it carries the task type and the task row gets the new due. It removes
  the row the task is actually queued under (the task's due, which differs
  from deliverAt once the task has been retried) and any row an earlier
  reschedule left at deliverAt.
- x:DmarcInternalReport/set and x:TlsInternalReport/set lock the report's
  task while they move it, as x:Task/set does, refuse while the report is
  being sent, release the locks however the request ends, and wake the task
  manager.
- The task manager logs a queue row it can't read (id, due, key, value) and
  skips it instead of ending the scan. It then repairs the row from its task:
  the row is rewritten with the task's type, and a row with no task behind
  it is removed. A row holding a report's object type for a report task is
  what the old reschedule wrote: the task is moved to that row's time, as
  the reschedule intended, and its old queue row is removed. Stores that
  already hold such a row recover on their own once it comes due.
- x:Task/query with a type filter skips an unreadable row instead of
  failing.

Test: smtp::reporting::reschedule (RocksDB and PostgreSQL). It fails on
main: x:Task/get shows the old due, and with that check removed, neither
report nor a later task ever runs.
2026-09-24 18:09:44 -07:00
jcoffey-dev b90a7f173e Merge pull request 'Cluster role changes apply to delivery and tasks without a restart' (#44) from fix/live-role-changes into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 34m3s
2026-09-25 00:56:30 +00:00
jcoffey-dev e00978c0b4 Cluster role changes apply to delivery and tasks without a restart
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m11s
In cluster rehearsal 3, turning outboundMta off on node1's role was
reported applied (x:settingsReload applied: true), yet node1 kept
delivering mail, a report message included, until it was restarted.
The queue and report managers were started at boot only when the
node's role included outboundMta (crates/smtp/src/lib.rs), and the task
manager only when the role had some task type (spawn_task_manager).
After that nothing looked at the role again: a queue manager that was
running kept claiming and delivering, and one that wasn't never
started.

They now start on every node (outside recovery mode) and follow the
role live:

- Queue manager: before each scan it reads the role from the running
  settings. Without outboundMta it claims nothing new; deliveries
  already running finish and report back as usual, which releases
  their locks. When the role comes back (a reload wakes the manager
  with ReloadSettings, and it looks again every 30 s regardless) it
  logs queue.started and scans the whole queue at once.
- Report scheduler: DMARC and TLS report events are handled only while
  the role has outboundMta, as at boot; events arriving without it are
  dropped, as they were on a node started without the role.
- Task manager: task_enabled already read the current role on every
  scan. It now also runs on nodes whose role has no task type (the
  scan returns at once until one is added), a job claimed before a
  role change is handed back at once rather than run or held until
  its lease lapses, and a settings reload wakes the manager so a role
  that gained task types starts claiming them straight away.

Starting the queue manager on every node also drains the queue channel
on nodes without outboundMta. Upstream left that channel unread, so
each message queued there parked a refresh in it, and by the code,
queueing would block once 1024 had piled up (not reproduced here).

A role object edit reaches the nodes that name that role in
INBUXA_ROLE. Moving a node to another role still means changing its
environment, and so a restart. Listener changes in a role still need a
restart too (listeners bind at boot); this change is about tasks and
delivery.

cluster::live_roles::live_role_tests (new; PostgreSQL, two nodes over
one store):
1. A node started with outboundMta delivers and runs a TLS report
   task; after its role loses outboundMta and the settings reload, a
   new message isn't attempted and a new report task stays pending;
   with the role back, both are taken up.
2. A node started with no task type at all gains outboundMta: a
   waiting message is attempted and a report task runs.
On main the test fails at step 1 ("delivery attempted without
outboundMta"); with step 1 bypassed, step 2 fails (nothing picked the
message up in 20 s).
2026-09-24 17:45:30 -07:00
jcoffey-dev ad58c35f39 Merge pull request 'SQL queries time out; readiness follows the data store' (#45) from fix/query-timeouts into main
ci / build (push) Canceled after 11m21s
ci / fork-checks (push) Successful in 55s
2026-09-25 00:45:09 +00:00
jcoffey-dev 181ab1c140 Merge pull request 'PostgreSQL search GIN indexes without a pending list' (#43) from fix/pg-gin-fastupdate into main
ci / fork-checks (push) Canceled after 0s
ci / build (push) Canceled after 0s
2026-09-25 00:45:08 +00:00
jcoffey-dev 08f29926d4 SQL queries time out; readiness follows the data store
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m13s
Cluster rehearsal 3: with PostgreSQL paused (docker pause, so its
kernel still answered TCP keepalives), requests on connections already
checked out hung until it came back, and /healthz/ready stayed 200
through the outage. #41 bounded getting a connection, not using one.

Client-side query limits (store::backend::query_timeout). Every
operation on a PostgreSQL or MySQL connection now runs under a time
limit. A server-side statement_timeout (or MySQL's MAX_EXECUTION_TIME,
which covers SELECTs only) can't do this: the server that would enforce
it is the one not answering. When an operation runs out, its connection
is closed instead of pooled, since a query may still be in flight on it
or a transaction open: deadpool's Object::take on PostgreSQL;
Conn::disconnect on MySQL, which marks the connection closed before it
sends anything, so the pool discards it even when the server never
answers.

- query, 2 minutes: reads, writes (the whole transaction with its
  retries), blobs, SQL lookups, search queries and indexing. These take
  milliseconds; two minutes leaves room for a large blob over a slow
  link and still ends a hang.
- maintenance, 30 minutes: range deletes (account removal, purges),
  unindexing, purge_store, and creating tables and indexes at startup,
  which can legitimately run long in one statement. Their existing
  chunked fallback for server-side statement timeouts is unchanged.
- iterate (exports, reindexing, maintenance scans) can run for hours,
  so the query limit bounds each wait for the database (preparing, the
  query starting, the next row) rather than the whole scan.

The limits are fixed, like the pool timeouts; the DataStore schema has
no field for them. Tests set them with Store::with_query_timeouts
(test_mode only).

Readiness. /healthz/ready answered 200 whenever a data store was
configured. It now reads one key from the data store with a 2 s limit
and reuses the answer for 2 s, so probes can't load the database;
while one probe runs, others get the last answer. The first failed
probe of an outage is logged. /healthz/live stays 200: restarting a
node doesn't bring its database back, and an orchestrator restarting on
failed liveness would restart every node at once. The container
HEALTHCHECK already uses /healthz/live.

Tests, store::pool_timeout (a proxy that stops forwarding while
keeping connections open plays the paused database):
- postgres_query_timeout, mysql_query_timeout (new): with four pooled
  connections open, a read, a scan and a write each fail with "Query
  timed out" 2.0 s after the pause (2 s test limit); once the proxy
  forwards again the store answers. With the limits set to an hour
  (upstream's behavior), the read was still waiting at the test's 20 s
  limit.
- postgres_readiness (new, STORE=PostgreSql): a node's data store
  goes through the proxy; /healthz/ready is 200, 503 about 4 s after
  the pause while /healthz/live stays 200, and 200 again about 2 s
  after it ends.
- postgres_pool_timeout, mysql_pool_timeout: pass as before.
store::store_tests (PostgreSql, MySql, including the MariaDB statement
timeout step) and store::task_locks (PostgreSql) pass;
store::search_tests (PostgreSql) fails at the same ordering assertion
(query.rs:684) as on main.
2026-09-24 16:45:54 -07:00
jcoffey-dev fde43774b4 PostgreSQL search GIN indexes without a pending list
ci / fork-checks (pull_request) Successful in 30s
ci / build (pull_request) Successful in 7m26s
A three-node rehearsal on PostgreSQL saw searches take about 185 ms
with 80 to 260 pages in the full-text indexes' pending lists, 2 to 6 ms
right after gin_clean_pending_list() or VACUUM, then creep back up as
mail came in. The search tables' GIN indexes were created with the
default fastupdate=on: new entries wait in an unindexed pending list
that every search scans in full until VACUUM (or 4 MB of backlog)
merges it, and autovacuum only visits an insert-only table after
thousands of inserts.

The search GIN indexes are now created WITH (fastupdate = off), so an
insert pays its index update at once. The schema step runs at every
startup (create_search_tables, via SearchStore::create_indexes), so
indexes made before this change are switched there: when an index's
reloptions don't already turn fastupdate off, ALTER INDEX ... SET
(fastupdate = off) and one gin_clean_pending_list() merge its backlog.
The ALTER takes a SHARE UPDATE EXCLUSIVE lock, which blocks neither
reads nor writes; after the first startup the step is one catalog read
per index. A failure is logged and startup goes on (search still
works, only slower).

Per-table autovacuum settings for the search tables are left alone.
The pending list was the only reason the insert threshold mattered for
search; dead tuples and freezing are served by the defaults, and table
settings would override whatever tuning the DBA has done.

MySQL is unaffected: InnoDB FULLTEXT keeps new entries in an in-memory
cache that queries read directly, with no setting like fastupdate.

store::search_gin::postgres_gin_fastupdate (new, PostgreSQL) builds
the search schema in a schema of its own and checks pg_class.reloptions:
fastupdate=off on every GIN index of a fresh schema; then, with the
option reset to the default and 500 rows pending, one startup turns it
off everywhere and leaves no pending tuples (pgstatginindex); a second
startup changes nothing. On main it fails at the first check.
2026-09-24 15:51:06 -07:00
jcoffey-dev d86e7639ac Merge pull request 'Allowed IPs take the full settings reload after a write' (#42) from fix/allowed-ip-reload into main
ci / fork-checks (push) Successful in 28s
ci / build (push) Successful in 35m32s
2026-09-24 20:33:18 +00:00
jcoffey-dev fcef4b1c3f Allowed IPs take the full settings reload after a write
ci / build (pull_request) Successful in 11m59s
ci / fork-checks (pull_request) Successful in 45s
write_reload_target sent AllowedIp writes to the blocked-IP reload, but
that reload rebuilds only BlockedIps. Allowed IPs are parsed into the
core's security settings (Security::parse), which only a full reload
rebuilds, so an AllowedIp write reported x:settingsReload applied: true
while the change wasn't live until the next full reload.

AllowedIp now maps to the full reload, like the other settings objects;
BlockedIp keeps its targeted reload.

system::auto_reload::settings_reload_tests now creates an allowed IP
over JMAP and checks that is_ip_allowed sees it with no ReloadSettings,
and that destroying it takes it out again. On main it fails ("allowed
IP not in the running settings").
2026-09-24 13:20:47 -07:00
jcoffey-dev 89860aa5cc Merge pull request 'SQL pools time out; task locks are a renewed five-minute lease' (#41) from fix/pool-timeouts into main
ci / build (push) Canceled after 14m20s
ci / fork-checks (push) Successful in 33s
2026-09-24 20:18:56 +00:00
jcoffey-dev 6e50ba25a9 SQL pools time out; task locks are a renewed five-minute lease
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 4m58s
A 3-node rehearsal (PostgreSQL + NATS + Garage) found two ways a crash
leaves work stuck:

Pool hangs. The PostgreSQL pool (deadpool) was built with no timeouts,
so a request waited for a free connection, and for one to be opened or
recycled, for as long as it took: forever when the server stopped
answering. MySQL's pool (mysql_async) has no wait timeout at all.

- PostgreSQL: wait 30 s (or the store's timeout if longer), create the
  store's timeout or 15 s (it bounds the whole handshake, where
  tokio-postgres's connect_timeout covers only the TCP connect), recycle
  10 s. The pool config is now always set, not only with
  poolMaxConnections.
- MySQL: every connection is taken through MysqlStore::conn(), which
  gives up after 30 s.
- Both: TCP keepalive after 60 s idle, so a server that vanished
  without closing the connection is noticed in minutes rather than the
  two-hour system default.

The DataStore schema has no pool timeout settings, so these are fixed
defaults; the store's own timeout bounds connecting on PostgreSQL.

Task locks. A task lock lasted an hour, so after a hard crash the dead
node's tasks waited up to an hour and five minutes. The lock is now a
five-minute lease: while this node runs a task, the task manager renews
its lock every third of the lifetime (InMemoryStore::renew_lock, a
compare-and-set on the store backends and SET XX EX on Redis, which
leaves a lock that already expired alone). A killed node's tasks run
elsewhere within about five minutes plus the claim recheck. A task this
node holds isn't handed to a worker again by the scan.

store::pool_timeout (new): a local listener that accepts connections
and never answers plays a hung server; a PostgreSQL store with a 2 s
timeout returns an error in about 4 s, and a MySQL store in 30 s.
Without the timeouts both wait for good. store::task_locks gains a
task held for 1.5 lock lifetimes: its lease is still held, and released
when the task ends.
2026-09-24 13:11:26 -07:00
jcoffey-dev 127ef5701d Merge pull request 'Task manager: every task type follows the node's cluster role' (#40) from fix/task-role-filtering into main
ci / build (push) Canceled after 11m59s
ci / fork-checks (push) Successful in 13s
2026-09-24 20:06:55 +00:00
jcoffey-dev 1543ea5a9e Task manager: every task type follows the node's cluster role
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 3m17s
A 3-node rehearsal found taskQueueProcessing didn't filter anything:
roles.task_manager only decided whether the task manager started, and
report, ACME, DKIM, DNS, calendar, thread-merge and restore tasks ran on
any node with a task manager (manager.rs returned true for them). A node
whose role left taskQueueProcessing off still ran them if it indexed or
did maintenance.

Every task type now answers to one ClusterTaskType (task_enabled):

- IndexDocument, UnindexDocument, IndexTrace: searchIndexing
- AccountMaintenance, TenantMaintenance, DestroyAccount:
  accountMaintenance
- StoreMaintenance: storeMaintenance
- SpamFilterMaintenance: spamClassifierTraining
- DmarcReport, TlsReport: outboundMta. They build and send reports to
  other domains (TLS reports can go straight to an HTTPS endpoint),
  which is the outbound MTA's business.
- CalendarAlarmEmail, CalendarAlarmNotification, CalendarItipMessage,
  MergeThreads, RestoreArchivedItem, AcmeRenewal, DkimManagement,
  DnsManagement: taskQueueProcessing, the role for queue tasks with no
  role of their own.

A node that may not run a task leaves it unclaimed (no lock), so a node
that may picks it up. The task manager also starts on a node whose only
task role is outboundMta, so reports still run there.

cluster::task_roles::task_role_tests (new, two task managers over one
PostgreSQL store): node A (taskQueueProcessing only) runs a DNS task and
leaves an unindex task and a TLS report pending; node B (searchIndexing
and outboundMta) comes up and runs those two; a DNS task scheduled next
stays pending on B and runs on A. On main node A runs the TLS report.
2026-09-24 12:46:49 -07:00
jcoffey-dev 4cb42f28f3 Merge pull request 'Registry writes apply to the running settings without ReloadSettings' (#39) from fix/registry-auto-reload into main
ci / fork-checks (push) Successful in 18s
ci / build (push) Canceled after 32m47s
2026-09-24 19:34:08 +00:00
jcoffey-dev 2c684be5c9 Registry writes apply to the running settings without ReloadSettings
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 3m21s
A 3-node rehearsal found that saving an MtaDeliverySchedule left it
unknown to the queue ("Queue strategy not found") until someone ran
x:Action ReloadSettings; only Directory and Authentication writes
reloaded (DIR-17). The admin UI has to remember a separate reload after
every save, and a script or API client that doesn't gets a server
running stale settings.

x:<Object>/set now reloads the running settings when it created,
updated or destroyed an object they are built from, and broadcasts the
same RegistryChange::Reload over the coordinator as ReloadSettings, so
every node applies it:

- Settings objects (MTA, spam filter, listeners, tracers, Sieve system
  scripts, cluster roles, directories, ...: the object types the core,
  telemetry, listener and directory builders read) get a full reload.
- Certificates, lookup stores and blocked/allowed IPs get their own
  targeted reloads.
- Accounts, domains, roles and other data read as needed, stores (they
  take a restart) and applications (their own reload action) get none.

Full reloads are coalesced: a write waits for a reload that started
after it was stored and joins one if it can, so a burst of writes, or
a request with many objects, costs one or two reloads, not one each.

The write itself is never undone. When the reload is refused (build
errors in objects that were working, the rule from the previous
commit), the set response says so in a new x:settingsReload field,
{"applied": false, "description": "Saved, but the running settings
were not reloaded. <object>: <error>"}; {"applied": true} otherwise.
The field is absent when the write needs no reload. The description
helper is shared with ReloadSettings' refusal.

Each reload sends the queue a ReloadSettings event, so the SMTP test
harness's read_event, try_read_event and assert_no_events now pass over
those; expect_reload_settings still waits for one.

system::auto_reload::settings_reload_tests (new): an MtaVirtualQueue
and an MtaDeliverySchedule created over JMAP are in the running
settings with no ReloadSettings, and gone once destroyed; eight
concurrent creates all land; a write whose reload fails is stored and
reported applied: false with the error; a domain write carries no
x:settingsReload. On main the new schedule is missing. The cluster
broadcast test (three nodes, PostgreSQL + NATS) now checks that every
node has a schedule created on node 0 without a reload.
2026-09-24 12:30:00 -07:00
jcoffey-dev 19eb25a426 Merge pull request 'Settings reload: no DNS at build time, don't refuse over old failures' (#38) from fix/reload-resilient-build-errors into main
ci / build (push) Canceled after 14m33s
ci / fork-checks (push) Successful in 44s
2026-09-24 19:19:34 +00:00
jcoffey-dev 999ae12cc7 Settings reload: no DNS at build time, don't refuse over old failures
ci / build (pull_request) Successful in 3m22s
ci / fork-checks (pull_request) Successful in 44s
A 3-node rehearsal found every settings reload refused, cluster-wide,
because one node couldn't resolve the Pyzor server:

- PyzorConfig::parse resolved the host while building the settings and
  made a failed lookup a build error. It now keeps the host and port and
  resolves when a message is checked (an IP address is used as is, a
  name is reused for five minutes, the lookup counts against the Pyzor
  timeout). A failure there is a Pyzor error for that message.
- A milter's hostname was resolved the same way, with a blocking
  to_socket_addrs in async code. An IP address is kept; a name is now
  resolved on each connection.

Other build-time I/O is already non-fatal: directories that can't
connect become unavailable with a warning (DIR-21), and the AI model
locality check only warns.

reload_registry swapped the core only when the whole build was free of
errors, while boot runs with whatever built. One failing object thus
refused every later reload, and the running settings went stale. Now a
reload is refused only for errors in objects that built when the
running settings were built (at boot or by the last applied reload):
applying it would lose those. Objects that already failed then are
missing from the running settings anyway, as at boot, so their errors
are logged and returned as known_errors but don't hold the reload back.
Refusing on new errors keeps a bad edit from taking a working object
out of service; the admin gets the error instead.

ReloadSettings now says "Settings were not reloaded." and names the
object and its error ("Tracer with id ...: Only one console tracer is
allowed"), with a count of any further errors. A refused reload after a
directory change logs its errors too.

system::reload::reload_tests (new): with Pyzor enabled on an
unresolvable host, ReloadSettings succeeds (on main it fails with
"Invalid address: failed to lookup address information"); an IP host
needs no lookup; a new build error refuses the reload, names the object
and leaves the running settings unchanged; the same error, once known
from the running settings' build, no longer blocks; once fixed, a new
error there blocks again. smtp::inbound::milter's session test now
names its milter "localhost", so the connect-time lookup is exercised.
2026-09-24 12:11:41 -07:00
jcoffey-dev 5853831bad Merge pull request 'Search: find addresses by local part, domain or name on PostgreSQL and MySQL' (#37) from fix/pg-address-search into main
ci / build (push) Failing after 3s
ci / fork-checks (push) Successful in 15s
2026-09-24 19:11:31 +00:00
jcoffey-dev 639a415a4f Search: find addresses by local part, domain or name on PostgreSQL and MySQL
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 4m20s
A 3-node PostgreSQL rehearsal found IMAP SEARCH FROM "noreply" matched
0-2 messages where RocksDB matched 23 of 930. The message indexer hands
each address and display name of From/To/Cc/Bcc to the search store as
keyword text (Language::None). The built-in index splits keyword text
into lowercase runs of alphanumerics, so an address is found by its full
form, its local part, its domain or a display-name word. The SQL
backends didn't:

- PostgreSQL's text parser keeps "[email protected]" as one email
  token (host names and URLs likewise), so neither "noreply" nor
  "amazon.com" ever matched it. Keyword text is now split the same way
  as the built-in index (SpaceTokenizer) before to_tsvector on insert
  and before plainto_tsquery/phraseto_tsquery on search, still under
  the 'simple' configuration, so the GIN index keeps serving the query.
  The sort columns keep the raw text.
- MySQL's FULLTEXT parser already splits on punctuation, but InnoDB
  never indexes its stopwords ("com", "de", "www", ...) or words under
  innodb_ft_min_token_size (3), and a required +word it hasn't indexed
  matches no row. So "amazon.com", "[email protected]" or "jane doe" found
  nothing. Those words are now matched with a word-boundary REGEXP on
  the rows the indexed words select. In language text (bodies,
  subjects) they are dropped when other words remain, and only checked
  when nothing else is left, so "the invoice" no longer finds nothing
  either.

Existing PostgreSQL search indexes hold the old single-token vectors and
need a reindex (the reindexAccounts task) before address searches find
old messages. MySQL needs none: only the query changed.

store::search_tests gains test_address_search: five messages, 28
FROM/TO/CC/BCC searches by full address, local part, domain, domain
labels, display name and hyphenated local part, plus a TEXT-style OR,
with the same expected ids on every backend. It passes on RocksDB,
SQLite, PostgreSQL and MySQL; on main it fails on PostgreSQL (From
"noreply") and MySQL (From "[email protected]").
2026-09-24 11:25:25 -07:00
jcoffey-dev 9311c1a38b Merge pull request 'Coordinator: join the cluster when NATS comes up, report the connection' (#36) from fix/coordinator-retry-and-health into main
ci / build (push) Failing after 3h0m50s
ci / fork-checks (push) Successful in 40s
2026-09-24 15:51:06 +00:00
jcoffey-dev 24be4a1b85 Merge pull request 'Publish amd64 first, then arm64, on a builder that keeps its cache' (#34) from ci/faster-publish into main
ci / fork-checks (push) Successful in 53s
ci / build (push) Canceled after 5m39s
Reviewed-on: #34
2026-09-24 15:45:26 +00:00
jcoffey-dev 95f0445d83 Coordinator: join the cluster when NATS comes up, report the connection
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 11m8s
A node that started while NATS was down never got a coordinator. The
connect failed at boot, bootstrap recorded a build error and the node ran
with Coordinator::None until restarted. It had no broadcast subscriber
or publisher, so cross-node push and cache invalidation to it stayed
broken, and its healthcheck said nothing about it. Losing NATS after
startup was silent too.

- The NATS client now connects in the background
  (retry_on_initial_connect): startup never waits on NATS or fails over
  it, the node gets its coordinator, subscriber and publisher at once,
  and the client keeps trying (async-nats's backoff, at most 4 s apart)
  until NATS answers. Subscriptions made meanwhile start delivering when
  it does. A configured maxReconnects still ends the attempts.
- Three new events report the connection: cluster.coordinator-connected
  (info), cluster.coordinator-disconnected (warn: lost, closed, gave up,
  or not connected within the connection timeout at startup) and
  cluster.coordinator-error (warn: a failed attempt, reported once per
  outage rather than every retry, and server errors, slow consumers and
  lame duck mode). They are in the packaged schema, ids 644 to 646.
- GET /healthz/cluster reports the coordinator: 200
  {"coordinator":"connected"}, 503 {"coordinator":"disconnected"}, or
  200 with "none" (no coordinator) or "unknown" (a backend that doesn't
  track its connection). /healthz/live and /healthz/ready are unchanged
  on purpose: a node without its coordinator still serves mail, and
  failing those would have orchestrators restart, or pull out of
  service, every node at once whenever NATS is down.

Only NATS connects lazily; the other coordinator backends still fail at
boot as before.

cluster::coordinator::coordinator_reconnect_tests starts a node against a
NATS port with nothing behind it, checks it boots with a coordinator and
reports it disconnected, subscribes, then starts NATS on that port: the
node connects on its own and the subscription receives a message from a
second client. Stopping and restarting NATS shows disconnected, then
connected, and the same subscription keeps working.
2026-09-24 08:38:59 -07:00
jcoffey-dev c974a0918e Merge pull request 'Task manager: release task locks on stop, recheck claims held elsewhere' (#35) from fix/task-lock-recovery into main
ci / build (push) Canceled after 6m47s
ci / fork-checks (push) Successful in 49s
2026-09-24 15:38:39 +00:00
jcoffey-dev 7c80a12d75 Task manager: release task locks on stop, recheck claims held elsewhere
ci / build (pull_request) Successful in 17m2s
ci / fork-checks (pull_request) Successful in 18s
A cluster rehearsal (PostgreSQL + NATS) left index tasks pending well
past the one-hour task lock after the node that claimed them was stopped
or killed. The exact cause there isn't confirmed; this closes every path
found in the task manager that stretches a takeover past the lock, or
keeps a task claimed without running it:

- A graceful stop never released the locks it held, so every task the
  node had claimed stayed blocked for an hour. The server now tracks the
  locks it holds (common::ipc::TaskLocks) and, once the shutdown signal
  arrives, stops claiming and releases them before exiting.
- A node that failed to claim a task (another node held it) set its own
  local hold for a full lock lifetime from that scan. If the holder
  claimed it just after the scan began, or ran on a clock ahead, that
  hold ran out a moment before the lock did and was set for another
  hour: two hours in all. Such claims are now tried again every five
  minutes (a twelfth of the lock lifetime), and the task manager wakes
  up for them: before, a node without a coordinator could sleep up to
  five minutes past the recheck, or until something else woke it.
- A worker that panicked took its task type down on that node for good,
  while the scan kept claiming that type's tasks and failing to hand them
  over, re-taking each lock as it expired and so starving every other
  node of them. Each batch now runs on a task of its own; a panic is
  logged, the batch's locks are released and the worker carries on. A
  failed hand-over releases the lock too.
- A claimed task the worker couldn't read, or found gone, kept its lock
  for the hour. It is released.
- An IndexDocument task for a file (not indexed) returned no result,
  which shifted every later result in the batch onto the wrong task in
  update_tasks. It returns Ignored. Nothing queues such a task today.

The lock lifetime stays one hour; it now lives per server so the tests
can shorten it.

store::task_locks::task_lock_tests plays a second node by writing its
locks straight into the in-memory store: tasks it claimed and abandoned
run here once its locks expire, including locks that outlive this node's
view of them, and a graceful stop hands this node's locks back at once
and claims nothing more. It passes on RocksDB, SQLite and PostgreSQL.
With the old recheck it fails.
2026-09-24 08:19:05 -07:00
jcoffey-dev 22d8ad8572 Publish amd64 first, then arm64, on a builder that keeps its cache
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 4m30s
Two release builds side by side on one machine each take twice as long,
and production only needs amd64. publish-amd64 now pushes :<version> as
soon as the amd64 build is done; publish-arm64 builds arm64 afterwards,
then replaces :<version> with the two-platform index and moves :latest.

Both jobs use one named BuildKit builder whose container outlives the
job, so the dependency layer (cargo chef cook) is reused until the
dependencies change. The release is created after amd64; the binaries
are attached once arm64 is in.
2026-09-24 08:04:53 -07:00
jcoffey-dev 7109e67f07 Merge pull request 'Trace search: index event type and queue id as integers' (#33) from fix/pg-index-trace-types into main
ci / fork-checks (push) Successful in 30s
ci / build (push) Successful in 37m5s
2026-09-24 14:57:18 +00:00
jcoffey-dev 52b5a5f909 Mark tests/src/store/query.rs as modified by the fork
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 4m20s
The trace document test changed an upstream file, so it carries the
AGPL section 5(a) notice (tools/fork/notice-check.py).
2026-09-24 07:39:04 -07:00
jcoffey-dev 9232662913 Trace search: index event type and queue id as integers
ci / fork-checks (pull_request) Failing after 47s
ci / build (pull_request) Successful in 4m55s
The trace index task wrote the event type (its name) and the queue id as
text, but the tracing search index types both as integers on every
backend: BIGINT on PostgreSQL and MySQL, long on Elasticsearch. On
PostgreSQL every batch holding a trace document failed with "cannot
convert between the Rust type String and the Postgres type int8", and
since a batch writes trace and email documents together, email indexing
stalled behind it.

The document is now built by trace_search_document(), which writes:

- the event type as the opening event's numeric id, the event
  x:Trace/query's event filter already matches on;
- the queue id as an integer, the first one the trace names;
- every queue id into the keywords as well, since the column holds one
  value and an SMTP session can queue several messages.

index_keyword() replaced the field on every call, so before this only the
last event type and queue id survived anyway.

x:Trace/query's queueId filter parses the id (a string, or now a number)
and matches the column or the keywords, so a session is found by any of
its queue ids on every backend. The monitoring spec says what is indexed.

Traces indexed before this on the built-in index keep their text values;
the reindexTelemetry maintenance task rebuilds them.

Tests: the search store suite builds trace documents with the index
task's code, indexes them and finds them by queue id, event type and
keyword (Sqlite, PostgreSQL, MySQL); the monitoring suite finds a real
trace by queueId through x:Trace/query.
2026-09-24 07:29:08 -07:00
jcoffey-dev 57d1c5b074 Merge pull request 'Broadcast subscriber: fix the inverted subscribe retry backoff' (#32) from fix/subscriber-backoff into main
ci / fork-checks (push) Successful in 43s
ci / build (push) Canceled after 30m24s
2026-09-24 14:26:52 +00:00
jcoffey-dev ca3abf40f0 Broadcast subscriber: fix the inverted subscribe retry backoff
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 5m14s
The broadcast subscriber waited 1 << retry_count.max(6) seconds between
failed subscribe attempts. max(6) turns the cap into a floor: the first
retry waited 64 s instead of 1 s, and each later one doubled without a
bound (and would overflow the shift after enough failures).

The delay now comes from subscribe_retry_delay(), 1 s, 2 s, 4 s ... capped
at 64 s, and the retry counter saturates. A unit test pins the schedule
and the top of the range.
2026-09-24 07:01:56 -07:00
jcoffey-dev 499e4d7810 Merge pull request 'Export/import: keep archived items, spam samples and the spam model' (#31) from fix/export-all-subspaces into main
ci / fork-checks (push) Successful in 3m24s
ci / build (push) Successful in 43m50s
2026-09-23 08:50:16 +00:00
jcoffey-dev 212cd77cd3 Export/import: keep archived items, spam samples and the spam model
ci / fork-checks (pull_request) Successful in 32s
ci / build (pull_request) Successful in 7m57s
--export skipped three things, so a move from one database to another
(RocksDB to PostgreSQL, say) lost them without a word:

- archived items (subspace j), the records behind undelete;
- spam training samples (subspace w);
- the trained spam classifier and its trainer state, blobs stored under
  fixed names that no blob link points at, so the walk over links never
  reached them.

j and w now travel with the registry family, where their indexes and id
counters already were, so EXPORT_TYPES=registry keeps them consistent.
The two named blobs travel with the blob family. The file format is
unchanged and import reads any subspace it is given, so an export made
by an older binary still imports.

The full-text index (subspace z) stays out, on purpose. It belongs to one
search backend: PostgreSQL and MySQL index into their own tables and have
no z table at all, and external engines keep the index themselves. So
--import now returns the subspaces it wrote, and boot queues the
reindexAccounts and reindexTelemetry store maintenance tasks, the same
ones an administrator can queue by hand, to rebuild the index for
whichever search store the server runs with once it starts.

The round trip also turned up a loss in import itself: the SQL stores
add a negative amount with an UPDATE, which does nothing to a row that
isn't there yet, so every negative counter or quota vanished on import
into PostgreSQL, MySQL or SQLite. Import now creates the row first.

The in-memory subspaces (m, y) stay out: rate limits, locks, greylisting,
ACME challenge tokens and OAuth codes, all short-lived. Issued
certificates are registry objects and travel.

The store test now writes archived items, spam samples, directory
entries, the fork's own subspace and the named blobs, checks they come
back in place, then imports the same export into a fresh store of the
other local backend (RocksDB to SQLite, or SQLite to RocksDB), compares
it key for key and counter for counter, and checks the queued reindex.
It fails on the old export code ("Subspace j was not exported").
--help now says what an export holds.
2026-09-23 01:41:28 -07:00
jcoffey-dev 7735780807 Merge pull request 'Image build: put the vendored crate where cargo chef cooks; release 2026.9.24.3' (#30) from fix/image-vendor-before-cook into main
ci / fork-checks (push) Successful in 41s
publish / version (push) Successful in 39s
ci / build (push) Successful in 36m33s
publish / publish (push) Successful in 1h2m24s
publish / release (push) Successful in 2s
publish / binaries (push) Successful in 1m8s
Reviewed-on: #30
2026-09-23 06:10:46 +00:00
jcoffey-dev e223f7d327 Release 2026.9.24.3
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 4m26s
2026.9.24.3 is 2026.9.24.2 plus the image build fix; 2026.9.24.2's tag never
published an image. Everything in 2026.9.24.2's notes applies.
2026-09-22 23:04:59 -07:00
jcoffey-dev 30df055e39 Image build: put the vendored crate where cargo chef cooks
#27 let the build context see vendor/, but the Dockerfile cooks the
dependencies before it copies the tree, from a recipe that carries only the
workspace's manifests. [patch.crates-io] points sieve-rs at vendor/, so the
cook failed the same way: failed to read /build/vendor/sieve-rs/Cargo.toml.
That's why 2026.9.24.2's publish failed. The builder stage now copies
vendor/ before cooking; a local build got past it into compiling the
dependencies.

context-check.py now also checks that each patched path is copied into the
cooking stage before the cook, and fails on the Dockerfile as it was.
2026-09-22 23:04:50 -07:00
jcoffey-dev 5393c4405a Merge pull request 'Release 2026.9.24.2' (#29) from release/2026.9.24.2 into main
ci / fork-checks (push) Successful in 50s
publish / version (push) Successful in 24s
publish / publish (push) Failing after 2m38s
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / build (push) Successful in 22m35s
Reviewed-on: #29
2026-09-23 05:47:10 +00:00
jcoffey-dev cc532b914c Release 2026.9.24.2
ci / fork-checks (pull_request) Successful in 1m49s
ci / build (pull_request) Successful in 4m8s
Replaces 2026.9.24, whose tag predates the image build fix (#27) and never
published. Carries everything 2026.9.24 did -- upstream 0.16.23 and its
fixes, the scim release-profile fix -- and since then:

- identifiers renamed from the upstream name, with no aliases: the JMAP
  registry capability is urn:inbuxa:jmap:registry, WebDAV tokens
  urn:inbuxa:dav*, Sieve extensions vnd.inbuxa.*, the web interface client
  inbuxa-webui; INBUXA_* settings only. Deploy with admin and webmail
  releases that use the new names.
- the brand in lowercase where people see it.
- the spam filter rules bundled with the server; on first start they add
  the AI classifier's LLM_* scores.
- a Local AI page link in Settings › Spam Filter, for the admin release
  that draws it.
- two start-up migrations: the spam model moves to its renamed keys, and
  the web interface's old OAuth client is retired.
2026-09-22 22:40:17 -07:00
jcoffey-dev c09eff2214 Merge pull request 'Bundle the spam filter rules with the server, and link the Local AI page' (#28) from fork/bundled-spam-rules into main
ci / fork-checks (push) Successful in 20s
ci / build (push) Canceled after 7m16s
Reviewed-on: #28
2026-09-23 05:39:50 +00:00
jcoffey-dev eba4c7a32e Settings › Spam Filter gains Local AI, the AI spam filtering setup page
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 4m23s
Adds a link to CustomComponent/LocalAi in the packaged schema's Settings ›
Spam Filter, above LLM Classifier, and updates the schema hash so admins
fetch the new layout rather than a cached one.

INBUXA Admin draws the page (feature/local-ai-setup); this makes it
reachable. An admin from before that page would show "Unknown component"
here, so this lands after the admin release that carries it.
2026-09-22 22:08:50 -07:00
jcoffey-dev 17426f6d60 Bundle the spam filter rules with the server
The server fetched upstream's latest published rules from GitHub at run
time: a version nobody here tested, code-like expressions from an account
we don't control, and the upstream name as a default in the admin form.

The published rules of spam-filter v3.0.2 are now embedded
(resources/spam-filter/, MIT, in THIRD-PARTY.md) and used whenever no other
source is configured. An empty setting and upstream's old default both mean
the bundled rules, so existing installs switch without a settings change;
the URL stays an operator override (https:// or file://). The schema default
is dropped and its description says what empty means, and the strip's
rename pass does the same to each import.

Rules load on first boot as before, and again whenever the bundled version
differs from the last one loaded, which only adds missing rules and tags.
That brings the AI classifier's LLM_* scores to installs that predate them:
production has none today.

upstream-watch now also opens an issue when spam-filter publishes a newer
release; resources/spam-filter/README.md says how to take it.

The antispam test now runs on the bundled rules, the path production
takes; SPAM_RULES_URL tests another set. Unit tests cover the URL handling
and that the bundled rules parse and score the AI tags as the AI spec says.
2026-09-22 22:01:30 -07:00
jcoffey-dev d7c9416713 Merge pull request 'Let the image build see the dependency Cargo patches' (#27) from fix/vendor-in-build-context into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Successful in 31m11s
2026-09-23 04:48:35 +00:00
jcoffey-dev 238079da66 Let the image build see the dependency Cargo patches
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 4m22s
The rename pass vendored a patched sieve-rs and pointed Cargo.toml's
[patch.crates-io] at vendor/sieve-rs. .dockerignore ignores everything and
re-includes a short list that did not have vendor on it, so the image build
had no such directory and stopped at

    failed to load source for dependency `sieve-rs`
    failed to read /build/vendor/sieve-rs/Cargo.toml

CI could not have caught that: it builds from a checkout, where the
directory is simply there, and only the image build has a context to prune.
The first that was known about it was a tag that had already been pushed.

So: vendor is re-included, and tools/fork/context-check.py now asserts the
thing that was quietly assumed -- every path a [patch] section names exists
and survives .dockerignore. It runs beside the other fork checks and takes
no toolchain.

Also, the comments in .dockerignore started with // , which Docker does not
read as a comment: they were patterns that happened to match nothing. They
are # now.
2026-09-22 21:43:37 -07:00
107 changed files with 7644 additions and 1322 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
// Ignore everything # Ignore everything
* *
// Allow what is needed # Allow what is needed
!crates !crates
!tests !tests
!resources !resources
# The patched dependency Cargo.toml's [patch.crates-io] points at. Without
# it the build context has no vendor/, and `cargo chef cook` fails on
# "failed to load source for dependency sieve-rs" -- which CI cannot see,
# because CI builds from a checkout and only the image build has a context.
!vendor
!Cargo.lock !Cargo.lock
!Cargo.toml !Cargo.toml
+5
View File
@@ -35,6 +35,11 @@ jobs:
- run: python3 tools/fork/name-check.py - run: python3 tools/fork/name-check.py
- if: always() - if: always()
run: python3 tools/fork/notice-check.py run: python3 tools/fork/notice-check.py
# Cargo can patch a dependency to a directory in this repository, and
# the image builds from a context .dockerignore prunes to almost
# nothing. CI never sees the difference; a release does.
- if: always()
run: python3 tools/fork/context-check.py
build: build:
# Either runner (host1 or host2): the build needs no docker socket. # Either runner (host1 or host2): the build needs no docker socket.
+81 -19
View File
@@ -3,19 +3,39 @@
# whether a person pushed it or weekly-release.yml created it through the # whether a person pushed it or weekly-release.yml created it through the
# releases API. # releases API.
# #
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in # The image is multi-arch (linux/amd64, linux/arm64), built by two jobs on
# one buildx run on host1 instead of one native runner per architecture: the # the image-build runner rather than one buildx run for both. The Dockerfile's
# Dockerfile's builder stage runs on the build platform and cross-compiles # builder stage runs on the build platform and cross-compiles with an aarch64
# with an aarch64 linker, so only the small final stage (apt, setcap) goes # linker, so only the small final stage (apt, setcap) goes through QEMU for
# through QEMU for arm64. No digest-joining job is needed. # arm64 -- but two release builds (LTO, one codegen unit) side by side on one
# machine each take twice as long. Production runs amd64, so amd64 goes first
# and on its own:
# * publish-amd64 pushes :<version>-amd64 and :<version>, a plain amd64
# image, as soon as its build is done. A deploy can start from it.
# * publish-arm64 then builds arm64, pushes :<version>-arm64, and replaces
# :<version> with the two-platform index. :latest moves only here, so it
# never names an image without arm64.
#
# Both jobs use one BuildKit builder, `gitea-builder`, whose container
# (buildx_buildkit_gitea-builder0) and state volume stay on the runner's host
# between jobs: a job container's `buildx create` finds the existing container
# and reuses it and its cache. The dependency build (`cargo chef cook`) is
# keyed on the recipe, which only a dependency change alters, so a release
# normally compiles just the workspace. Removing that container or its volume
# costs the next release a cold build, nothing more. The planner and dependency
# layers for the build platform are shared, so arm64 also reuses what amd64
# just did where it can.
# #
# Two guards before anything is pushed: # Two guards before anything is pushed:
# * the tag must be v<brand_version!>. The version is a string in # * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged # crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that # with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag. # reports a different version from its tag.
# * the tag must be on main, so an image never describes code that was never # * the tag must be on main or on a release/* branch, so an image never
# reviewed onto the default branch. # describes code that was never reviewed onto one of them. A release/*
# branch carries a hotfix: it starts at an earlier release tag, takes
# fixes through pull requests into it, and is tagged there, so production
# can get a fix without everything that has landed on main since.
# #
# :latest moves with every published tag: tags are cut by the weekly release # :latest moves with every published tag: tags are cut by the weekly release
# (or by hand for a real release); there are no prerelease tags here. # (or by hand for a real release); there are no prerelease tags here.
@@ -57,12 +77,17 @@ jobs:
echo "Refusing to publish an image that would report the wrong version." >&2 echo "Refusing to publish an image that would report the wrong version." >&2
exit 1 exit 1
fi fi
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \ commit="$(git rev-parse "${TAG}^{commit}")"
|| { echo "$TAG is not on main" >&2; exit 1; } on=""
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
done
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
echo "$TAG is on $on"
echo "version=$V" >> "$GITHUB_OUTPUT" echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V" echo "version $V"
publish: publish-amd64:
needs: [version] needs: [version]
runs-on: docker runs-on: docker
container: container:
@@ -81,16 +106,15 @@ jobs:
test -n "$REGISTRY" && test -n "$VERSION" test -n "$REGISTRY" && test -n "$VERSION"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; } test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY" echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# Attestations off, as before: they add manifests of their own to the # Attestations off, as before: they add manifests of their own, and the
# index, and the index should hold the two images and nothing else. # index should hold the two images and nothing else.
- run: | - run: |
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64 \
--provenance=false --sbom=false \ --provenance=false --sbom=false \
--tag "$IMAGE:$VERSION-amd64" \
--tag "$IMAGE:$VERSION" \ --tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
--push . --push .
docker buildx imagetools inspect "$IMAGE:$VERSION" docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on # Gitea keeps a container package on its owner; linking it shows it on
@@ -103,11 +127,47 @@ jobs:
- if: always() - if: always()
run: docker logout "$REGISTRY" || true run: docker logout "$REGISTRY" || true
publish-arm64:
needs: [version, publish-amd64]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# The index is built from the two per-architecture tags rather than from
# :<version>, which by now is the amd64 image and would be read as such.
- run: |
docker buildx build \
--platform linux/arm64 \
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION-arm64" \
--push .
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
- if: always()
run: docker logout "$REGISTRY" || true
# The weekly release creates its Release (and so the tag) first; a tag # The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one # pushed by hand has none. Either way the tag ends up with exactly one
# Release, created after the image exists so its pull instructions work. # Release, created once the amd64 image exists so its pull instructions
# work; arm64 and the binaries follow.
release: release:
needs: [version, publish] needs: [version, publish-amd64]
runs-on: light runs-on: light
container: container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -131,7 +191,9 @@ jobs:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code != 404: raise if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}" image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`.\n\n" body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`. "
"amd64 is published first; arm64 is added to the same tag when its build "
"finishes, and `:latest` moves then.\n\n"
"Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and " "Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and "
"`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this " "`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this "
"release's image for that architecture, so it is the same build. The image " "release's image for that architecture, so it is the same build. The image "
@@ -154,7 +216,7 @@ jobs:
# `docker create` does not start anything, so pulling an arm64 image on an # `docker create` does not start anything, so pulling an arm64 image on an
# amd64 runner and copying a file out of it needs no emulation. # amd64 runner and copying a file out of it needs no emulation.
binaries: binaries:
needs: [version, publish, release] needs: [version, publish-arm64, release]
runs-on: docker runs-on: docker
container: container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
+35 -1
View File
@@ -12,6 +12,9 @@
# An issue is opened once per release: an existing one with the same title, # An issue is opened once per release: an existing one with the same title,
# open or closed, stops a second. # open or closed, stops a second.
# #
# It also watches spam-filter, whose rules the server bundles
# (resources/spam-filter/), and opens an issue for a newer release.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch. # Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: upstream-watch name: upstream-watch
@@ -64,7 +67,7 @@ jobs:
and key(r["tag_name"]) > key(base)), and key(r["tag_name"]) > key(base)),
key=lambda r: key(r["tag_name"])) key=lambda r: key(r["tag_name"]))
if not newer: if not newer:
print(f"Up to date: {base} is the newest upstream release."); sys.exit(0) print(f"Up to date: {base} is the newest upstream release.")
# Titles and bodies stay free of the upstream project's name, as the # Titles and bodies stay free of the upstream project's name, as the
# rest of the fork's user-visible text does. # rest of the fork's user-visible text does.
@@ -85,4 +88,35 @@ jobs:
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.") "add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body}) issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"{tag}: opened #{issue['number']}.") print(f"{tag}: opened #{issue['number']}.")
# The spam filter rules bundled with the server (resources/spam-filter/):
# an issue when spam-filter publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main.
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
import base64
text = base64.b64decode(src["content"]).decode()
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
if not m:
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
bundled = "v" + m.group(1)
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(bundled)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
latest = newer[-1]
tag = latest["tag_name"]
title = f"Update the bundled spam rules to {tag}"
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
if title in existing:
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
f"The server bundles {bundled}.\n\n"
"Update it as resources/spam-filter/README.md describes: take the rules file "
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
"and run the antispam test.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"spam rules {tag}: opened #{issue['number']}.")
PY PY
+4
View File
@@ -19,6 +19,10 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
RUN rustup target add "$(cat /target.txt)" RUN rustup target add "$(cat /target.txt)"
COPY --from=planner /recipe.json /recipe.json COPY --from=planner /recipe.json /recipe.json
# inbuxa: [patch.crates-io] points sieve-rs at vendor/, and the recipe only
# carries the workspace's own manifests, so cooking the dependencies needs the
# vendored crate itself (the context allows it since #27; this puts it here).
COPY vendor/ vendor/
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
COPY . . COPY . .
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
+1 -1
View File
@@ -24,7 +24,7 @@ carry their own license files.
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann | | `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> | | `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker | | `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
| `tests/resources/smtp/antispam/spam-filter-rules.json.gz` | the published rules of [spam-filter](https://github.com/stalwartlabs/spam-filter) v3.0.2, unmodified, for the spam filter's tests (MIT or Apache-2.0) | Copyright (C) 2024, Stalwart Labs LLC | | `resources/spam-filter/spam-filter-rules.json.gz` | the published rules of [spam-filter](https://github.com/stalwartlabs/spam-filter) v3.0.2, unmodified, built into the server as its default spam rules (MIT or Apache-2.0) | Copyright (C) 2024, Stalwart Labs LLC |
Each notice above applies with this permission notice: Each notice above applies with this permission notice:
+289 -29
View File
@@ -13,20 +13,27 @@ use crate::{
storage::Storage, storage::Storage,
telemetry::Telemetry, telemetry::Telemetry,
}, },
ipc::{QueueEvent, RegistryChange}, ipc::{BroadcastEvent, QueueEvent, RegistryChange},
network::security::{BlockedIps, IpWithTtl}, network::security::{BlockedIps, IpWithTtl},
}; };
use ahash::AHashMap; use ahash::AHashMap;
use directory::Directories; use directory::Directories;
use registry::{ use registry::{
schema::{prelude::ObjectType, structs::BlockedIp}, schema::{prelude::ObjectType, structs::BlockedIp},
types::error::{Error, Warning}, types::{
error::{Error, Warning},
id::ObjectId,
},
}; };
use std::sync::Arc; use std::sync::Arc;
use store::{LookupStores, registry::bootstrap::Bootstrap, write::now}; use store::{LookupStores, registry::bootstrap::Bootstrap, write::now};
pub struct ReloadResult { pub struct ReloadResult {
/// Errors that kept the reload from being applied.
pub errors: Vec<Error>, pub errors: Vec<Error>,
/// inbuxa: errors in objects that already failed when the running
/// settings were built; logged, but they don't refuse a reload.
pub known_errors: Vec<Error>,
pub warnings: Vec<Warning>, pub warnings: Vec<Warning>,
pub replaced_core: bool, pub replaced_core: bool,
} }
@@ -114,42 +121,66 @@ impl Server {
directories: directory.directories, directories: directory.directories,
}; };
// Parse tracers // inbuxa: upstream swapped the core only when the whole build
// was free of errors, while boot runs with whatever built. So one
// object that failed (a DNS lookup that timed out, say) refused
// every later reload, cluster-wide when the reload came from
// ReloadSettings, and the running settings went stale. Now a
// reload is refused only for errors in objects that built when
// the running settings were built: those would be lost by
// applying it. Objects that already failed then are missing
// from the running settings anyway, as at boot, so their
// errors are reported but don't hold the reload back.
let tracers = Telemetry::parse(&mut bootstrap, &storage).await; let tracers = Telemetry::parse(&mut bootstrap, &storage).await;
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
let mut servers = Listeners::parse(&mut bootstrap).await;
if bootstrap.errors.is_empty() { if !self.has_new_build_errors(&bootstrap.errors) {
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await; servers
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
.await;
if bootstrap.errors.is_empty() { if !self.has_new_build_errors(&bootstrap.errors) {
let mut servers = Listeners::parse(&mut bootstrap).await; // Update core
servers self.inner.shared_core.store(core.into());
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
.await;
if bootstrap.errors.is_empty() { // Update tracers
// Update core tracers.update();
self.inner.shared_core.store(core.into());
// Update tracers // Reload queue settings
self.inner
.ipc
.queue_tx
.send(QueueEvent::ReloadSettings)
.await
.ok();
tracers.update(); // inbuxa: the task manager reads the node's role on
// every scan; scan now, so a role that gained task
// types starts claiming them without waiting out the
// refresh interval
self.inner.ipc.task_tx.notify_one();
// Reload queue settings self.record_build_errors(&bootstrap.errors);
self.inner
.ipc
.queue_tx
.send(QueueEvent::ReloadSettings)
.await
.ok();
return Ok(ReloadResult { return Ok(ReloadResult {
errors: bootstrap.errors, errors: Vec::new(),
warnings: bootstrap.warnings, known_errors: bootstrap.errors,
replaced_core: true, warnings: bootstrap.warnings,
}); replaced_core: true,
} });
} }
} }
let (known_errors, errors) = std::mem::take(&mut bootstrap.errors)
.into_iter()
.partition(|error| self.is_known_build_error(error));
return Ok(ReloadResult {
errors,
known_errors,
warnings: bootstrap.warnings,
replaced_core: false,
});
} }
} }
@@ -163,7 +194,7 @@ impl ReloadResult {
} }
pub fn log(&self) { pub fn log(&self) {
for error in &self.errors { for error in self.errors.iter().chain(&self.known_errors) {
error.log(); error.log();
} }
for warning in &self.warnings { for warning in &self.warnings {
@@ -176,8 +207,237 @@ impl From<Bootstrap> for ReloadResult {
fn from(bootstrap: Bootstrap) -> Self { fn from(bootstrap: Bootstrap) -> Self {
Self { Self {
errors: bootstrap.errors, errors: bootstrap.errors,
known_errors: Vec::new(),
warnings: bootstrap.warnings, warnings: bootstrap.warnings,
replaced_core: false, replaced_core: false,
} }
} }
} }
// inbuxa: which objects failed to build for the running settings
impl Server {
/// Records the objects that failed to build for the settings now running.
pub fn record_build_errors(&self, errors: &[Error]) {
*self.inner.data.build_errors.lock() = errors.iter().filter_map(error_object).collect();
}
fn is_known_build_error(&self, error: &Error) -> bool {
error_object(error).is_some_and(|id| self.inner.data.build_errors.lock().contains(&id))
}
fn has_new_build_errors(&self, errors: &[Error]) -> bool {
errors.iter().any(|error| !self.is_known_build_error(error))
}
}
fn error_object(error: &Error) -> Option<ObjectId> {
match error {
Error::Validation { object_id, .. }
| Error::Build { object_id, .. }
| Error::NotFound { object_id } => Some(*object_id),
Error::Internal { object_id, .. } => *object_id,
}
}
// inbuxa: upstream applied a registry write to the running settings only on
// an explicit x:Action ReloadSettings (Directory and Authentication aside), so
// a new MtaDeliverySchedule, say, stayed unknown ("Queue strategy not found")
// until someone reloaded. Writes to objects the settings are built from now
// reload them, here and across the cluster, as ReloadSettings does.
/// Coalesces the full reloads that registry writes trigger: a write waits for
/// a reload that started after it was stored, and joins one if it can, so a
/// burst of writes costs a reload or two rather than one each.
#[derive(Default)]
pub struct SettingsReloadGate {
requested: std::sync::atomic::AtomicU64,
state: tokio::sync::Mutex<SettingsReloadState>,
}
#[derive(Default)]
struct SettingsReloadState {
completed: u64,
refused: Option<String>,
}
/// The reload a write to `object` calls for: the object to reload, or None
/// when the running settings don't hold that object (accounts, domains and
/// other data read as needed, stores, which take a restart, and objects with
/// reload actions of their own, such as applications). Blocked IPs have a
/// reload of their own; allowed IPs take the full one.
pub fn write_reload_target(object: ObjectType) -> Option<ObjectType> {
match object {
ObjectType::Certificate => Some(ObjectType::Certificate),
ObjectType::MemoryLookupKey
| ObjectType::MemoryLookupKeyValue
| ObjectType::HttpLookup
| ObjectType::StoreLookup => Some(ObjectType::StoreLookup),
ObjectType::BlockedIp => Some(ObjectType::BlockedIp),
// Allowed IPs are part of the core's security settings
// (Security::parse), which only a full reload rebuilds; the blocked-IP
// reload doesn't touch them
ObjectType::AllowedIp
| ObjectType::AcmeProvider
| ObjectType::AddressBook
| ObjectType::AiModel
| ObjectType::Asn
| ObjectType::Authentication
| ObjectType::Cache
| ObjectType::Calendar
| ObjectType::CalendarAlarm
| ObjectType::CalendarScheduling
| ObjectType::ClusterRole
| ObjectType::DataRetention
| ObjectType::Directory
| ObjectType::DkimReportSettings
| ObjectType::DmarcReportSettings
| ObjectType::DnsResolver
| ObjectType::DsnReportSettings
| ObjectType::Email
| ObjectType::EventTracingLevel
| ObjectType::FileStorage
| ObjectType::Http
| ObjectType::HttpForm
| ObjectType::Imap
| ObjectType::Jmap
| ObjectType::Metrics
| ObjectType::MtaConnectionStrategy
| ObjectType::MtaDeliverySchedule
| ObjectType::MtaExtensions
| ObjectType::MtaHook
| ObjectType::MtaInboundSession
| ObjectType::MtaInboundThrottle
| ObjectType::MtaMilter
| ObjectType::MtaOutboundStrategy
| ObjectType::MtaOutboundThrottle
| ObjectType::MtaQueueQuota
| ObjectType::MtaRoute
| ObjectType::MtaStageAuth
| ObjectType::MtaStageConnect
| ObjectType::MtaStageData
| ObjectType::MtaStageEhlo
| ObjectType::MtaStageMail
| ObjectType::MtaStageRcpt
| ObjectType::MtaSts
| ObjectType::MtaTlsStrategy
| ObjectType::MtaVirtualQueue
| ObjectType::NetworkListener
| ObjectType::OidcProvider
| ObjectType::ReportSettings
| ObjectType::Search
| ObjectType::Security
| ObjectType::SenderAuth
| ObjectType::Sharing
| ObjectType::SieveSystemInterpreter
| ObjectType::SieveSystemScript
| ObjectType::SieveUserInterpreter
| ObjectType::SieveUserScript
| ObjectType::SpamClassifier
| ObjectType::SpamDnsblServer
| ObjectType::SpamDnsblSettings
| ObjectType::SpamFileExtension
| ObjectType::SpamPyzor
| ObjectType::SpamRule
| ObjectType::SpamSettings
| ObjectType::SpamTag
| ObjectType::SpfReportSettings
| ObjectType::SystemSettings
| ObjectType::TaskManager
| ObjectType::TlsReportSettings
| ObjectType::Tracer
| ObjectType::WebDav
| ObjectType::WebHook => Some(object),
_ => None,
}
}
impl Server {
/// Applies a stored registry write to `object` to the running settings,
/// and on success tells the other nodes to do the same. Returns None when
/// the write needs no reload, Some(Ok(())) when it was applied, and
/// Some(Err(reason)) when the reload was refused (the write stays stored;
/// ReloadSettings reports the same errors).
pub async fn reload_after_write(&self, object: ObjectType) -> Option<Result<(), String>> {
let target = write_reload_target(object)?;
let change = RegistryChange::Reload(target);
if matches!(
target,
ObjectType::Certificate | ObjectType::StoreLookup | ObjectType::BlockedIp
) {
// Cheap, and limited to their own objects
let result = self.reload_and_broadcast(change).await;
return Some(result);
}
let gate = &self.inner.data.settings_reload;
let ticket = gate
.requested
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
let mut state = gate.state.lock().await;
if state.completed >= ticket {
// A reload that started after this write was stored has run
return Some(state.refused.clone().map_or(Ok(()), Err));
}
let covers = gate.requested.load(std::sync::atomic::Ordering::SeqCst);
let result = self.reload_and_broadcast(change).await;
state.completed = covers;
state.refused = result.clone().err();
Some(result)
}
async fn reload_and_broadcast(&self, change: RegistryChange) -> Result<(), String> {
match Box::pin(self.reload_registry(change)).await {
Ok(reload) if !reload.has_errors() => {
reload.log();
self.cluster_broadcast(BroadcastEvent::RegistryChange(change))
.await;
Ok(())
}
Ok(reload) => {
reload.log();
let reason = describe_reload_errors(&reload.errors);
trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = "Settings didn't reload after a registry write",
Reason = reason.clone(),
);
Err(reason)
}
Err(err) => {
let reason = err.to_string();
trc::error!(err.details("Failed to reload settings after a registry write"));
Err(reason)
}
}
}
}
/// inbuxa: a refused reload's errors in a sentence: the first one, naming its
/// object, and how many more there are.
pub fn describe_reload_errors(errors: &[Error]) -> String {
let mut description = match errors.first() {
Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"),
Some(Error::Validation { object_id, errors }) => format!(
"{object_id}: {}",
errors
.iter()
.map(|err| err.to_string())
.collect::<Vec<_>>()
.join("; ")
),
Some(Error::Internal {
object_id: Some(object_id),
error,
}) => format!("{object_id}: {error}"),
Some(Error::Internal { error, .. }) => error.to_string(),
Some(Error::NotFound { object_id }) => format!("{object_id} was not found"),
None => String::new(),
};
let more = errors.len().saturating_sub(1);
if more > 0 {
description.push_str(&format!(" ({more} more in the server log.)"));
}
description
}
+6
View File
@@ -93,9 +93,12 @@ impl Data {
registry_id_gen: id_generator.clone(), registry_id_gen: id_generator.clone(),
span_id_gen: id_generator, span_id_gen: id_generator,
queue_status: true.into(), queue_status: true.into(),
settings_reload: Default::default(),
store_health: Default::default(),
applications, applications,
logos: Default::default(), logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"), smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
build_errors: Default::default(),
asn_geo_data: Default::default(), asn_geo_data: Default::default(),
} }
} }
@@ -234,9 +237,12 @@ impl Default for Data {
span_id_gen: Default::default(), span_id_gen: Default::default(),
registry_id_gen: Default::default(), registry_id_gen: Default::default(),
queue_status: true.into(), queue_status: true.into(),
settings_reload: Default::default(),
store_health: Default::default(),
applications: WebApplications::new(), applications: WebApplications::new(),
logos: Default::default(), logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().unwrap(), smtp_connectors: TlsConnectors::try_new().unwrap(),
build_errors: Default::default(),
asn_geo_data: Default::default(), asn_geo_data: Default::default(),
lookup_stores: Default::default(), lookup_stores: Default::default(),
} }
@@ -16,7 +16,6 @@ use mail_auth::common::resolver::ToReverseName;
use nlp::classifier::model::{CcfhClassifier, FhClassifier}; use nlp::classifier::model::{CcfhClassifier, FhClassifier};
use registry::schema::{ use registry::schema::{
enums::{ExpressionVariable, ModelSize}, enums::{ExpressionVariable, ModelSize},
prelude::ObjectType,
structs::{ structs::{
self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule, self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule,
SpamSettings, SpamTag, SpamSettings, SpamTag,
@@ -25,10 +24,10 @@ use registry::schema::{
use sieve::SpamStatus; use sieve::SpamStatus;
use std::{ use std::{
net::{IpAddr, SocketAddr}, net::{IpAddr, SocketAddr},
time::Duration, sync::Arc,
time::{Duration, Instant},
}; };
use store::registry::{RegistryObject, bootstrap::Bootstrap}; use store::registry::{RegistryObject, bootstrap::Bootstrap};
use tokio::net::lookup_host;
use utils::{cache::CacheItemWeight, glob::GlobMap}; use utils::{cache::CacheItemWeight, glob::GlobMap};
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)]
@@ -157,7 +156,11 @@ pub struct FtrlParameters {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PyzorConfig { pub struct PyzorConfig {
pub address: SocketAddr, // inbuxa: the server is resolved when a message is checked, not while the
// settings are built (see PyzorConfig::address)
pub host: String,
pub port: u16,
pub resolved: Arc<parking_lot::Mutex<Option<(SocketAddr, Instant)>>>,
pub timeout: Duration, pub timeout: Duration,
pub min_count: u64, pub min_count: u64,
pub min_wl_count: u64, pub min_wl_count: u64,
@@ -243,7 +246,8 @@ impl SpamFilterConfig {
spam_threshold: spam.score_spam.into_inner() as f32, spam_threshold: spam.score_spam.into_inner() as f32,
}, },
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
spam_rules_url: spam.spam_filter_rules_url, // inbuxa: unset, empty or upstream's old default means the bundled rules
spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url),
url_client: utils::http::http_client_builder(true) url_client: utils::http::http_client_builder(true)
.pool_max_idle_per_host(0) .pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
@@ -473,31 +477,15 @@ impl PyzorConfig {
return None; return None;
} }
let port = pyzor.port; // inbuxa: upstream resolved the host here and reported a failed lookup
let host = pyzor.host; // as a build error, so a DNS hiccup on one node refused every settings
let address = match lookup_host(format!("{host}:{port}")) // reload on it (and, from the node that ran ReloadSettings, across the
.await // cluster). The lookup now happens when a message is checked; a
.map(|mut a| a.next()) // failure there is logged as a Pyzor error for that message.
{
Ok(Some(address)) => address,
Ok(None) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
"Invalid address: No addresses found.",
);
return None;
}
Err(err) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
format!("Invalid address: {}", err),
);
return None;
}
};
PyzorConfig { PyzorConfig {
address, host: pyzor.host,
port: pyzor.port as u16,
resolved: Default::default(),
timeout: pyzor.timeout.into_inner(), timeout: pyzor.timeout.into_inner(),
min_count: pyzor.block_count, min_count: pyzor.block_count,
min_wl_count: pyzor.allow_count, min_wl_count: pyzor.allow_count,
@@ -507,6 +495,35 @@ impl PyzorConfig {
} }
} }
// inbuxa: how long a resolved Pyzor address is reused
const PYZOR_RESOLVE_TTL: Duration = Duration::from_secs(300);
impl PyzorConfig {
/// The server's address: the host itself when it is an IP address,
/// otherwise the first address it resolves to, reused for five minutes.
pub async fn address(&self) -> std::io::Result<SocketAddr> {
if let Ok(ip) = self.host.parse::<IpAddr>() {
return Ok(SocketAddr::new(ip, self.port));
}
if let Some((address, resolved_at)) = *self.resolved.lock()
&& resolved_at.elapsed() < PYZOR_RESOLVE_TTL
{
return Ok(address);
}
let address = tokio::net::lookup_host((self.host.as_str(), self.port))
.await?
.next()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} has no addresses", self.host),
)
})?;
*self.resolved.lock() = Some((address, Instant::now()));
Ok(address)
}
}
impl ClassifierConfig { impl ClassifierConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> { pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let classifier = bp.setting_infallible::<structs::SpamClassifier>().await; let classifier = bp.setting_infallible::<structs::SpamClassifier>().await;
+13 -14
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use self::resolver::Policy; use self::resolver::Policy;
@@ -22,7 +24,7 @@ use registry::schema::{
}; };
use smtp_proto::*; use smtp_proto::*;
use std::{ use std::{
net::{SocketAddr, ToSocketAddrs}, net::{IpAddr, SocketAddr},
str::FromStr, str::FromStr,
time::Duration, time::Duration,
}; };
@@ -384,19 +386,16 @@ impl SessionConfig {
Some(Milter { Some(Milter {
enable: bp.compile_expr(id, &milter.ctx_enable()), enable: bp.compile_expr(id, &milter.ctx_enable()),
id, id,
addrs: format!("{}:{}", milter.hostname, milter.port) // inbuxa: upstream resolved the hostname here (a
.to_socket_addrs() // blocking lookup) and made a failure a build error,
.map_err(|err| { // which refused the whole settings reload. An IP
bp.build_error( // address is kept as is; a name is resolved on each
id, // connection (MilterClient::connect).
format!( addrs: milter
"Unable to resolve milter hostname {}: {}", .hostname
milter.hostname, err .parse::<IpAddr>()
), .map(|ip| vec![SocketAddr::new(ip, milter.port as u16)])
) .unwrap_or_default(),
})
.ok()?
.collect(),
hostname: milter.hostname, hostname: milter.hostname,
port: milter.port as u16, port: milter.port as u16,
timeout_connect: milter.timeout_connect.into_inner(), timeout_connect: milter.timeout_connect.into_inner(),
+48
View File
@@ -31,6 +31,10 @@ pub struct TelemetrySubscriber {
pub interests: Interests, pub interests: Interests,
pub typ: TelemetrySubscriberType, pub typ: TelemetrySubscriberType,
pub lossy: bool, pub lossy: bool,
/// inbuxa: a hash of the settings the running tracer is built from
/// (everything but its events, level and lossiness, which change in
/// place), so a reload can tell which tracers to start over.
pub settings: u64,
} }
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
@@ -167,6 +171,7 @@ impl Tracers {
for tracer in bp.list_infallible::<Tracer>().await { for tracer in bp.list_infallible::<Tracer>().await {
let id = tracer.id; let id = tracer.id;
let tracer = tracer.object; let tracer = tracer.object;
let settings = tracer_settings(&tracer);
let level; let level;
let lossy; let lossy;
let events; let events;
@@ -379,6 +384,7 @@ impl Tracers {
interests: Default::default(), interests: Default::default(),
lossy, lossy,
typ, typ,
settings,
}; };
// Parse disabled events // Parse disabled events
@@ -426,6 +432,7 @@ impl Tracers {
for hook in bp.list_infallible::<WebHook>().await { for hook in bp.list_infallible::<WebHook>().await {
let id = hook.id; let id = hook.id;
let hook = hook.object; let hook = hook.object;
let settings = webhook_settings(&hook);
if !hook.enable { if !hook.enable {
continue; continue;
@@ -448,6 +455,7 @@ impl Tracers {
id: format!("w_{}", id.id()), id: format!("w_{}", id.id()),
interests: Default::default(), interests: Default::default(),
lossy: hook.lossy, lossy: hook.lossy,
settings,
typ: TelemetrySubscriberType::Webhook(WebhookTracer { typ: TelemetrySubscriberType::Webhook(WebhookTracer {
url: hook.url, url: hook.url,
timeout: hook.timeout.into_inner(), timeout: hook.timeout.into_inner(),
@@ -516,6 +524,8 @@ impl Tracers {
data: storage.data.clone(), data: storage.data.clone(),
}), }),
lossy: true, lossy: true,
// Stores take a restart
settings: 0,
}); });
} }
@@ -541,6 +551,7 @@ impl Tracers {
buffered: true, buffered: true,
}), }),
lossy: false, lossy: false,
settings: 0,
}); });
} }
} else { } else {
@@ -568,6 +579,7 @@ impl Tracers {
buffered: true, buffered: true,
}), }),
lossy: false, lossy: false,
settings: 0,
}); });
} }
@@ -701,6 +713,42 @@ impl Metrics {
} }
} }
// inbuxa: what a tracer is built from, less what changes in place
macro_rules! in_place_reset {
($tracer:expr) => {{
$tracer.enable = true;
$tracer.level = Default::default();
$tracer.lossy = false;
$tracer.events = Default::default();
$tracer.events_policy = Default::default();
}};
}
fn settings_hash(settings: &impl std::fmt::Debug) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
format!("{settings:?}").hash(&mut hasher);
hasher.finish()
}
fn tracer_settings(tracer: &Tracer) -> u64 {
let mut tracer = tracer.clone();
match &mut tracer {
Tracer::Log(tracer) => in_place_reset!(tracer),
Tracer::Stdout(tracer) => in_place_reset!(tracer),
Tracer::Journal(tracer) => in_place_reset!(tracer),
Tracer::OtelHttp(tracer) => in_place_reset!(tracer),
Tracer::OtelGrpc(tracer) => in_place_reset!(tracer),
}
settings_hash(&tracer)
}
fn webhook_settings(hook: &WebHook) -> u64 {
let mut hook = hook.clone();
in_place_reset!(hook);
settings_hash(&hook)
}
fn apply_events( fn apply_events(
event_types: impl IntoIterator<Item = EventType>, event_types: impl IntoIterator<Item = EventType>,
policy: EventPolicy, policy: EventPolicy,
+69
View File
@@ -335,3 +335,72 @@ impl EmailPush {
} }
} }
} }
/// inbuxa: the task locks this node holds, so a graceful stop can hand them
/// back instead of leaving the tasks blocked until the locks expire.
pub struct TaskLocks {
held: parking_lot::Mutex<ahash::AHashSet<u64>>,
stopping: AtomicBool,
expiry: std::sync::atomic::AtomicU64,
}
impl TaskLocks {
/// How long a task lock lasts, in seconds, unless it is released first
/// or renewed. inbuxa: upstream held a lock for an hour, so a killed
/// node's tasks waited that long; the lock is now a five-minute lease
/// that the task manager renews every third of it while the task runs
/// (renew_task_locks), so a dead node's tasks run elsewhere within
/// minutes.
pub const DEFAULT_EXPIRY: u64 = 5 * 60;
pub fn is_stopping(&self) -> bool {
self.stopping.load(Ordering::Acquire)
}
/// Stops new claims and returns the ids of every lock still held.
pub fn stop(&self) -> Vec<u64> {
self.stopping.store(true, Ordering::Release);
self.held.lock().drain().collect()
}
pub fn insert(&self, id: u64) {
self.held.lock().insert(id);
}
pub fn remove(&self, id: u64) {
self.held.lock().remove(&id);
}
pub fn held(&self) -> usize {
self.held.lock().len()
}
/// inbuxa: the tasks this node holds, to renew their locks.
pub fn held_ids(&self) -> Vec<u64> {
self.held.lock().iter().copied().collect()
}
/// inbuxa: whether this node holds (and is running) the task.
pub fn is_held(&self, id: u64) -> bool {
self.held.lock().contains(&id)
}
pub fn expiry(&self) -> u64 {
self.expiry.load(Ordering::Relaxed)
}
/// Changes the lock lifetime; the tests shorten it.
pub fn set_expiry(&self, seconds: u64) {
self.expiry.store(seconds.max(1), Ordering::Relaxed);
}
}
impl Default for TaskLocks {
fn default() -> Self {
Self {
held: Default::default(),
stopping: AtomicBool::new(false),
expiry: std::sync::atomic::AtomicU64::new(Self::DEFAULT_EXPIRY),
}
}
}
+10
View File
@@ -161,11 +161,19 @@ pub struct Data {
pub span_id_gen: SnowflakeIdGenerator, pub span_id_gen: SnowflakeIdGenerator,
pub registry_id_gen: SnowflakeIdGenerator, pub registry_id_gen: SnowflakeIdGenerator,
pub queue_status: AtomicBool, pub queue_status: AtomicBool,
// inbuxa: coalesces the settings reloads registry writes trigger
pub settings_reload: cache::reload::SettingsReloadGate,
// inbuxa: the readiness probe's cached answer
pub store_health: storage::ready::StoreHealth,
pub applications: WebApplications, pub applications: WebApplications,
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>, pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
pub smtp_connectors: TlsConnectors, pub smtp_connectors: TlsConnectors,
// inbuxa: the objects that failed to build when the running settings
// were built, at boot or by the last applied reload (see reload_registry)
pub build_errors: Mutex<AHashSet<registry::types::id::ObjectId>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -279,6 +287,8 @@ pub struct HttpAuthCache {
pub struct Ipc { pub struct Ipc {
pub push_tx: mpsc::Sender<PushEvent>, pub push_tx: mpsc::Sender<PushEvent>,
pub task_tx: Arc<Notify>, pub task_tx: Arc<Notify>,
// inbuxa: task locks held by this node, released on a graceful stop
pub task_locks: Arc<crate::ipc::TaskLocks>,
pub queue_tx: mpsc::Sender<QueueEvent>, pub queue_tx: mpsc::Sender<QueueEvent>,
pub report_tx: mpsc::Sender<ReportingEvent>, pub report_tx: mpsc::Sender<ReportingEvent>,
pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>, pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>,
+25 -6
View File
@@ -23,6 +23,13 @@ use utils::{UnwrapFailure, codec::leb128::Leb128_};
pub(super) const MAGIC_MARKER: u8 = 123; pub(super) const MAGIC_MARKER: u8 = 123;
// inbuxa: blobs kept under a fixed name instead of a content hash. Nothing
// links to them, so the export names them outright.
const NAMED_BLOBS: &[&[u8]] = &[
crate::manager::SPAM_CLASSIFIER_KEY,
crate::manager::SPAM_TRAINER_KEY,
];
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub(super) enum Family { pub(super) enum Family {
Data = 0, Data = 0,
@@ -143,15 +150,21 @@ impl Core {
.await .await
.failed("Failed to iterate over data store"); .failed("Failed to iterate over data store");
for hash in blobs { // inbuxa: the trained spam classifier and its trainer state are
// blobs stored under fixed names with no blob link, so the walk
// over links above never reaches them.
let named = NAMED_BLOBS.iter().map(|key| key.to_vec());
for key in blobs
.into_iter()
.map(|hash| hash.as_slice().to_vec())
.chain(named)
{
if let Some(blob) = blob_store if let Some(blob) = blob_store
.get_blob(hash.as_slice(), 0..usize::MAX) .get_blob(&key, 0..usize::MAX)
.await .await
.failed("Failed to get blob") .failed("Failed to get blob")
{ {
writer writer.send((key, blob)).failed("Failed to send key");
.send((hash.as_slice().to_vec(), blob))
.failed("Failed to send key");
} }
} }
}), }),
@@ -323,7 +336,13 @@ impl Family {
SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_IDX,
SUBSPACE_REGISTRY_PK, SUBSPACE_REGISTRY_PK,
SUBSPACE_DIRECTORY, SUBSPACE_DIRECTORY,
store::SUBSPACE_INBUXA, // inbuxa: masked email // inbuxa: registry objects the upstream list left out, so an
// export dropped them: archived items (undelete) and spam
// training samples. Their indexes and id counters already
// travel in this family and in `data`, so they ride along.
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
store::SUBSPACE_INBUXA, // inbuxa: the fork's own data (masked email, undelete, policies)
], ],
Family::Changelog => &[SUBSPACE_LOGS], Family::Changelog => &[SUBSPACE_LOGS],
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT], Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
+15 -4
View File
@@ -54,6 +54,13 @@ Options:
-o, --console Open the store console -o, --console Open the store console
-h, --help Print help -h, --help Print help
-V, --version Print version -V, --version Print version
An export holds everything in the data and blob stores except short-lived
in-memory state (rate limits, locks, greylisting) and the full-text search
index, which belongs to one search backend. An import into an empty store
queues the index to be rebuilt when the server next starts. EXPORT_TYPES
limits an export to some of: data, registry, blob, changelog, queue, report,
telemetry, tasks.
"# "#
); );
@@ -233,6 +240,9 @@ impl BootManager {
.parse_tcp_acceptors(&mut bootstrap, inner.clone()) .parse_tcp_acceptors(&mut bootstrap, inner.clone())
.await; .await;
// inbuxa: a reload isn't refused over objects that failed here
inner.build_server().record_build_errors(&bootstrap.errors);
BootManager { BootManager {
inner, inner,
bootstrap, bootstrap,
@@ -256,10 +266,10 @@ impl BootManager {
telemetry.enable(); telemetry.enable();
// Parse settings and restore // Parse settings and restore
Box::pin(Core::parse(&mut bootstrap, storage)) let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
.await let imported = core.restore(path).await;
.restore(path) // inbuxa: the search index isn't exported; rebuild it
.await; core.queue_reindex(&imported).await;
std::process::exit(0); std::process::exit(0);
} }
StoreOp::Console => { StoreOp::Console => {
@@ -290,6 +300,7 @@ pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) {
report_tx, report_tx,
broadcast_tx: has_pubsub.then_some(broadcast_tx), broadcast_tx: has_pubsub.then_some(broadcast_tx),
task_tx: Arc::new(Notify::new()), task_tx: Arc::new(Notify::new()),
task_locks: Arc::new(crate::ipc::TaskLocks::default()),
train_task_controller: Arc::new(TrainTaskController::default()), train_task_controller: Arc::new(TrainTaskController::default()),
}, },
IpcReceivers { IpcReceivers {
+14 -5
View File
@@ -530,13 +530,22 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
use store::write::BatchBuilder; use store::write::BatchBuilder;
use types::id::Id; use types::id::Id;
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 // inbuxa: rules are always to hand, since a copy ships with the server
&& bp // (spam_rules). They load on first boot, and again when the bundled
.registry // version differs from the one last loaded, which only adds what's
// missing: new tags and rules, never a changed score.
let rules_url = super::spam_rules::rules_url(
bp.registry
.object::<SpamSettings>(Id::singleton()) .object::<SpamSettings>(Id::singleton())
.await? .await?
.is_none_or(|spam| spam.spam_filter_rules_url.is_some()) .and_then(|spam| spam.spam_filter_rules_url),
{ );
let bundled_is_new = rules_url.is_none()
&& super::spam_rules::applied_version(&bp.data_store)
.await?
.as_deref()
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION);
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules, maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
+1
View File
@@ -22,6 +22,7 @@ pub mod console;
pub mod defaults; pub mod defaults;
pub mod first_party; pub mod first_party;
pub mod restore; pub mod restore;
pub mod spam_rules; // inbuxa: rules bundled with the server
pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes(); pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes(); pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
+84 -15
View File
@@ -9,15 +9,22 @@
use super::backup::MAGIC_MARKER; use super::backup::MAGIC_MARKER;
use crate::{Core, DATABASE_SCHEMA_VERSION}; use crate::{Core, DATABASE_SCHEMA_VERSION};
use lz4_flex::frame::FrameDecoder; use lz4_flex::frame::FrameDecoder;
use registry::schema::enums::CompressionAlgo; use registry::{
schema::{
enums::{CompressionAlgo, TaskStoreMaintenanceType},
structs::{Task, TaskStatus, TaskStoreMaintenance},
},
types::EnumImpl,
};
use std::{ use std::{
fs::File, fs::File,
io::{BufReader, ErrorKind, Read}, io::{BufReader, ErrorKind, Read},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use store::{ use store::{
BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES, SUBSPACE_QUOTA, BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES,
SUBSPACE_REGISTRY_PK, Store, U32_LEN, SUBSPACE_PROPERTY, SUBSPACE_QUOTA, SUBSPACE_REGISTRY_PK, SUBSPACE_TELEMETRY_SPAN, Store,
U32_LEN,
write::{ write::{
AnyClass, AnyKey, BatchBuilder, ValueClass, AnyClass, AnyKey, BatchBuilder, ValueClass,
key::{DeserializeBigEndian, is_node_id_key}, key::{DeserializeBigEndian, is_node_id_key},
@@ -27,7 +34,9 @@ use types::{collection::Collection, field::Field};
use utils::{UnwrapFailure, failed}; use utils::{UnwrapFailure, failed};
impl Core { impl Core {
pub async fn restore(&self, src: PathBuf) { /// Imports an export into an empty store and returns the subspaces it
/// wrote. inbuxa: the caller hands them to [`Core::queue_reindex`].
pub async fn restore(&self, src: PathBuf) -> Vec<u8> {
// Backup the core // Backup the core
let paths = if src.is_dir() { let paths = if src.is_dir() {
let mut paths = Vec::new(); let mut paths = Vec::new();
@@ -64,6 +73,13 @@ impl Core {
std::process::exit(1); std::process::exit(1);
} }
let mut imported = paths
.iter()
.map(|path| KeyValueReader::new(path).subspace)
.collect::<Vec<_>>();
imported.sort_unstable();
imported.dedup();
let mut tasks = Vec::new(); let mut tasks = Vec::new();
for path in paths { for path in paths {
let storage = self.storage.clone(); let storage = self.storage.clone();
@@ -76,6 +92,54 @@ impl Core {
for task in tasks { for task in tasks {
task.await.failed("Failed to wait for task"); task.await.failed("Failed to wait for task");
} }
imported
}
/// inbuxa: an export never carries the full-text index. It is built by
/// and for one search backend (the SQL stores index into their own
/// tables, the key-value stores into a subspace, external engines keep it
/// themselves), so it would be wrong or unreadable after a move to
/// another one. Instead, an import queues the same reindex tasks an
/// administrator can queue by hand (`reindexAccounts` and
/// `reindexTelemetry` store maintenance), and the server rebuilds the
/// index for whatever search store it is configured with once it starts.
pub async fn queue_reindex(&self, imported: &[u8]) -> Vec<TaskStoreMaintenanceType> {
let mut queued = Vec::new();
if imported.contains(&SUBSPACE_PROPERTY) {
queued.push(TaskStoreMaintenanceType::ReindexAccounts);
}
if imported.contains(&SUBSPACE_TELEMETRY_SPAN) {
queued.push(TaskStoreMaintenanceType::ReindexTelemetry);
}
if queued.is_empty() {
return queued;
}
let mut batch = BatchBuilder::new();
for maintenance_type in &queued {
batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: *maintenance_type,
status: TaskStatus::now(),
shard_index: None,
}));
}
self.storage
.data
.write(batch.build_all())
.await
.failed("Failed to queue the reindex tasks");
println!(
"Queued {} to rebuild the search index; it runs when the server starts.",
queued
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(" and ")
);
queued
} }
} }
@@ -125,17 +189,22 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) {
} }
SUBSPACE_COUNTER | SUBSPACE_QUOTA => { SUBSPACE_COUNTER | SUBSPACE_QUOTA => {
while let Some((key, value)) = reader.next() { while let Some((key, value)) = reader.next() {
batch.add( let class = ValueClass::Any(AnyClass {
ValueClass::Any(AnyClass { subspace: reader.subspace,
subspace: reader.subspace, key,
key, });
}), let value = u64::from_le_bytes(
u64::from_le_bytes( value
value .try_into()
.try_into() .expect("Failed to deserialize counter/quota"),
.expect("Failed to deserialize counter/quota"), ) as i64;
) as i64, // inbuxa: the SQL stores add a negative amount with an UPDATE,
); // which does nothing to a row that isn't there yet, so a
// negative counter vanished on import. Create the row first.
if value < 0 {
batch.add(class.clone(), 0);
}
batch.add(class, value);
if batch.is_large_batch() { if batch.is_large_batch() {
store store
.write(batch.build_all()) .write(batch.build_all())
+103
View File
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: the spam filter rules that ship with the server.
//!
//! Upstream fetches its latest published rules from GitHub at run time, so
//! scoring changes with a release nobody here tested and depends on reaching
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
//! and license) and uses it whenever no other source is configured. The rules
//! URL remains an operator override (`https://` or `file://`).
//!
//! Loading rules only ever adds what's missing, never changes an existing rule
//! or score. They load on first boot, and again whenever the bundled version
//! differs from the one last applied, so an upgrade brings new tags (the AI
//! classifier's `LLM_*` scores, say) to an install that already had rules.
use std::io::Read;
use store::{
SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// The version of spam-filter the embedded rules come from.
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
static BUNDLED_SPAM_RULES: &[u8] =
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
/// Upstream's default rules source, the value every install created before
/// the rules were bundled has saved. Read only to treat it as unset.
const LEGACY_DEFAULT_URL: &str =
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
/// setting and upstream's old default both mean the bundled rules.
pub fn rules_url(configured: Option<String>) -> Option<String> {
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
}
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
pub fn bundled_rules() -> Result<Vec<u8>, String> {
let mut json = Vec::new();
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
.read_to_end(&mut json)
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
Ok(json)
}
fn applied_key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Sr".to_vec(),
})
}
/// The bundled version last loaded into the registry, if any.
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(applied_key()))
.await
.caused_by(trc::location!())
}
/// Records that the bundled rules of this version have been loaded.
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(applied_key(), version.as_bytes().to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upstream_default_and_empty_mean_bundled() {
assert_eq!(rules_url(None), None);
assert_eq!(rules_url(Some(String::new())), None);
assert_eq!(rules_url(Some(" ".into())), None);
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
assert_eq!(
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
Some("file:///srv/rules.json.gz")
);
}
#[test]
fn bundled_rules_parse_and_score_the_ai_tags() {
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
let tags = rules["SpamTag"].as_array().unwrap();
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
}
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
}
}
+1
View File
@@ -26,6 +26,7 @@ pub mod document;
pub mod encryption; pub mod encryption;
pub mod index; pub mod index;
pub mod quota; pub mod quota;
pub mod ready; // inbuxa: readiness follows the data store
pub mod state; pub mod state;
pub mod transaction; pub mod transaction;
+83
View File
@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Readiness that reflects the data store.
//!
//! /healthz/ready used to answer 200 whenever a data store was configured,
//! so a load balancer kept sending traffic to a node through a database
//! outage. It now reads one key from the data store, with a short time
//! limit, and caches the answer for a couple of seconds so probes can't load
//! the database. Liveness stays 200: restarting a node doesn't bring its
//! database back, and an orchestrator that restarts on failed liveness would
//! otherwise restart every node at once.
use crate::Server;
use parking_lot::Mutex;
use std::{
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use store::{ValueKey, write::ValueClass};
/// How long a probe's answer is reused.
pub const READY_CACHE: Duration = Duration::from_secs(2);
/// How long a probe waits for the data store.
pub const READY_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Default)]
pub struct StoreHealth {
last: Mutex<Option<(Instant, bool)>>,
probing: AtomicBool,
}
/// Clears the probing flag even when the request is dropped mid-probe.
struct ProbeGuard<'x>(&'x AtomicBool);
impl Drop for ProbeGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
impl Server {
/// Whether the data store answers: a cached result younger than
/// READY_CACHE, or a fresh read bounded by READY_PROBE_TIMEOUT. While
/// one probe is running, other callers get the last answer.
pub async fn is_data_store_ready(&self) -> bool {
let store = &self.core.storage.data;
if store.is_none() {
return false;
}
let health = &self.inner.data.store_health;
let last = *health.last.lock();
if let Some((at, ready)) = last
&& at.elapsed() < READY_CACHE
{
return ready;
}
if health.probing.swap(true, Ordering::AcqRel) {
return last.is_none_or(|(_, ready)| ready);
}
let _guard = ProbeGuard(&health.probing);
let ready = tokio::time::timeout(
READY_PROBE_TIMEOUT,
store.get_value::<u64>(ValueKey::from(ValueClass::Property(0))),
)
.await
.is_ok_and(|result| result.is_ok());
// Say so once per outage, not on every probe
if !ready && last.is_none_or(|(_, ready)| ready) {
trc::event!(
Store(trc::StoreEvent::UnexpectedError),
Details = "Readiness probe: the data store didn't answer",
Limit = READY_PROBE_TIMEOUT,
);
}
*health.last.lock() = Some((Instant::now(), ready));
ready
}
}
+34 -9
View File
@@ -14,15 +14,26 @@ pub mod webhooks;
use tracers::log::spawn_log_tracer; use tracers::log::spawn_log_tracer;
use tracers::otel::spawn_otel_tracer; use tracers::otel::spawn_otel_tracer;
use tracers::stdout::spawn_console_tracer; use tracers::stdout::spawn_console_tracer;
use ahash::AHashMap;
use parking_lot::Mutex;
use trc::{Collector, ipc::subscriber::SubscriberBuilder}; use trc::{Collector, ipc::subscriber::SubscriberBuilder};
use webhooks::spawn_webhook_tracer; use webhooks::spawn_webhook_tracer;
use crate::config::telemetry::{Telemetry, TelemetrySubscriberType}; use crate::config::telemetry::{Telemetry, TelemetrySubscriberType};
/// inbuxa: the tracers this server started, by subscriber id, with the
/// settings each was built from. Live-tracing streams and other subscribers
/// registered elsewhere aren't listed, so a reload leaves them running.
static RUNNING_TRACERS: Mutex<Option<AHashMap<String, u64>>> = Mutex::new(None);
impl Telemetry { impl Telemetry {
pub fn enable(self) { pub fn enable(self) {
let mut running = RUNNING_TRACERS.lock();
let running = running.get_or_insert_with(AHashMap::new);
// Spawn tracers // Spawn tracers
for tracer in self.tracers.subscribers { for tracer in self.tracers.subscribers {
running.insert(tracer.id.clone(), tracer.settings);
tracer.typ.spawn( tracer.typ.spawn(
SubscriberBuilder::new(tracer.id) SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests) .with_interests(tracer.interests)
@@ -37,25 +48,39 @@ impl Telemetry {
Collector::reload(); Collector::reload();
} }
// inbuxa: upstream only refreshed the events, level and lossiness of a
// tracer that was already running, so a Log tracer moved to another
// path (or any tracer whose own settings changed) kept going as it was
// built until a restart, while the reload reported the change applied.
// A tracer whose settings changed is now started over: the new one is
// registered under the same id and the collector swaps it in at an
// event boundary, so no event is lost or written twice (see
// Update::RegisterSubscriber); the old one writes what it has queued
// and stops.
pub fn update(self) { pub fn update(self) {
let mut running = RUNNING_TRACERS.lock();
let running = running.get_or_insert_with(AHashMap::new);
// Remove tracers that are no longer active // Remove tracers that are no longer active
let active_subscribers = Collector::get_subscribers(); running.retain(|id, _| {
for subscribed_id in &active_subscribers { let keep = self
if !self
.tracers .tracers
.subscribers .subscribers
.iter() .iter()
.any(|tracer| tracer.id == *subscribed_id) .any(|tracer| tracer.id == *id);
{ if !keep {
Collector::remove_subscriber(subscribed_id.clone()); Collector::remove_subscriber(id.clone());
} }
} keep
});
// Activate new tracers or update existing ones // Start new tracers, start over those whose settings changed and
// update the rest in place
for tracer in self.tracers.subscribers { for tracer in self.tracers.subscribers {
if active_subscribers.contains(&tracer.id) { if running.get(&tracer.id) == Some(&tracer.settings) {
Collector::update_subscriber(tracer.id, tracer.interests, tracer.lossy); Collector::update_subscriber(tracer.id, tracer.interests, tracer.lossy);
} else { } else {
running.insert(tracer.id.clone(), tracer.settings);
tracer.typ.spawn( tracer.typ.spawn(
SubscriberBuilder::new(tracer.id) SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests) .with_interests(tracer.interests)
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::{path::PathBuf, time::SystemTime}; use std::{path::PathBuf, time::SystemTime};
@@ -15,9 +17,27 @@ use tokio::{
}; };
use trc::{TelemetryEvent, ipc::subscriber::SubscriberBuilder, serializers::text::FmtWriter}; use trc::{TelemetryEvent, ipc::subscriber::SubscriberBuilder, serializers::text::FmtWriter};
// inbuxa: when a Log tracer is started over on the same files (its rotation
// or format changed), the new one waits for the old one to write what it
// has queued, so their lines don't interleave. Keyed by path and prefix;
// each entry is the last tracer's "done" signal, sent when it ends.
type LogFileOwners = ahash::AHashMap<(String, String), tokio::sync::oneshot::Receiver<()>>;
static LOG_FILE_OWNERS: parking_lot::Mutex<Option<LogFileOwners>> = parking_lot::Mutex::new(None);
pub(crate) fn spawn_log_tracer(builder: SubscriberBuilder, settings: LogTracer) { pub(crate) fn spawn_log_tracer(builder: SubscriberBuilder, settings: LogTracer) {
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
let previous = LOG_FILE_OWNERS
.lock()
.get_or_insert_with(Default::default)
.insert((settings.path.clone(), settings.prefix.clone()), done_rx);
let (_, mut rx) = builder.register(); let (_, mut rx) = builder.register();
tokio::spawn(async move { tokio::spawn(async move {
// Dropped when this tracer ends, however it ends
let _done = done_tx;
if let Some(previous) = previous {
let _ = previous.await;
}
if let Some(writer) = settings.build_writer().await { if let Some(writer) = settings.build_writer().await {
let mut buf = FmtWriter::new(writer) let mut buf = FmtWriter::new(writer)
.with_ansi(settings.ansi) .with_ansi(settings.ansi)
+22 -1
View File
@@ -47,6 +47,10 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
let mut pending_spans = Vec::new(); let mut pending_spans = Vec::new();
let mut active_spans = AHashMap::new(); let mut active_spans = AHashMap::new();
let mut closing = false;
let started = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
loop { loop {
// Wait for the next event or timeout // Wait for the next event or timeout
@@ -75,12 +79,26 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
events.iter().chain(std::iter::once(&event)), events.iter().chain(std::iter::once(&event)),
&instrumentation, &instrumentation,
)); ));
} else if span.inner.timestamp < started {
// inbuxa: a span that was open when this
// tracer replaced another one (its settings
// changed) is exported with its end event
// rather than dropped
pending_spans.push(build_span_data(
span,
&event,
std::iter::once(&event),
&instrumentation,
));
} }
} }
} }
} }
Ok(None) => { Ok(None) => {
break; // inbuxa: the tracer was removed or replaced; export
// what is pending now rather than drop it
closing = true;
next_delivery = Instant::now();
} }
Err(_) => (), Err(_) => (),
} }
@@ -131,6 +149,9 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
} }
} }
} }
if closing {
break;
}
wakeup_time = next_retry.unwrap_or(LONG_1Y_SLUMBER); wakeup_time = next_retry.unwrap_or(LONG_1Y_SLUMBER);
} }
}); });
+22 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{LONG_1Y_SLUMBER, config::telemetry::WebhookTracer}; use crate::{LONG_1Y_SLUMBER, config::telemetry::WebhookTracer};
@@ -25,6 +27,11 @@ use trc::{
pub(crate) fn spawn_webhook_tracer(builder: SubscriberBuilder, settings: WebhookTracer) { pub(crate) fn spawn_webhook_tracer(builder: SubscriberBuilder, settings: WebhookTracer) {
let (tx, mut rx) = builder.register(); let (tx, mut rx) = builder.register();
// inbuxa: failed deliveries come back through a weak sender, so the
// channel closes when the collector drops this webhook (removed, or
// replaced after a settings change) and the task ends; upstream held a
// sender here and the task outlived its subscription
let tx = tx.downgrade();
tokio::spawn(async move { tokio::spawn(async move {
let settings = Arc::new(settings); let settings = Arc::new(settings);
let mut wakeup_time = LONG_1Y_SLUMBER; let mut wakeup_time = LONG_1Y_SLUMBER;
@@ -58,6 +65,15 @@ pub(crate) fn spawn_webhook_tracer(builder: SubscriberBuilder, settings: Webhook
} }
} }
Ok(None) => { Ok(None) => {
// inbuxa: deliver what is pending rather than drop it
if !pending_events.is_empty() {
spawn_webhook_handler(
settings.clone(),
in_flight.clone(),
std::mem::take(&mut pending_events),
tx.clone(),
);
}
break; break;
} }
Err(_) => (), Err(_) => (),
@@ -102,7 +118,7 @@ fn spawn_webhook_handler(
settings: Arc<WebhookTracer>, settings: Arc<WebhookTracer>,
in_flight: Arc<AtomicBool>, in_flight: Arc<AtomicBool>,
events: EventBatch, events: EventBatch,
webhook_tx: mpsc::Sender<EventBatch>, webhook_tx: mpsc::WeakSender<EventBatch>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
in_flight.store(true, Ordering::Relaxed); in_flight.store(true, Ordering::Relaxed);
@@ -113,7 +129,11 @@ fn spawn_webhook_handler(
if let Err(err) = post_webhook_events(&settings, &wrapper).await { if let Err(err) = post_webhook_events(&settings, &wrapper).await {
trc::event!(Telemetry(TelemetryEvent::WebhookError), Details = err); trc::event!(Telemetry(TelemetryEvent::WebhookError), Details = err);
if webhook_tx.send(wrapper.events.into_inner()).await.is_err() { let sent = match webhook_tx.upgrade() {
Some(webhook_tx) => webhook_tx.send(wrapper.events.into_inner()).await.is_ok(),
None => false,
};
if !sent {
trc::event!( trc::event!(
Server(ServerEvent::ThreadError), Server(ServerEvent::ThreadError),
Details = "Failed to send failed webhook events back to main thread", Details = "Failed to send failed webhook events back to main thread",
+1 -1
View File
@@ -8,7 +8,7 @@ store = { path = "../store" }
registry = { path = "../registry" } registry = { path = "../registry" }
trc = { path = "../trc" } trc = { path = "../trc" }
futures = { version = "0.3", optional = true } futures = { version = "0.3", optional = true }
tokio = { version = "1.53", features = ["sync", "fs", "io-util"] } tokio = { version = "1.53", features = ["sync", "fs", "io-util", "rt", "time"] }
async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true } async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true }
zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true } zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true }
rdkafka = { version = "0.39", features = ["cmake-build"], optional = true } rdkafka = { version = "0.39", features = ["cmake-build"], optional = true }
+118 -2
View File
@@ -2,13 +2,22 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::sync::Arc; use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use crate::Coordinator; use crate::Coordinator;
use async_nats::Client; use async_nats::Client;
use registry::schema::structs::NatsCoordinator; use registry::schema::structs::NatsCoordinator;
use trc::ClusterEvent;
pub mod pubsub; pub mod pubsub;
@@ -47,9 +56,116 @@ impl NatsPubSub {
opts = opts.token(credentials); opts = opts.token(credentials);
} }
// inbuxa: connect in the background and keep trying, so a node that
// starts while NATS is down still joins the cluster once NATS is
// back, instead of running without a coordinator until restarted;
// and report the connection going and coming back
let reporter = Arc::new(Reporter::default());
opts = opts.retry_on_initial_connect().event_callback({
let reporter = reporter.clone();
move |event| {
let reporter = reporter.clone();
async move { reporter.report(event) }
}
});
let connection_timeout = config.timeout_connection.into_inner();
async_nats::connect_with_options(config.addresses.into_inner(), opts) async_nats::connect_with_options(config.addresses.into_inner(), opts)
.await .await
.map(|client| Coordinator::Nats(Arc::new(NatsPubSub { client }))) .map(|client| {
reporter.watch_first_connection(client.clone(), connection_timeout);
Coordinator::Nats(Arc::new(NatsPubSub { client }))
})
.map_err(|err| format!("Failed to connect to Nats: {}", err)) .map_err(|err| format!("Failed to connect to Nats: {}", err))
} }
/// inbuxa: whether the client is connected to a NATS server right now.
pub fn is_connected(&self) -> bool {
matches!(
self.client.connection_state(),
async_nats::connection::State::Connected
)
}
}
/// inbuxa: reports the client's connection events as the server's own.
#[derive(Default)]
struct Reporter {
connected_once: AtomicBool,
// A failed attempt raises an error each time the client retries, every
// few seconds while NATS is down: report the first after each change
error_reported: AtomicBool,
}
impl Reporter {
fn report(&self, event: async_nats::Event) {
match event {
async_nats::Event::Connected => {
self.connected_once.store(true, Ordering::Relaxed);
self.error_reported.store(false, Ordering::Relaxed);
trc::event!(Cluster(ClusterEvent::CoordinatorConnected), Type = "nats");
}
async_nats::Event::Disconnected => {
self.error_reported.store(false, Ordering::Relaxed);
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Connection lost; reconnecting in the background",
);
}
async_nats::Event::Closed => {
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Connection closed; no further attempts will be made",
);
}
async_nats::Event::ClientError(async_nats::ClientError::MaxReconnects) => {
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Gave up reconnecting (maxReconnects reached)",
);
}
async_nats::Event::ClientError(err) => {
if !self.error_reported.swap(true, Ordering::Relaxed) {
trc::event!(
Cluster(ClusterEvent::CoordinatorError),
Type = "nats",
Details = "Connection attempt failed; retrying",
Reason = err.to_string(),
);
}
}
event => {
trc::event!(
Cluster(ClusterEvent::CoordinatorError),
Type = "nats",
Details = event.to_string(),
);
}
}
}
/// The first connection is made in the background, so say so when it
/// hasn't been made within the connection timeout. The client keeps
/// trying, and reports the connection when it comes.
fn watch_first_connection(self: &Arc<Self>, client: Client, timeout: Duration) {
let reporter = self.clone();
tokio::spawn(async move {
tokio::time::sleep(timeout).await;
if !reporter.connected_once.load(Ordering::Relaxed)
&& !matches!(
client.connection_state(),
async_nats::connection::State::Connected
)
{
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Not connected at startup; retrying in the background",
);
}
});
}
} }
+13
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Coordinator, Msg, PubSubStream}; use crate::{Coordinator, Msg, PubSubStream};
@@ -43,6 +45,17 @@ impl Coordinator {
pub fn is_none(&self) -> bool { pub fn is_none(&self) -> bool {
matches!(self, Coordinator::None) matches!(self, Coordinator::None)
} }
/// inbuxa: whether the coordinator is connected right now, for the
/// backends that track it (NATS); `None` for the others and when no
/// coordinator is configured.
pub fn is_connected(&self) -> Option<bool> {
match self {
#[cfg(feature = "nats")]
Coordinator::Nats(store) => Some(store.is_connected()),
_ => None,
}
}
} }
impl PubSubStream { impl PubSubStream {
+24 -1
View File
@@ -553,8 +553,10 @@ impl ParseHttp for Server {
return Ok(JsonProblemResponse(StatusCode::OK).into_http_response()); return Ok(JsonProblemResponse(StatusCode::OK).into_http_response());
} }
"ready" => { "ready" => {
// inbuxa: ready only while the data store answers
// (a cached, time-limited read); liveness stays 200
return Ok(JsonProblemResponse({ return Ok(JsonProblemResponse({
if !self.core.storage.data.is_none() { if self.is_data_store_ready().await {
StatusCode::OK StatusCode::OK
} else { } else {
StatusCode::SERVICE_UNAVAILABLE StatusCode::SERVICE_UNAVAILABLE
@@ -562,6 +564,27 @@ impl ParseHttp for Server {
}) })
.into_http_response()); .into_http_response());
} }
// inbuxa: the cluster coordinator's connection, for
// monitoring. It stays out of live and ready on purpose:
// a node without its coordinator still serves mail, and
// failing those would have an orchestrator restart, or
// take out of service, every node at once when the
// coordinator goes down
"cluster" => {
let coordinator = &self.core.storage.coordinator;
let (status, state) = match coordinator.is_connected() {
Some(true) => (StatusCode::OK, "connected"),
Some(false) => (StatusCode::SERVICE_UNAVAILABLE, "disconnected"),
None if coordinator.is_none() => (StatusCode::OK, "none"),
None => (StatusCode::OK, "unknown"),
};
return Ok(http_proto::JsonResponse::with_status(
status,
serde_json::json!({ "coordinator": state }),
)
.no_cache()
.into_http_response());
}
_ => (), _ => (),
} }
} }
+20
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::ahash_is_empty; use super::ahash_is_empty;
@@ -71,6 +73,23 @@ pub struct SetResponse<T: JmapObject> {
#[serde(rename = "notDestroyed")] #[serde(rename = "notDestroyed")]
#[serde(skip_serializing_if = "VecMap::is_empty")] #[serde(skip_serializing_if = "VecMap::is_empty")]
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>, pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
// inbuxa: on a registry write that changes the running settings, whether
// the server applied it
#[serde(rename = "x:settingsReload")]
#[serde(skip_serializing_if = "Option::is_none")]
pub settings_reload: Option<SettingsReload>,
}
/// inbuxa: the settings reload that followed a registry write.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SettingsReload {
/// The running settings (here and, through the cluster, on every node)
/// include the write.
pub applied: bool,
/// Why they don't, when they don't.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
} }
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> { impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
@@ -199,6 +218,7 @@ impl<T: JmapObject> SetResponse<T> {
not_created: VecMap::new(), not_created: VecMap::new(),
not_updated: VecMap::new(), not_updated: VecMap::new(),
not_destroyed: VecMap::new(), not_destroyed: VecMap::new(),
settings_reload: None,
}) })
} else { } else {
Err(trc::JmapEvent::RequestTooLarge.into_err()) Err(trc::JmapEvent::RequestTooLarge.into_err())
+17 -2
View File
@@ -427,9 +427,24 @@ pub(crate) async fn trace_query(
} }
None => false, None => false,
}, },
Property::QueueId => match value.as_str() { // The queue id column is an integer on every search backend, and
// holds a trace's first queue id; the keywords carry all of them
Property::QueueId => match value
.as_str()
.and_then(|v| v.trim().parse::<u64>().ok())
.or_else(|| value.as_u64())
{
Some(queue_id) => { Some(queue_id) => {
search.push(SearchFilter::eq(TracingSearchField::QueueId, queue_id.to_string())); search.extend([
SearchFilter::Or,
SearchFilter::eq(TracingSearchField::QueueId, queue_id),
SearchFilter::has_text(
TracingSearchField::Keywords,
queue_id.to_string(),
nlp::language::Language::None,
),
SearchFilter::End,
]);
true true
} }
None => false, None => false,
+14 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error}; use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error};
@@ -99,7 +101,7 @@ pub(crate) async fn action_set(
} else { } else {
set.response set.response
.not_created .not_created
.append(id, map_bootstrap_error(result.errors)); .append(id, reload_refused(result.errors));
} }
} }
Action::InvalidateCaches => { Action::InvalidateCaches => {
@@ -573,3 +575,14 @@ async fn dmarc_troubleshoot(
Some(request) Some(request)
} }
/// inbuxa: a refused reload names the object that stopped it and says the
/// settings weren't applied; upstream passed on the first error's bare message
/// ("Invalid address: ..."), which read like a problem with the request.
fn reload_refused(errors: Vec<registry::types::error::Error>) -> SetError<Property> {
let description = format!(
"Settings were not reloaded. {}",
common::cache::reload::describe_reload_errors(&errors)
);
map_bootstrap_error(errors).with_description(description)
}
+63 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -15,22 +17,42 @@ use jmap_proto::{error::set::SetError, types::state::State};
use jmap_tools::{Key, Value}; use jmap_tools::{Key, Value};
use registry::{ use registry::{
jmap::IntoValue, jmap::IntoValue,
schema::prelude::{Object, ObjectInner, ObjectType, Property}, schema::{
prelude::{Object, ObjectInner, ObjectType, Property},
structs::Task,
},
types::{EnumImpl, datetime::UTCDateTime}, types::{EnumImpl, datetime::UTCDateTime},
}; };
use services::task_manager::lock::TaskLockManager;
use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex}; use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex};
use std::str::FromStr; use std::str::FromStr;
use store::{ use store::{
U64_LEN, ValueKey, U64_LEN, ValueKey,
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery}, registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
write::{BatchBuilder, RegistryClass, ValueClass, key::KeySerializer}, write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, key::KeySerializer},
}; };
use trc::AddContext; use trc::AddContext;
use types::id::Id; use types::id::Id;
pub(crate) async fn report_set( pub(crate) async fn report_set(
mut set: RegistrySetResponse<'_>, set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> { ) -> trc::Result<RegistrySetResponse<'_>> {
// inbuxa: task locks taken to reschedule reports are released however
// the request ends; a held lock is renewed, so a leaked one would keep
// the report's task from ever running
let server = set.server;
let mut locked_tasks = Vec::new();
let result = report_set_locked(set, &mut locked_tasks).await;
for task_id in locked_tasks {
server.remove_index_lock(task_id).await;
}
result
}
async fn report_set_locked<'x>(
mut set: RegistrySetResponse<'x>,
locked_tasks: &mut Vec<u64>,
) -> trc::Result<RegistrySetResponse<'x>> {
let object_id = set.object_type.to_id(); let object_id = set.object_type.to_id();
// Reports cannot be created // Reports cannot be created
@@ -89,12 +111,45 @@ pub(crate) async fn report_set(
.get_value::<Object>(ValueKey::from(key.clone())) .get_value::<Object>(ValueKey::from(key.clone()))
.await? .await?
{ {
// inbuxa: the report's task shares its id. Hold the task
// while its queue rows move, as x:Task/set does, and move the
// row the task is actually queued under
if !set.server.try_lock_task(item_id).await {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"The report is being sent and cannot be rescheduled".to_string(),
),
);
continue;
}
locked_tasks.push(item_id);
let queued = set
.server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: item_id },
)))
.await?;
match &mut report_obj.inner { match &mut report_obj.inner {
ObjectInner::DmarcInternalReport(report) => { ObjectInner::DmarcInternalReport(report) => {
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at); report.reschedule_ops(
&mut batch,
item_id,
report_obj.revision,
deliver_at,
queued.as_ref(),
);
} }
ObjectInner::TlsInternalReport(report) => { ObjectInner::TlsInternalReport(report) => {
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at); report.reschedule_ops(
&mut batch,
item_id,
report_obj.revision,
deliver_at,
queued.as_ref(),
);
} }
_ => {} _ => {}
} }
@@ -156,6 +211,9 @@ pub(crate) async fn report_set(
.write(batch.build_all()) .write(batch.build_all())
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// inbuxa: a rescheduled report may now be due sooner than the task
// manager's next scan
set.server.notify_task_queue();
} }
Ok(set) Ok(set)
+4 -9
View File
@@ -463,15 +463,10 @@ pub(crate) async fn task_query(
.set_values(typ.is_some()), .set_values(typ.is_some()),
|key, value| { |key, value| {
if let Some(typ) = typ { if let Some(typ) = typ {
let task_type = // inbuxa: a row whose type can't be read matches no type
TaskType::from_id(value.deserialize_be_u16(0)?).ok_or_else(|| { // filter; the task manager logs and repairs it
trc::StoreEvent::DataCorruption let task_type = value.deserialize_be_u16(0).ok().and_then(TaskType::from_id);
.into_err() if task_type != Some(typ) {
.ctx(trc::Key::Key, key.to_vec())
.ctx(trc::Key::Value, value.to_vec())
.caused_by(trc::location!())
})?;
if task_type != typ {
return Ok(true); return Ok(true);
} }
} }
+19 -21
View File
@@ -38,7 +38,7 @@ use directory::core::secret::{hash_secret, is_password_hash};
use http_proto::HttpSessionData; use http_proto::HttpSessionData;
use jmap_proto::{ use jmap_proto::{
error::set::{SetError, SetErrorType}, error::set::{SetError, SetErrorType},
method::set::{SetRequest, SetResponse}, method::set::{SetRequest, SetResponse, SettingsReload},
object::registry::Registry, object::registry::Registry,
references::resolve::ResolveCreatedReference, references::resolve::ResolveCreatedReference,
request::{IntoValid, MaybeInvalid}, request::{IntoValid, MaybeInvalid},
@@ -931,30 +931,28 @@ impl RegistrySet for Server {
} }
}; };
// inbuxa: DIR-17: a directory or the server default applies on the // inbuxa: a write to an object the running settings are built from
// next request, here and on every node // applies at once, here and on every node (DIR-17 did this for
if matches!( // directories and the server default; now it covers every such object)
object_type, let mut result = result;
ObjectType::Directory | ObjectType::Authentication if let Ok(response) = &mut result
) && let Ok(response) = &result
&& (!response.created.is_empty() && (!response.created.is_empty()
|| !response.updated.is_empty() || !response.updated.is_empty()
|| !response.destroyed.is_empty()) || !response.destroyed.is_empty())
&& let Some(reload) = self.reload_after_write(object_type).await
{ {
let change = common::ipc::RegistryChange::Reload(ObjectType::Directory); response.settings_reload = Some(match reload {
match Box::pin(self.reload_registry(change)).await { Ok(()) => SettingsReload {
Ok(reload) if !reload.has_errors() => { applied: true,
self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change)) description: None,
.await; },
} Err(reason) => SettingsReload {
Ok(_) => trc::event!( applied: false,
Registry(trc::RegistryEvent::BuildWarning), description: Some(format!(
Details = "Settings didn't reload after a directory change", "Saved, but the running settings were not reloaded. {reason}"
), )),
Err(err) => { },
trc::error!(err.details("Failed to reload directories")); });
}
}
} }
result result
} }
+4
View File
@@ -109,6 +109,10 @@ async fn main() -> std::io::Result<()> {
// Wait for shutdown signal // Wait for shutdown signal
wait_for_shutdown().await; wait_for_shutdown().await;
// inbuxa: hand back the task locks this node holds, so other nodes can
// run those tasks now rather than when the locks expire
services::task_manager::lock::release_task_locks(&inner.build_server()).await;
// Shutdown collector // Shutdown collector
Collector::shutdown(); Collector::shutdown();
+1 -1
View File
@@ -40215,7 +40215,7 @@ impl Default for SpamSettings {
score_reject: Float::new(0.0f64), score_reject: Float::new(0.0f64),
score_spam: Float::new(5.0f64), score_spam: Float::new(5.0f64),
trust_replies: true, trust_replies: true,
spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()), spam_filter_rules_url: None,
} }
} }
} }
+24 -3
View File
@@ -26,7 +26,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}; };
tokio::spawn(async move { tokio::spawn(async move {
let mut retry_count = 0; let mut retry_count: u32 = 0;
trc::event!(Cluster(ClusterEvent::SubscriberStart)); trc::event!(Cluster(ClusterEvent::SubscriberStart));
@@ -53,7 +53,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
); );
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(1 << retry_count.max(6)), subscribe_retry_delay(retry_count),
shutdown_rx.changed(), shutdown_rx.changed(),
) )
.await .await
@@ -62,7 +62,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
break; break;
} }
Err(_) => { Err(_) => {
retry_count += 1; retry_count = retry_count.saturating_add(1);
continue; continue;
} }
} }
@@ -234,6 +234,11 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}); });
} }
/// Delay before the next subscribe attempt: 1 s, 2 s, 4 s ... capped at 64 s.
fn subscribe_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs(1u64 << retry_count.min(6))
}
fn log_event(event: &BroadcastEvent) -> trc::Value { fn log_event(event: &BroadcastEvent) -> trc::Value {
match event { match event {
BroadcastEvent::PushNotification(notification) => match notification { BroadcastEvent::PushNotification(notification) => match notification {
@@ -296,3 +301,19 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
BroadcastEvent::QueueRefresh => "QueueRefresh".into(), BroadcastEvent::QueueRefresh => "QueueRefresh".into(),
} }
} }
#[cfg(test)]
mod tests {
use super::subscribe_retry_delay;
use std::time::Duration;
#[test]
fn subscribe_retry_backoff_grows_then_caps() {
let schedule: Vec<u64> = (0..10)
.map(|n| subscribe_retry_delay(n).as_secs())
.collect();
assert_eq!(schedule, vec![1, 2, 4, 8, 16, 32, 64, 64, 64, 64]);
// No shift overflow at the top of the range.
assert_eq!(subscribe_retry_delay(u32::MAX), Duration::from_secs(64));
}
}
+66 -21
View File
@@ -91,7 +91,15 @@ impl SearchIndexTask for Server {
build_contact_document(self, account_id, document_id).await build_contact_document(self, account_id, document_id).await
} }
IndexDocumentType::File => { IndexDocumentType::File => {
// File indexing not implemented yet // File indexing not implemented yet. inbuxa: still
// one result per task: update_tasks pairs them by
// position, and a missing one shifts every result
// after it onto the wrong task
results.push(IndexTaskResult {
task_type: TaskType::Insert,
index: task.document_type,
result: TaskResult::Ignored,
});
continue; continue;
} }
}; };
@@ -567,20 +575,14 @@ async fn build_contact_document(
} }
// inbuxa: MON-16: a trace's search document, when trace search is on: // inbuxa: MON-16: a trace's search document, when trace search is on
// its event types, queue ids, and addresses, their domains, hosts, IPs,
// message ids and account names as keywords
async fn build_tracing_span_document( async fn build_tracing_span_document(
server: &Server, server: &Server,
span_id: u64, span_id: u64,
) -> trc::Result<Option<IndexDocument>> { ) -> trc::Result<Option<IndexDocument>> {
use common::telemetry::tracers::store::MaybeTrace; use common::telemetry::tracers::store::MaybeTrace;
use registry::schema::{enums::SearchTracingField, structs::Search}; use registry::schema::structs::Search;
use store::{ use store::write::{TelemetryClass, ValueClass};
search::TracingSearchField,
write::{TelemetryClass, ValueClass},
};
use trc::Key;
let settings = server let settings = server
.registry() .registry()
@@ -590,7 +592,6 @@ async fn build_tracing_span_document(
if !settings.index_telemetry { if !settings.index_telemetry {
return Ok(None); return Ok(None);
} }
let wants = |field: SearchTracingField| settings.index_tracing_fields.iter().any(|f| *f == field);
let Some(MaybeTrace(Some(trace))) = server let Some(MaybeTrace(Some(trace))) = server
.tracing_store() .tracing_store()
.get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span( .get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(
@@ -601,23 +602,67 @@ async fn build_tracing_span_document(
return Ok(None); return Ok(None);
}; };
Ok(Some(trace_search_document(
span_id,
&trace,
&settings
.index_tracing_fields
.iter()
.copied()
.collect::<Vec<_>>(),
)))
}
/// inbuxa: MON-16: the search document for a stored trace.
///
/// The event type and queue id columns are integers on every search backend
/// (BIGINT on PostgreSQL and MySQL, long on Elasticsearch), and each holds a
/// single value per trace: the event type is the trace's opening event, the
/// one `x:Trace/query` filters on, and the queue id is the first queue id the
/// trace mentions. Every queue id also goes into the keywords, so a session
/// that queued several messages is found by any of them.
pub fn trace_search_document(
span_id: u64,
trace: &registry::schema::structs::Trace,
fields: &[registry::schema::enums::SearchTracingField],
) -> IndexDocument {
use registry::schema::{enums::SearchTracingField, structs::TraceValue};
use store::search::TracingSearchField;
use trc::Key;
let wants = |field: SearchTracingField| fields.contains(&field);
let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id); let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id);
if wants(SearchTracingField::EventType)
&& let Some(first) = trace.events.iter().next()
{
document.index_unsigned(TracingSearchField::EventType, first.event.to_id() as u64);
}
let mut seen = store::ahash::AHashSet::new(); let mut seen = store::ahash::AHashSet::new();
let mut queue_id_indexed = false;
for event in trace.events.iter() { for event in trace.events.iter() {
if wants(SearchTracingField::EventType) && seen.insert(event.event.as_str().to_string()) {
document.index_keyword(TracingSearchField::EventType, event.event.as_str());
}
for kv in event.key_values.iter() { for kv in event.key_values.iter() {
let text = match &kv.value { let text = match &kv.value {
registry::schema::structs::TraceValue::String(v) => v.value.clone(), TraceValue::String(v) => v.value.clone(),
registry::schema::structs::TraceValue::UnsignedInt(v) => v.value.to_string(), TraceValue::UnsignedInt(v) => v.value.to_string(),
registry::schema::structs::TraceValue::IpAddr(v) => v.value.to_string(), TraceValue::IpAddr(v) => v.value.to_string(),
_ => continue, _ => continue,
}; };
match kv.key { match kv.key {
Key::QueueId if wants(SearchTracingField::QueueId) => { Key::QueueId => {
if seen.insert(format!("q:{text}")) { let Ok(queue_id) = text.parse::<u64>() else {
document.index_keyword(TracingSearchField::QueueId, &text); continue;
};
if wants(SearchTracingField::QueueId) && !queue_id_indexed {
document.index_unsigned(TracingSearchField::QueueId, queue_id);
queue_id_indexed = true;
}
if wants(SearchTracingField::Keywords) && seen.insert(format!("k:{text}")) {
document.index_text(
TracingSearchField::Keywords,
&text,
nlp::language::Language::None,
);
} }
} }
Key::From Key::From
@@ -648,7 +693,7 @@ async fn build_tracing_span_document(
} }
} }
} }
Ok(Some(document)) document
} }
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at // inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
+73 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::*; use crate::task_manager::*;
@@ -13,13 +15,21 @@ pub trait TaskLockManager: Sync + Send {
impl TaskLockManager for Server { impl TaskLockManager for Server {
async fn try_lock_task(&self, id: u64) -> bool { async fn try_lock_task(&self, id: u64) -> bool {
// inbuxa: a node that is stopping claims nothing new
let locks = &self.inner.ipc.task_locks;
if locks.is_stopping() {
return false;
}
match self match self
.in_memory_store() .in_memory_store()
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), DEFAULT_LOCK_EXPIRY) .try_lock(KV_LOCK_TASK, &id.to_be_bytes(), locks.expiry())
.await .await
{ {
Ok(result) => { Ok(result) => {
if !result { if result {
locks.insert(id);
} else {
trc::event!( trc::event!(
TaskManager(TaskManagerEvent::TaskLocked), TaskManager(TaskManagerEvent::TaskLocked),
Id = id, Id = id,
@@ -48,5 +58,66 @@ impl TaskLockManager for Server {
.caused_by(trc::location!()) .caused_by(trc::location!())
); );
} }
self.inner.ipc.task_locks.remove(id);
} }
} }
/// inbuxa: on a graceful stop, stops claiming tasks and releases every task
/// lock this node holds, so the rest of the cluster can pick the tasks up at
/// once instead of after the lock expires. Returns how many were released.
pub async fn release_task_locks(server: &Server) -> usize {
let ids = server.inner.ipc.task_locks.stop();
for id in &ids {
if let Err(err) = server
.in_memory_store()
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
.await
{
trc::error!(
err.details("Failed to release task lock on shutdown")
.ctx(trc::Key::Id, *id)
.caused_by(trc::location!())
);
}
}
ids.len()
}
/// inbuxa: renews the lease on every task this node is running, so it stays
/// claimed for as long as it runs while a node that dies loses its claims
/// within one lock lifetime. Returns how many leases were renewed and how
/// many were found lost (expired, perhaps taken by another node).
pub async fn renew_task_locks(server: &Server) -> (usize, usize) {
let locks = &server.inner.ipc.task_locks;
let expiry = locks.expiry();
let (mut renewed, mut lost) = (0, 0);
for id in locks.held_ids() {
match server
.in_memory_store()
.renew_lock(KV_LOCK_TASK, &id.to_be_bytes(), expiry)
.await
{
Ok(true) => renewed += 1,
Ok(false) => {
// Still held here as far as this node knows; the task
// finishes and its lock is removed as usual
if locks.is_held(id) {
lost += 1;
trc::event!(
TaskManager(TaskManagerEvent::TaskLocked),
Id = id,
Details = "Task lock expired while the task was running",
);
}
}
Err(err) => {
trc::error!(
err.details("Failed to renew task lock")
.ctx(trc::Key::Id, id)
.caused_by(trc::location!())
);
}
}
}
(renewed, lost)
}
+441 -181
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::acme::AcmeTask; use crate::task_manager::acme::AcmeTask;
@@ -11,22 +13,24 @@ use crate::task_manager::dkim::DkimManagementTask;
use crate::task_manager::dns::DnsManagementTask; use crate::task_manager::dns::DnsManagementTask;
use crate::task_manager::imip::SendImipTask; use crate::task_manager::imip::SendImipTask;
use crate::task_manager::index::SearchIndexTask; use crate::task_manager::index::SearchIndexTask;
use crate::task_manager::lock::TaskLockManager; use crate::task_manager::lock::{TaskLockManager, renew_task_locks};
use crate::task_manager::maintenance::MaintenanceTask; use crate::task_manager::maintenance::MaintenanceTask;
use crate::task_manager::merge_threads::MergeThreadsTask; use crate::task_manager::merge_threads::MergeThreadsTask;
use crate::task_manager::report::{self, SubmitReportTask}; use crate::task_manager::report::{self, SubmitReportTask};
use crate::task_manager::restore_item::RestoreItemTask; use crate::task_manager::restore_item::RestoreItemTask;
use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask; use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask;
use crate::task_manager::{ use crate::task_manager::{
DEFAULT_LOCK_EXPIRY, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo, CLAIM_RECHECK_INTERVAL, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo,
TaskJob, TaskManagerIpc, TaskResult, TaskJob, TaskManagerIpc, TaskResult,
}; };
use common::BuildServer; use common::BuildServer;
use common::config::network::ClusterRoles;
use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol}; use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol};
use common::network::limiter::ConcurrencyLimiter; use common::network::limiter::ConcurrencyLimiter;
use common::network::{ServerInstance, TcpAcceptor}; use common::network::{ServerInstance, TcpAcceptor};
use common::{Inner, Server}; use common::{Inner, Server};
use registry::schema::enums::TaskType; use registry::schema::enums::TaskType;
use registry::schema::prelude::ObjectType;
use registry::schema::structs::{ use registry::schema::structs::{
Task, TaskManager, TaskRetryStrategy, TaskStatus, TaskStatusFailed, TaskStatusRetry, Task, TaskManager, TaskRetryStrategy, TaskStatus, TaskStatusFailed, TaskStatusRetry,
}; };
@@ -52,24 +56,37 @@ const PERPETUAL_RETRY_MIN_DELAY: u64 = 3600;
const PERPETUAL_RETRY_MAX_DELAY: u64 = 21600; const PERPETUAL_RETRY_MAX_DELAY: u64 = 21600;
pub fn spawn_task_manager(inner: Arc<Inner>) { pub fn spawn_task_manager(inner: Arc<Inner>) {
let is_clustered = { // inbuxa: upstream didn't start the task manager on a node whose role
let server = inner.build_server(); // had no task types at boot, so adding one later did nothing until a
let roles = &server.core.network.roles; // restart. It now always runs and reads the role on every scan and
// before every job (task_enabled), so a role change applies at the next
if !roles.account_maintenance // settings reload.
&& !roles.store_maintenance let is_clustered = inner.build_server().core.storage.coordinator.is_enabled();
&& !roles.search_indexing
&& !roles.spam_training
&& !roles.task_manager
{
return;
}
server.core.storage.coordinator.is_enabled()
};
trc::event!(TaskManager(TaskManagerEvent::ManagerStarted)); trc::event!(TaskManager(TaskManagerEvent::ManagerStarted));
// inbuxa: keep the leases of running tasks alive, every third of a lock
// lifetime, until the node stops
{
let inner = inner.clone();
tokio::spawn(async move {
let mut renewed_at = Instant::now();
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let locks = &inner.ipc.task_locks;
if locks.is_stopping() {
break;
}
if renewed_at.elapsed() >= Duration::from_secs((locks.expiry() / 3).max(1)) {
renewed_at = Instant::now();
if locks.held() > 0 {
renew_task_locks(&inner.build_server()).await;
}
}
}
});
}
// Create dummy server instance for alarms // Create dummy server instance for alarms
let server_instance = Arc::new(ServerInstance { let server_instance = Arc::new(ServerInstance {
id: "_local".to_string(), id: "_local".to_string(),
@@ -124,72 +141,50 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
let server = inner.build_server(); let server = inner.build_server();
let batch_size = server.core.email.index_batch_size; let batch_size = server.core.email.index_batch_size;
let mut batch = Vec::with_capacity(batch_size); let mut batch = Vec::with_capacity(batch_size);
match server if let Some(task) = fetch_enabled_task(&server, job).await {
.store() batch.push(task);
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: job.id },
)))
.await
{
Ok(Some(task)) => {
batch.push(TaskDetails { task, info: job });
}
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
}
} }
while batch.len() < batch_size { while batch.len() < batch_size {
match rx.try_recv() { match rx.try_recv() {
Ok(job) => { Ok(job) => {
match server if let Some(task) = fetch_enabled_task(&server, job).await {
.store() batch.push(task);
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: job.id },
)))
.await
{
Ok(Some(task)) => {
batch.push(TaskDetails { task, info: job });
}
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
}
} }
} }
Err(_) => break, Err(_) => break,
} }
} }
if batch.is_empty() {
continue;
}
// Dispatch // Dispatch. inbuxa: on a task of its own, so a panic
// releases the batch's locks and leaves this worker
// running; a dead worker would keep claiming tasks it
// can never run
let mut refresh_queue = false; let mut refresh_queue = false;
let results = server.index(&batch).await.into_iter().map(|r| { let ids = batch.iter().map(|task| task.info.id).collect::<Vec<_>>();
refresh_queue |= r.result.is_retry(); let run = {
r.result let server = server.clone();
}); tokio::spawn(async move {
update_tasks(&server, &mut batch, results).await; let results = server.index(&batch).await;
(batch, results)
})
};
match run.await {
Ok((mut batch, results)) => {
let results = results.into_iter().map(|r| {
refresh_queue |= r.result.is_retry();
r.result
});
update_tasks(&server, &mut batch, results).await;
}
Err(err) => {
worker_failed(&server, &ids, err).await;
refresh_queue = true;
}
}
if refresh_queue || rx.is_empty() { if refresh_queue || rx.is_empty() {
server.notify_task_queue(); server.notify_task_queue();
@@ -203,83 +198,32 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
let server = inner.build_server(); let server = inner.build_server();
let mut refresh_queue = false; let mut refresh_queue = false;
match server if let Some(TaskDetails { task, info }) = fetch_enabled_task(&server, job).await
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: job.id },
)))
.await
{ {
Ok(Some(task)) => { // inbuxa: on a task of its own, as above
let result = match &task { let run = {
Task::CalendarAlarmEmail(task) => { let server = server.clone();
server.send_email_alarm(task, server_instance.clone()).await let server_instance = server_instance.clone();
} tokio::spawn(async move {
Task::CalendarAlarmNotification(task) => { let result = run_task(&server, &task, server_instance).await;
server.send_display_alarm(task).await (task, result)
} })
Task::CalendarItipMessage(task) => { };
server.send_imip(task, server_instance.clone()).await match run.await {
} Ok((task, result)) => {
Task::MergeThreads(task) => server.merge_threads(task).await, refresh_queue = result.is_retry();
Task::DmarcReport(task) => {
server
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
.await
}
Task::TlsReport(task) => {
server
.submit_report(report::ReportId::Tls(task.report_id.id()))
.await
}
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
Task::DestroyAccount(task) => server.destroy_account(task).await,
Task::AccountMaintenance(task) => {
server.account_maintenance(task).await
}
Task::TenantMaintenance(task) => {
server.tenant_maintenance(task).await
}
Task::StoreMaintenance(task) => {
server.store_maintenance(task).await
}
Task::SpamFilterMaintenance(task) => {
Box::pin(server.spam_filter_maintenance(task)).await
}
Task::AcmeRenewal(task) => server.acme_management(task).await,
Task::DkimManagement(task_dkim_rotation) => {
server.dkim_management(task_dkim_rotation).await
}
Task::DnsManagement(task_dns_management) => {
server.dns_management(task_dns_management).await
}
Task::IndexDocument(_)
| Task::UnindexDocument(_)
| Task::IndexTrace(_) => unreachable!(),
};
refresh_queue = result.is_retry(); update_tasks(
&server,
update_tasks( &mut [TaskDetails { task, info }],
&server, vec![result],
&mut [TaskDetails { task, info: job }], )
vec![result], .await;
) }
.await; Err(err) => {
} worker_failed(&server, &[info.id], err).await;
Ok(None) => { refresh_queue = true;
trc::event!( }
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
} }
} }
@@ -318,6 +262,24 @@ pub(crate) trait TaskQueueManager: Sync + Send {
impl TaskQueueManager for Server { impl TaskQueueManager for Server {
async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration { async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration {
// inbuxa: a node that is stopping has released its locks and claims
// nothing new
let task_locks = &self.inner.ipc.task_locks;
if task_locks.is_stopping() {
return Duration::from_secs(QUEUE_REFRESH_INTERVAL);
}
// inbuxa: with no task type enabled by this node's role there is
// nothing to claim; a settings reload wakes the manager when that
// changes
let roles = &self.core.network.roles;
if !(0..TaskType::COUNT as u16)
.filter_map(TaskType::from_id)
.any(|task_type| task_enabled(roles, task_type))
{
ipc.locked.clear();
return Duration::from_secs(QUEUE_REFRESH_INTERVAL);
}
let lock_expiry = task_locks.expiry();
let now_timestamp = now(); let now_timestamp = now();
let from_key = ValueKey::<ValueClass> { let from_key = ValueKey::<ValueClass> {
account_id: 0, account_id: 0,
@@ -337,9 +299,9 @@ impl TaskQueueManager for Server {
// Retrieve tasks pending to be processed // Retrieve tasks pending to be processed
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let mut unreadable = Vec::new();
let now = Instant::now(); let now = Instant::now();
let mut next_event = None; let mut next_event = None;
let roles = &self.core.network.roles;
ipc.revision += 1; ipc.revision += 1;
let _ = self let _ = self
.store() .store()
@@ -351,32 +313,28 @@ impl TaskQueueManager for Server {
let task_id = key.deserialize_be_u64(U64_LEN)?; let task_id = key.deserialize_be_u64(U64_LEN)?;
if task_due <= now_timestamp { if task_due <= now_timestamp {
let task_type_idx = value.deserialize_be_u16(0)?; // inbuxa: a row whose task type can't be read is
let task_type = TaskType::from_id(task_type_idx).ok_or_else(|| { // set aside, not allowed to end the scan: every
trc::StoreEvent::DataCorruption // task due after it would wait behind it
.caused_by(trc::location!()) let Some((task_type_idx, task_type)) = value
.ctx(trc::Key::Value, value) .deserialize_be_u16(0)
})?; .ok()
let enabled = match task_type { .and_then(|idx| TaskType::from_id(idx).map(|typ| (idx, typ)))
TaskType::IndexDocument else {
| TaskType::UnindexDocument unreadable.push(UnreadableDueRow {
| TaskType::IndexTrace => roles.search_indexing, due: task_due,
TaskType::AccountMaintenance id: task_id,
| TaskType::TenantMaintenance value: value.to_vec(),
| TaskType::DestroyAccount => roles.account_maintenance, });
TaskType::StoreMaintenance => roles.store_maintenance, return Ok(true);
TaskType::SpamFilterMaintenance => roles.spam_training,
TaskType::CalendarAlarmEmail
| TaskType::CalendarAlarmNotification
| TaskType::CalendarItipMessage
| TaskType::MergeThreads
| TaskType::DmarcReport
| TaskType::TlsReport
| TaskType::RestoreArchivedItem
| TaskType::AcmeRenewal
| TaskType::DkimManagement
| TaskType::DnsManagement => true,
}; };
// inbuxa: running here under a lease this node
// renews; don't hand it to a worker again
if task_locks.is_held(task_id) {
return Ok(true);
}
let enabled = task_enabled(roles, task_type);
if !enabled { if !enabled {
trc::event!( trc::event!(
@@ -393,9 +351,7 @@ impl TaskQueueManager for Server {
let locked = entry.get_mut(); let locked = entry.get_mut();
if locked.expires <= now || locked.due < task_due { if locked.expires <= now || locked.due < task_due {
locked.expires = Instant::now() locked.expires = Instant::now()
+ std::time::Duration::from_secs( + std::time::Duration::from_secs(lock_expiry + 1);
DEFAULT_LOCK_EXPIRY + 1,
);
locked.due = task_due; locked.due = task_due;
tasks.push(( tasks.push((
TaskJob { TaskJob {
@@ -411,9 +367,7 @@ impl TaskQueueManager for Server {
Entry::Vacant(entry) => { Entry::Vacant(entry) => {
entry.insert(Locked { entry.insert(Locked {
expires: Instant::now() expires: Instant::now()
+ std::time::Duration::from_secs( + std::time::Duration::from_secs(lock_expiry + 1),
DEFAULT_LOCK_EXPIRY + 1,
),
due: task_due, due: task_due,
revision: ipc.revision, revision: ipc.revision,
}); });
@@ -446,6 +400,11 @@ impl TaskQueueManager for Server {
); );
}); });
if !unreadable.is_empty() && repair_due_rows(self, unreadable).await {
// Look again at once for the rows that were rewritten
self.notify_task_queue();
}
if !tasks.is_empty() { if !tasks.is_empty() {
trc::event!( trc::event!(
TaskManager(TaskManagerEvent::TaskAcquired), TaskManager(TaskManagerEvent::TaskAcquired),
@@ -464,12 +423,26 @@ impl TaskQueueManager for Server {
let tx = &ipc.txs[task_type_idx as usize]; let tx = &ipc.txs[task_type_idx as usize];
if tx.capacity() > 0 { if tx.capacity() > 0 {
if self.try_lock_task(task_job.id).await && tx.send(task_job).await.is_err() { let id = task_job.id;
if !self.try_lock_task(id).await {
// inbuxa: another node holds the task. Look again after a
// short while rather than a full lock lifetime from now:
// the holder may have claimed it after this scan began,
// or run on a clock ahead of this one, and waiting the
// whole lifetime again would leave the task stuck for
// another hour past its lock if that holder died
if let Some(locked) = ipc.locked.get_mut(&id) {
locked.expires =
Instant::now() + Duration::from_secs(claim_recheck_interval(lock_expiry));
}
} else if tx.send(task_job).await.is_err() {
trc::event!( trc::event!(
Server(trc::ServerEvent::ThreadError), Server(trc::ServerEvent::ThreadError),
Details = "Error sending task.", Details = "Error sending task.",
CausedBy = trc::location!() CausedBy = trc::location!()
); );
// inbuxa: nothing will run it here, so don't hold it
self.remove_index_lock(id).await;
} }
} else { } else {
// If the channel is full, release the lock so it can be picked up in the next iteration // If the channel is full, release the lock so it can be picked up in the next iteration
@@ -481,9 +454,178 @@ impl TaskQueueManager for Server {
let now = Instant::now(); let now = Instant::now();
ipc.locked ipc.locked
.retain(|_, locked| locked.expires > now && locked.revision == ipc.revision); .retain(|_, locked| locked.expires > now && locked.revision == ipc.revision);
Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| { let sleep_for = Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| {
timestamp.saturating_sub(store::write::now()) timestamp.saturating_sub(store::write::now())
})) }));
// inbuxa: wake up when a claim held elsewhere is due to be tried
// again, rather than only on the next task or refresh
ipc.locked
.values()
.map(|locked| locked.expires.saturating_duration_since(now))
.min()
.map_or(sleep_for, |recheck| sleep_for.min(recheck.max(Duration::from_secs(1))))
}
}
/// inbuxa: whether this node's cluster role lets it run a task type. Upstream
/// checked the dedicated roles (search indexing, account and store
/// maintenance, spam training) and let every node with a task manager run
/// the rest, whatever its taskQueueProcessing setting. Every task type now
/// answers to one ClusterTaskType:
///
/// - IndexDocument, UnindexDocument, IndexTrace: searchIndexing
/// - AccountMaintenance, TenantMaintenance, DestroyAccount: accountMaintenance
/// - StoreMaintenance: storeMaintenance
/// - SpamFilterMaintenance: spamClassifierTraining
/// - DmarcReport, TlsReport: outboundMta. They build and send reports to
/// other domains (TLS reports can go straight to an HTTPS endpoint), which
/// is the outbound MTA's business.
/// - CalendarAlarmEmail, CalendarAlarmNotification, CalendarItipMessage,
/// MergeThreads, RestoreArchivedItem, AcmeRenewal, DkimManagement,
/// DnsManagement: taskQueueProcessing, the role for queue tasks with no
/// role of their own.
///
/// A node that may not run a task leaves it unclaimed, so a node that may
/// picks it up.
pub fn task_enabled(roles: &ClusterRoles, task_type: TaskType) -> bool {
match task_type {
TaskType::IndexDocument | TaskType::UnindexDocument | TaskType::IndexTrace => {
roles.search_indexing
}
TaskType::AccountMaintenance | TaskType::TenantMaintenance | TaskType::DestroyAccount => {
roles.account_maintenance
}
TaskType::StoreMaintenance => roles.store_maintenance,
TaskType::SpamFilterMaintenance => roles.spam_training,
TaskType::DmarcReport | TaskType::TlsReport => roles.outbound_mta,
TaskType::CalendarAlarmEmail
| TaskType::CalendarAlarmNotification
| TaskType::CalendarItipMessage
| TaskType::MergeThreads
| TaskType::RestoreArchivedItem
| TaskType::AcmeRenewal
| TaskType::DkimManagement
| TaskType::DnsManagement => roles.task_manager,
}
}
async fn run_task(
server: &Server,
task: &Task,
server_instance: Arc<ServerInstance>,
) -> TaskResult {
match task {
Task::CalendarAlarmEmail(task) => {
server.send_email_alarm(task, server_instance.clone()).await
}
Task::CalendarAlarmNotification(task) => {
server.send_display_alarm(task).await
}
Task::CalendarItipMessage(task) => {
server.send_imip(task, server_instance.clone()).await
}
Task::MergeThreads(task) => server.merge_threads(task).await,
Task::DmarcReport(task) => {
server
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
.await
}
Task::TlsReport(task) => {
server
.submit_report(report::ReportId::Tls(task.report_id.id()))
.await
}
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
Task::DestroyAccount(task) => server.destroy_account(task).await,
Task::AccountMaintenance(task) => {
server.account_maintenance(task).await
}
Task::TenantMaintenance(task) => {
server.tenant_maintenance(task).await
}
Task::StoreMaintenance(task) => {
server.store_maintenance(task).await
}
Task::SpamFilterMaintenance(task) => {
Box::pin(server.spam_filter_maintenance(task)).await
}
Task::AcmeRenewal(task) => server.acme_management(task).await,
Task::DkimManagement(task_dkim_rotation) => {
server.dkim_management(task_dkim_rotation).await
}
Task::DnsManagement(task_dns_management) => {
server.dns_management(task_dns_management).await
}
Task::IndexDocument(_)
| Task::UnindexDocument(_)
| Task::IndexTrace(_) => unreachable!(),
}
}
/// inbuxa: reads a claimed task when this node's role still allows its type.
/// The role may have changed since the task was claimed (a settings reload in
/// between); the claim is then handed back at once for a node that may run
/// it, rather than held until the lease runs out.
async fn fetch_enabled_task(server: &Server, job: TaskJob) -> Option<TaskDetails> {
if task_enabled(&server.core.network.roles, job.typ) {
fetch_task(server, job).await
} else {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Details = job.typ.as_str(),
Reason = "Task type was disabled by cluster roles after it was claimed.",
);
server.remove_index_lock(job.id).await;
None
}
}
/// Reads a claimed task. When it is gone or can't be read, the claim is
/// released: inbuxa: holding it would block the task, everywhere, until
/// the lock expired.
async fn fetch_task(server: &Server, job: TaskJob) -> Option<TaskDetails> {
match server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task {
id: job.id,
})))
.await
{
Ok(Some(task)) => Some(TaskDetails { task, info: job }),
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
server.remove_index_lock(job.id).await;
None
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
server.remove_index_lock(job.id).await;
None
}
}
}
/// inbuxa: a task panicked: its locks are released so it runs again, here or
/// on another node, and the worker carries on.
async fn worker_failed(server: &Server, ids: &[u64], err: tokio::task::JoinError) {
trc::event!(
Server(trc::ServerEvent::ThreadError),
Details = "Task worker failed",
Reason = err.to_string(),
CausedBy = trc::location!()
);
for id in ids {
server.remove_index_lock(*id).await;
} }
} }
@@ -614,6 +756,13 @@ async fn update_tasks(
} }
} }
/// inbuxa: how long to wait before trying again to claim a task another node
/// holds: a twelfth of the lock lifetime, so five minutes for the one-hour
/// lock, never more than that and never under a second.
pub(crate) fn claim_recheck_interval(lock_expiry: u64) -> u64 {
(lock_expiry / 12).clamp(1, CLAIM_RECHECK_INTERVAL)
}
pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> { pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
matches!( matches!(
typ, typ,
@@ -686,3 +835,114 @@ impl TaskResult {
) )
} }
} }
/// inbuxa: a task queue row whose task type could not be read.
struct UnreadableDueRow {
due: u64,
id: u64,
value: Vec<u8>,
}
/// inbuxa: logs each unreadable queue row and repairs it from the task it
/// schedules. The task row says what the task is, so the queue row is
/// rewritten with that task's type; a row with no task behind it is removed.
///
/// Rescheduling an internal DMARC or TLS report wrote the report's object
/// type into the queue row instead of the task type. Such a row is the time
/// an administrator chose, so the task is moved to it as the reschedule
/// meant to do: the task row takes that due, and a queue row left at the
/// task's previous due is removed. Returns whether any row was repaired.
async fn repair_due_rows(server: &Server, rows: Vec<UnreadableDueRow>) -> bool {
let mut repaired = false;
for row in rows {
let UnreadableDueRow { due, id, value } = row;
trc::error!(
trc::StoreEvent::DataCorruption
.into_err()
.id(id)
.ctx(trc::Key::Due, trc::Value::Timestamp(due))
.ctx(
trc::Key::Key,
[due.to_be_bytes(), id.to_be_bytes()].concat()
)
.ctx(trc::Key::Value, value.clone())
.details("Unreadable task queue row skipped")
.caused_by(trc::location!())
);
let task_key = ValueClass::TaskQueue(TaskQueueClass::Task { id });
let due_key = ValueClass::TaskQueue(TaskQueueClass::Due { id, due });
let task = match server
.store()
.get_value::<Task>(ValueKey::from(task_key.clone()))
.await
{
Ok(task) => task,
Err(err) => {
trc::error!(
err.id(id)
.details("Failed to read the task of an unreadable queue row.")
.caused_by(trc::location!())
);
continue;
}
};
let mut batch = BatchBuilder::new();
let action = if let Some(mut task) = task {
let task_type = task.object_type();
batch.assert_value(task_key.clone(), AssertValue::Some);
if rescheduled_report_type(&value) == Some(task_type) {
let old_due = task.due_timestamp();
if old_due != due {
batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id,
due: old_due,
}));
}
task.set_status(TaskStatus::at(due as i64));
}
batch
.set(due_key, task_type.to_id().serialize())
.set(task_key, task.to_pickled_vec());
"Rewrote the queue row from its task."
} else {
batch.clear(due_key);
"Removed a queue row with no task."
};
match server.store().write(batch.build_all()).await {
Ok(_) => {
repaired = true;
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = id,
Due = trc::Value::Timestamp(due),
Reason = action,
);
}
Err(err) if err.matches(trc::EventType::Store(trc::StoreEvent::AssertValueFailed)) => {
// The task went away meanwhile; the next scan looks again
}
Err(err) => {
trc::error!(
err.id(id)
.details("Failed to repair an unreadable queue row.")
.caused_by(trc::location!())
);
}
}
}
repaired
}
/// inbuxa: the task type a report reschedule meant, when a queue row holds
/// an internal report's object type (the value that reschedule wrote).
fn rescheduled_report_type(value: &[u8]) -> Option<TaskType> {
let id = u16::from_be_bytes(value.get(..2)?.try_into().ok()?);
match ObjectType::from_id(id)? {
ObjectType::DmarcInternalReport => Some(TaskType::DmarcReport),
ObjectType::TlsInternalReport => Some(TaskType::TlsReport),
_ => None,
}
}
+3 -1
View File
@@ -35,7 +35,9 @@ pub mod scheduler;
pub mod spam_classifier; pub mod spam_classifier;
const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes
const DEFAULT_LOCK_EXPIRY: u64 = 60 * 60; // 1 hour // inbuxa: the lock lifetime (one hour) lives in common::ipc::TaskLocks, per
// server, so a graceful stop can release the locks and the tests can shorten it
const CLAIM_RECHECK_INTERVAL: u64 = 60 * 5; // 5 minutes
pub(crate) struct TaskManagerIpc { pub(crate) struct TaskManagerIpc {
txs: [mpsc::Sender<TaskJob>; TaskType::COUNT], txs: [mpsc::Sender<TaskJob>; TaskType::COUNT],
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::{TaskFailureType, TaskResult}; use crate::task_manager::{TaskFailureType, TaskResult};
use common::{ use common::{
Server, Server,
ipc::{BroadcastEvent, RegistryChange}, ipc::{BroadcastEvent, RegistryChange},
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource}, manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource, spam_rules},
}; };
use registry::{ use registry::{
schema::{ schema::{
@@ -106,6 +108,7 @@ struct RuleUpdateResult {
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> { async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
let started = Instant::now(); let started = Instant::now();
let bundled = server.core.spam.spam_rules_url.is_none();
let rules = match fetch_spam_rules(server).await { let rules = match fetch_spam_rules(server).await {
Ok(rules) => rules, Ok(rules) => rules,
Err(err) => { Err(err) => {
@@ -289,29 +292,36 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
Elapsed = started.elapsed(), Elapsed = started.elapsed(),
); );
// inbuxa: so the next start knows these bundled rules are in
if bundled {
spam_rules::set_applied_version(server.store(), spam_rules::BUNDLED_SPAM_RULES_VERSION)
.await?;
}
Ok(TaskResult::Success(vec![])) Ok(TaskResult::Success(vec![]))
} }
async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> { async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
let Some(rules_url) = server.core.spam.spam_rules_url.as_ref() else { // inbuxa: no URL means the rules bundled with the server
return Err(RuleUpdateError { let bytes = match server.core.spam.spam_rules_url.as_ref() {
typ: TaskFailureType::Permanent, Some(rules_url) => fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
reason: "Spam rules resource URL not configured".to_string(),
});
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
.await .await
.map_err(|reason| RuleUpdateError { .map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Temporary, typ: TaskFailureType::Temporary,
reason, reason,
}),
None => spam_rules::bundled_rules().map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason,
}),
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
bytes.and_then(|bytes| {
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason: format!("Failed to parse spam rules JSON: {err}"),
}) })
.and_then(|bytes| { })?;
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason: format!("Failed to parse spam rules JSON: {err}"),
})
})?;
let mut rules = Rules::default(); let mut rules = Rules::default();
for (object_type, values) in rules_json { for (object_type, values) in rules_json {
+15 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::config::smtp::session::Milter; use common::config::smtp::session::Milter;
@@ -25,7 +27,19 @@ impl MilterClient<TcpStream> {
pub async fn connect(config: &Milter, session_id: u64) -> Result<Self> { pub async fn connect(config: &Milter, session_id: u64) -> Result<Self> {
tokio::time::timeout(config.timeout_command, async { tokio::time::timeout(config.timeout_command, async {
let mut last_err = Error::Disconnected; let mut last_err = Error::Disconnected;
for addr in &config.addrs { // inbuxa: a hostname is resolved here, per connection, rather
// than while the settings are built
let resolved;
let addrs = if config.addrs.is_empty() {
resolved = tokio::net::lookup_host((config.hostname.as_str(), config.port))
.await
.map_err(Error::Io)?
.collect::<Vec<_>>();
&resolved
} else {
&config.addrs
};
for addr in addrs {
match TcpStream::connect(addr).await { match TcpStream::connect(addr).await {
Ok(stream) => { Ok(stream) => {
return Ok(MilterClient { return Ok(MilterClient {
+10 -1
View File
@@ -44,7 +44,16 @@ impl StartQueueManager for BootManager {
impl SpawnQueueManager for IpcReceivers { impl SpawnQueueManager for IpcReceivers {
fn spawn_queue_manager(&mut self, inner: Arc<Inner>) { fn spawn_queue_manager(&mut self, inner: Arc<Inner>) {
let core = inner.shared_core.load(); let core = inner.shared_core.load();
if !core.storage.registry.is_recovery_mode() && core.network.roles.outbound_mta { // inbuxa: upstream started these only when the node's role included
// outboundMta at boot, so turning the role on later did nothing and
// turning it off left them delivering until a restart. They now run
// on every node: the queue follows the role live (see Queue::start),
// and the report scheduler records DMARC and TLS results on every
// node, whatever its role (see reporting/scheduler.rs). This also
// drains the queue channel on nodes
// without the role, where every queued message's refresh used to sit
// in a channel nobody read until it filled and queueing blocked.
if !core.storage.registry.is_recovery_mode() {
// Spawn queue manager // Spawn queue manager
self.queue_rx.take().unwrap().spawn(inner.clone()); self.queue_rx.take().unwrap().spawn(inner.clone());
+30
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{Message, QueueId, Status, spool::SmtpSpool}; use super::{Message, QueueId, Status, spool::SmtpSpool};
@@ -39,6 +41,9 @@ pub struct Queue {
pub urgent_refresh: bool, pub urgent_refresh: bool,
pub last_scan: Instant, pub last_scan: Instant,
pub last_full_scan: Instant, pub last_full_scan: Instant,
/// inbuxa: whether this node's role included outboundMta when last
/// checked (None before the first check)
pub role_enabled: Option<bool>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -67,6 +72,9 @@ impl SpawnQueue for mpsc::Receiver<QueueEvent> {
const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60); const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60);
const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100); const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100);
const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2); const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2);
/// inbuxa: how often a node without the outbound MTA role looks at its role
/// again when nothing else wakes it (a settings reload does)
const ROLE_RECHECK_INTERVAL: Duration = Duration::from_secs(30);
impl Queue { impl Queue {
pub fn new(core: Arc<Inner>, rx: mpsc::Receiver<QueueEvent>) -> Self { pub fn new(core: Arc<Inner>, rx: mpsc::Receiver<QueueEvent>) -> Self {
@@ -87,6 +95,7 @@ impl Queue {
urgent_refresh: false, urgent_refresh: false,
last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now), last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now),
last_full_scan: now, last_full_scan: now,
role_enabled: None,
} }
} }
@@ -123,6 +132,27 @@ impl Queue {
continue; continue;
} }
// inbuxa: follow the node's role live. Without outboundMta the
// queue claims nothing new; deliveries already running finish
// and report back as usual, releasing their locks. When the role
// comes back, the whole queue is scanned at once.
let role_enabled = self.core.shared_core.load().network.roles.outbound_mta;
if self.role_enabled.replace(role_enabled) == Some(false) && role_enabled {
trc::event!(
Queue(trc::QueueEvent::Started),
Details = "This node's cluster role now includes outboundMta",
);
self.scan_from = 0;
self.pending_refresh = true;
self.urgent_refresh = true;
}
if !role_enabled {
self.pending_refresh = false;
self.urgent_refresh = false;
self.next_refresh = Instant::now() + ROLE_RECHECK_INTERVAL;
continue;
}
self.pending_refresh |= refresh_queue; self.pending_refresh |= refresh_queue;
if !self.pending_refresh && self.next_refresh > Instant::now() { if !self.pending_refresh && self.next_refresh > Instant::now() {
continue; continue;
+43 -23
View File
@@ -2,9 +2,12 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::AggregateTimestamp; use super::AggregateTimestamp;
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
use crate::{ use crate::{
core::Session, core::Session,
queue::RecipientDomain, queue::RecipientDomain,
@@ -349,29 +352,43 @@ impl DmarcReporting for Server {
let object_id = ObjectType::DmarcInternalReport.to_id(); let object_id = ObjectType::DmarcInternalReport.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
let Some(report) = self // Delete report. inbuxa: only the version read here, so a record
.store() // another node appends meanwhile is sent with it rather than lost
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone())) let mut attempt = 0;
.await let report = loop {
.caused_by(trc::location!())? let Some(Revisioned {
else { revision,
return Ok(()); value: report,
}; }) = self
.store()
.get_value::<Revisioned<DmarcInternalReport>>(ValueKey::from(key.clone()))
.await
.caused_by(trc::location!())?
else {
return Ok(());
};
// Delete report let mut batch = BatchBuilder::new();
let mut batch = BatchBuilder::new(); batch
batch.clear(key).clear(RegistryClass::PrimaryKey { .assert_value(key.clone(), AssertValue::Hash(revision))
object_id: object_id.into(), .clear(key.clone())
index_id: Property::Domain.to_id(), .clear(RegistryClass::PrimaryKey {
key: KeySerializer::new(report.domain.len() + U64_LEN) object_id: object_id.into(),
.write(&report.domain) index_id: Property::Domain.to_id(),
.write(report.policy_identifier) key: KeySerializer::new(report.domain.len() + U64_LEN)
.finalize(), .write(&report.domain)
}); .write(report.policy_identifier)
self.store() .finalize(),
.write(batch.build_all()) });
.await match self.store().write(batch.build_all()).await {
.caused_by(trc::location!())?; Ok(_) => break report,
Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
attempt += 1;
write_retry_pause(attempt).await;
}
Err(err) => return Err(err.caused_by(trc::location!())),
}
};
let span_id = self.inner.data.span_id_gen.generate(); let span_id = self.inner.data.span_id_gen.generate();
let event_from = report.report.date_range_begin.timestamp() as u64; let event_from = report.report.date_range_begin.timestamp() as u64;
@@ -676,8 +693,11 @@ impl DmarcReporting for Server {
break; break;
} }
Err(err) => { Err(err) => {
if err.is_assertion_failure() && rety_count < 3 { // inbuxa: another node appended first; try again
// after a short pause
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
rety_count += 1; rety_count += 1;
write_retry_pause(rety_count).await;
continue; continue;
} }
trc::error!( trc::error!(
+29 -13
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use registry::{ use registry::{
@@ -40,35 +42,49 @@ pub trait InternalReportIndex: ObjectImpl {
fn primary_key(&self) -> ValueClass; fn primary_key(&self) -> ValueClass;
/// Moves the report's delivery, and its queued task, to `at`.
///
/// inbuxa: the new queue row carries the task's type, as
/// `schedule_task_with_id` writes it, and the task row gets the new due
/// too. `queued` is the task as stored: its due, not the report's
/// `deliverAt`, is the queue row that exists (they differ once the task
/// has been retried).
fn reschedule_ops( fn reschedule_ops(
&mut self, &mut self,
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
item_id: u64, item_id: u64,
revision: u64, revision: u64,
at: UTCDateTime, at: UTCDateTime,
queued: Option<&Task>,
) { ) {
let current_deliver_at = self.deliver_at(); let current_deliver_at = self.deliver_at();
let current_due = current_deliver_at.timestamp() as u64;
let queued_due = queued.map_or(current_due, |task| task.due_timestamp());
let new_due = at.timestamp() as u64;
if current_deliver_at != at { if current_deliver_at != at || queued_due != new_due {
let object = Self::OBJECT; let object = Self::OBJECT;
let object_id = object.to_id(); let object_id = object.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
self.set_deliver_at(at); self.set_deliver_at(at);
batch batch.assert_value(key.clone(), AssertValue::Hash(revision));
.assert_value(key.clone(), AssertValue::Hash(revision)) if queued_due != new_due {
.clear(ValueClass::TaskQueue(TaskQueueClass::Due { batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id, id: item_id,
due: current_deliver_at.timestamp() as u64, due: queued_due,
})) }));
.set( }
ValueClass::TaskQueue(TaskQueueClass::Due { // A row an earlier reschedule left at the report's deliverAt
id: item_id, if current_due != new_due && current_due != queued_due {
due: at.timestamp() as u64, batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
}), id: item_id,
object_id.serialize(), due: current_due,
) }));
}
batch
.schedule_task_with_id(item_id, self.task(item_id))
.set(key, self.to_pickled_vec()); .set(key, self.to_pickled_vec());
} }
} }
+3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::config::smtp::report::AggregateFrequency; use common::config::smtp::report::AggregateFrequency;
@@ -15,6 +17,7 @@ pub mod inbound;
pub mod index; pub mod index;
pub mod scheduler; pub mod scheduler;
pub mod send; pub mod send;
pub mod shared; // inbuxa: reports written by every node
pub mod spf; pub mod spf;
pub mod tls; pub mod tls;
+13
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{dmarc::DmarcReporting, tls::TlsReporting}; use super::{dmarc::DmarcReporting, tls::TlsReporting};
@@ -18,6 +20,17 @@ impl SpawnReport for mpsc::Receiver<ReportingEvent> {
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = self.recv().await { while let Some(event) = self.recv().await {
let server = inner.build_server(); let server = inner.build_server();
// inbuxa: every node records what it received, whatever its
// role. An aggregate report covers all of a domain's mail,
// whichever node took it, and recording is a store write
// that nodes already share: the report's primary key is
// versioned, so concurrent appends from several nodes retry
// rather than overwrite. Only building and sending the
// report (the DmarcReport and TlsReport tasks) belongs to
// the outbound MTA; the task manager keeps those to nodes
// with that role. Upstream ran this only on outbound MTA
// nodes, so mail received anywhere else never reached a
// report.
match event { match event {
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await, ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
ReportingEvent::Tls(event) => server.schedule_tls(event).await, ReportingEvent::Tls(event) => server.schedule_tls(event).await,
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: internal DMARC and TLS reports are shared by every node. Any node
//! that receives mail appends to them, so several nodes can write one report
//! at once, and the node that sends it may do so while another is appending.
//! Appends already guard the report's versioned primary key and retry when
//! another writer got there first; these helpers give those retries room and
//! let the sender delete exactly the report it read.
use rand::RngExt;
use std::time::Duration;
use store::{Deserialize, xxhash_rust::xxh3::xxh3_64};
/// How many times a report write that lost to another writer is retried.
/// Upstream retried three times, when only outbound MTA nodes wrote.
pub(crate) const MAX_WRITE_RETRIES: u32 = 10;
/// A short random pause, longer on each attempt, before retrying a report
/// write that lost to another node, so the writers spread out instead of
/// colliding again.
pub(crate) async fn write_retry_pause(attempt: u32) {
let ms = rand::rng().random_range(5..=25u64) * u64::from(attempt.max(1));
tokio::time::sleep(Duration::from_millis(ms)).await;
}
/// A stored value with the hash of the bytes it was read from, for
/// `AssertValue::Hash`: a write asserting it fails if anyone changed the
/// value since.
pub(crate) struct Revisioned<T> {
pub revision: u64,
pub value: T,
}
impl<T: Deserialize> Deserialize for Revisioned<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Revisioned {
revision: xxh3_64(bytes),
value: T::deserialize(bytes)?,
})
}
}
+40 -22
View File
@@ -2,9 +2,12 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::AggregateTimestamp; use super::AggregateTimestamp;
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
use crate::{ use crate::{
queue::RecipientDomain, queue::RecipientDomain,
reporting::{index::InternalReportIndex, send::MtaReportSend}, reporting::{index::InternalReportIndex, send::MtaReportSend},
@@ -70,28 +73,40 @@ impl TlsReporting for Server {
let object_id = ObjectType::TlsInternalReport.to_id(); let object_id = ObjectType::TlsInternalReport.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
let Some(report) = self // Delete report. inbuxa: only the version read here, so a result
.store() // another node appends meanwhile is sent with it rather than lost
.get_value::<TlsInternalReport>(ValueKey::from(key.clone())) let mut attempt = 0;
.await let report = loop {
.caused_by(trc::location!())? let Some(Revisioned {
else { revision,
return Ok(()); value: report,
}; }) = self
.store()
.get_value::<Revisioned<TlsInternalReport>>(ValueKey::from(key.clone()))
.await
.caused_by(trc::location!())?
else {
return Ok(());
};
// Delete report let mut batch = BatchBuilder::new();
let mut batch = BatchBuilder::new(); batch
batch.clear(key).clear(RegistryClass::PrimaryKey { .assert_value(key.clone(), AssertValue::Hash(revision))
object_id: object_id.into(), .clear(key.clone())
index_id: Property::Domain.to_id(), .clear(RegistryClass::PrimaryKey {
key: report.domain.as_bytes().to_vec(), object_id: object_id.into(),
}); index_id: Property::Domain.to_id(),
self.core key: report.domain.as_bytes().to_vec(),
.storage });
.data match self.core.storage.data.write(batch.build_all()).await {
.write(batch.build_all()) Ok(_) => break report,
.await Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
.caused_by(trc::location!())?; attempt += 1;
write_retry_pause(attempt).await;
}
Err(err) => return Err(err.caused_by(trc::location!())),
}
};
let domain_name = report.domain.as_str(); let domain_name = report.domain.as_str();
let event_from = report.report.date_range_start.timestamp() as u64; let event_from = report.report.date_range_start.timestamp() as u64;
@@ -477,8 +492,11 @@ impl TlsReporting for Server {
break; break;
} }
Err(err) => { Err(err) => {
if err.is_assertion_failure() && rety_count < 3 { // inbuxa: another node appended first; try again
// after a short pause
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
rety_count += 1; rety_count += 1;
write_retry_pause(rety_count).await;
continue; continue;
} }
trc::error!( trc::error!(
+15 -10
View File
@@ -48,19 +48,24 @@ pub(crate) async fn pyzor_check(
// Send message to address. inbuxa: in tests, a fixed table answers // Send message to address. inbuxa: in tests, a fixed table answers
// instead of a public server (test_response). // instead of a public server (test_response).
#[cfg(not(feature = "test_mode"))] #[cfg(not(feature = "test_mode"))]
let response = pyzor_send_message(config.address, config.timeout, &request).await; let response = match tokio::time::timeout(config.timeout, config.address()).await {
Ok(Ok(address)) => pyzor_send_message(address, config.timeout, &request).await,
Ok(Err(err)) => Err(err),
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out resolving the Pyzor server",
)),
};
#[cfg(feature = "test_mode")] #[cfg(feature = "test_mode")]
let response = std::io::Result::Ok(test_response(&request)); let response = std::io::Result::Ok(test_response(&request));
response response.map(Into::into).map_err(|err| {
.map(Into::into) trc::SpamEvent::PyzorError
.map_err(|err| { .into_err()
trc::SpamEvent::PyzorError .ctx(trc::Key::Url, format!("{}:{}", config.host, config.port))
.into_err() .reason(err)
.ctx(trc::Key::Url, config.address.to_string()) .details("Pyzor failed")
.reason(err) })
.details("Pyzor failed")
})
} }
/// inbuxa: the answers tests get, by digest, instead of a public server's, /// inbuxa: the answers tests get, by digest, instead of a public server's,
+3
View File
@@ -30,6 +30,9 @@ pub mod s3;
pub mod sqlite; pub mod sqlite;
// inbuxa: scale-out storage (sharded stores) // inbuxa: scale-out storage (sharded stores)
pub mod scaleout; pub mod scaleout;
// inbuxa: client-side SQL query limits
#[cfg(any(feature = "postgres", feature = "mysql"))]
pub mod query_timeout;
pub const MAX_TOKEN_LENGTH: usize = (u8::MAX >> 1) as usize; pub const MAX_TOKEN_LENGTH: usize = (u8::MAX >> 1) as usize;
+55 -38
View File
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::ops::Range; use std::ops::Range;
use mysql_async::prelude::Queryable; use mysql_async::prelude::Queryable;
use super::{MysqlStore, into_error}; use super::{MysqlStore, bounded, into_error};
impl MysqlStore { impl MysqlStore {
pub(crate) async fn get_blob( pub(crate) async fn get_blob(
@@ -16,49 +18,64 @@ impl MysqlStore {
key: &[u8], key: &[u8],
range: Range<usize>, range: Range<usize>,
) -> trc::Result<Option<Vec<u8>>> { ) -> trc::Result<Option<Vec<u8>>> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep("SELECT v FROM t WHERE k = ?") let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prep("SELECT v FROM t WHERE k = ?")
conn.exec_first::<Vec<u8>, _, _>(&s, (key,)) .await
.await .map_err(into_error)?;
.map(|bytes| { conn.exec_first::<Vec<u8>, _, _>(&s, (key,))
if range.start == 0 && range.end == usize::MAX { .await
bytes .map(|bytes| {
} else { if range.start == 0 && range.end == usize::MAX {
bytes.map(|bytes| {
bytes bytes
.get(range.start..std::cmp::min(bytes.len(), range.end)) } else {
.unwrap_or_default() bytes.map(|bytes| {
.to_vec() bytes
}) .get(range.start..std::cmp::min(bytes.len(), range.end))
} .unwrap_or_default()
}) .to_vec()
.map_err(into_error) })
}
})
.map_err(into_error)
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)") let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)")
conn.exec_drop(&s, (key, data)) .await
.await .map_err(into_error)?;
.map_err(into_error) conn.exec_drop(&s, (key, data))
.map(|_| ()) .await
.map_err(into_error)
.map(|_| ())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> { pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep("DELETE FROM t WHERE k = ?") let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prep("DELETE FROM t WHERE k = ?")
conn.exec_iter(&s, (key,)) .await
.await .map_err(into_error)?;
.map_err(into_error) conn.exec_iter(&s, (key,))
.map(|hits| hits.affected_rows() > 0) .await
.map_err(into_error)
.map(|hits| hits.affected_rows() > 0)
})
.await;
bounded(conn, result, limit)
} }
} }
+29 -22
View File
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use mysql_async::{Params, Row, prelude::Queryable}; use mysql_async::{Params, Row, prelude::Queryable};
use crate::{IntoRows, QueryResult, QueryType, Value}; use crate::{IntoRows, QueryResult, QueryType, Value};
use super::{MysqlStore, into_error}; use super::{MysqlStore, bounded, into_error};
impl MysqlStore { impl MysqlStore {
pub(crate) async fn sql_query<T: QueryResult>( pub(crate) async fn sql_query<T: QueryResult>(
@@ -16,28 +18,33 @@ impl MysqlStore {
query: &str, query: &str,
params: &[Value<'_>], params: &[Value<'_>],
) -> trc::Result<T> { ) -> trc::Result<T> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn.prep(query).await.map_err(into_error)?; let limit = self.timeouts.query;
let params = Params::Positional(params.iter().map(Into::into).collect()); let result = tokio::time::timeout(limit, async {
let s = conn.prep(query).await.map_err(into_error)?;
let params = Params::Positional(params.iter().map(Into::into).collect());
match T::query_type() { match T::query_type() {
QueryType::Execute => conn.exec_drop(s, params).await.map_or_else( QueryType::Execute => conn.exec_drop(s, params).await.map_or_else(
|e| Err(into_error(e)), |e| Err(into_error(e)),
|_| Ok(T::from_exec(conn.affected_rows() as usize)), |_| Ok(T::from_exec(conn.affected_rows() as usize)),
), ),
QueryType::Exists => conn QueryType::Exists => conn
.exec_first::<Row, _, _>(s, params) .exec_first::<Row, _, _>(s, params)
.await .await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))), .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))),
QueryType::QueryOne => conn QueryType::QueryOne => conn
.exec_first::<Row, _, _>(s, params) .exec_first::<Row, _, _>(s, params)
.await .await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))), .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
QueryType::QueryAll => conn QueryType::QueryAll => conn
.exec::<Row, _, _>(s, params) .exec::<Row, _, _>(s, params)
.await .await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))), .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
} }
})
.await;
bounded(conn, result, limit)
} }
} }
+86 -73
View File
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{MysqlStore, into_error}; use super::{MysqlStore, bounded, into_error};
use crate::{ use crate::{
backend::mysql::MysqlSearchField, backend::mysql::MysqlSearchField,
search::{ search::{
@@ -32,6 +32,9 @@ impl MysqlStore {
.max_allowed_packet(config.max_allowed_packet.map(|v| v as usize)) .max_allowed_packet(config.max_allowed_packet.map(|v| v as usize))
.wait_timeout(config.timeout.map(|t| t.as_secs() as usize)) .wait_timeout(config.timeout.map(|t| t.as_secs() as usize))
.client_found_rows(true) .client_found_rows(true)
// inbuxa: notice a server that went away without closing the
// connection in minutes, not the system default of two hours
.tcp_keepalive(Some(super::POOL_KEEPALIVE_IDLE))
.tcp_port(config.port as u16); .tcp_port(config.port as u16);
if config.use_tls { if config.use_tls {
@@ -69,6 +72,7 @@ impl MysqlStore {
.db_name(Some(replica.database.clone())) .db_name(Some(replica.database.clone()))
.tcp_port(replica.port as u16), .tcp_port(replica.port as u16),
), ),
timeouts: Default::default(),
})), })),
replica.host, replica.host,
replica.port as u16, replica.port as u16,
@@ -78,6 +82,7 @@ impl MysqlStore {
let primary = Store::MySQL(Arc::new(MysqlStore { let primary = Store::MySQL(Arc::new(MysqlStore {
conn_pool: Pool::new(opts), conn_pool: Pool::new(opts),
timeouts: Default::default(),
})); }));
// ST-1: no replicas, no change // ST-1: no replicas, no change
@@ -95,89 +100,97 @@ impl MysqlStore {
} }
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> { pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
for table in [
SUBSPACE_ACL,
SUBSPACE_TASK_QUEUE,
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
crate::SUBSPACE_INBUXA, // inbuxa: masked email
SUBSPACE_BLOB_LINK,
SUBSPACE_IN_MEMORY_VALUE,
SUBSPACE_PROPERTY,
SUBSPACE_REGISTRY,
SUBSPACE_REGISTRY_PK,
SUBSPACE_DIRECTORY,
SUBSPACE_QUEUE_MESSAGE,
SUBSPACE_QUEUE_EVENT,
SUBSPACE_REPORT_OUT,
SUBSPACE_REPORT_IN,
SUBSPACE_LOGS,
SUBSPACE_TELEMETRY_SPAN,
SUBSPACE_TELEMETRY_METRIC,
] {
let table = char::from(table);
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {table} (
k VARBINARY(255) NOT NULL,
v MEDIUMBLOB NOT NULL,
PRIMARY KEY (k)
) ENGINE=InnoDB"
))
.await
.map_err(into_error)?;
}
for table in [
SUBSPACE_ACL,
SUBSPACE_TASK_QUEUE,
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
crate::SUBSPACE_INBUXA, // inbuxa: masked email
SUBSPACE_BLOB_LINK,
SUBSPACE_IN_MEMORY_VALUE,
SUBSPACE_PROPERTY,
SUBSPACE_REGISTRY,
SUBSPACE_REGISTRY_PK,
SUBSPACE_DIRECTORY,
SUBSPACE_QUEUE_MESSAGE,
SUBSPACE_QUEUE_EVENT,
SUBSPACE_REPORT_OUT,
SUBSPACE_REPORT_IN,
SUBSPACE_LOGS,
SUBSPACE_TELEMETRY_SPAN,
SUBSPACE_TELEMETRY_METRIC,
] {
let table = char::from(table);
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {table} (
k VARBINARY(255) NOT NULL,
v MEDIUMBLOB NOT NULL,
PRIMARY KEY (k)
) ENGINE=InnoDB"
))
.await
.map_err(into_error)?;
}
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {} (
k VARBINARY(255) NOT NULL,
v LONGBLOB NOT NULL,
PRIMARY KEY (k)
) ENGINE=InnoDB",
char::from(SUBSPACE_BLOBS),
))
.await
.map_err(into_error)?;
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
let table = char::from(table);
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {table} (
k BLOB,
PRIMARY KEY (k(400))
) ENGINE=InnoDB"
))
.await
.map_err(into_error)?;
}
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
conn.query_drop(format!( conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {} ( "CREATE TABLE IF NOT EXISTS {} (
k VARBINARY(255) NOT NULL, k VARBINARY(255) NOT NULL,
v BIGINT NOT NULL DEFAULT 0, v LONGBLOB NOT NULL,
PRIMARY KEY (k) PRIMARY KEY (k)
) ENGINE=InnoDB", ) ENGINE=InnoDB",
char::from(table) char::from(SUBSPACE_BLOBS),
)) ))
.await .await
.map_err(into_error)?; .map_err(into_error)?;
}
Ok(()) for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
let table = char::from(table);
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {table} (
k BLOB,
PRIMARY KEY (k(400))
) ENGINE=InnoDB"
))
.await
.map_err(into_error)?;
}
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
conn.query_drop(format!(
"CREATE TABLE IF NOT EXISTS {} (
k VARBINARY(255) NOT NULL,
v BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (k)
) ENGINE=InnoDB",
char::from(table)
))
.await
.map_err(into_error)?;
}
Ok(())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> { pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
create_search_tables::<EmailSearchField>(&mut conn).await?;
create_search_tables::<CalendarSearchField>(&mut conn).await?;
create_search_tables::<ContactSearchField>(&mut conn).await?;
//create_search_tables::<FileSearchField>(&mut conn).await?;
create_search_tables::<TracingSearchField>(&mut conn).await?;
create_search_tables::<EmailSearchField>(&mut conn).await?; Ok(())
create_search_tables::<CalendarSearchField>(&mut conn).await?; })
create_search_tables::<ContactSearchField>(&mut conn).await?; .await;
//create_search_tables::<FileSearchField>(&mut conn).await?; bounded(conn, result, limit)
create_search_tables::<TracingSearchField>(&mut conn).await?;
Ok(())
} }
} }
+68 -1
View File
@@ -6,6 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::backend::query_timeout::QueryTimeouts;
use crate::{ use crate::{
search::{ search::{
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField, CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
@@ -14,7 +15,7 @@ use crate::{
write::SearchIndex, write::SearchIndex,
}; };
use mysql_async::Pool; use mysql_async::Pool;
use std::fmt::Display; use std::{fmt::Display, time::Duration};
pub mod blob; pub mod blob;
pub mod lookup; pub mod lookup;
@@ -25,6 +26,72 @@ pub mod write;
pub struct MysqlStore { pub struct MysqlStore {
pub(crate) conn_pool: Pool, pub(crate) conn_pool: Pool,
/// inbuxa: client-side query limits (see backend::query_timeout)
pub(crate) timeouts: QueryTimeouts,
}
/// inbuxa: how long a request waits for a pooled connection (including
/// opening one). mysql_async's pool has no wait timeout, so upstream waited
/// forever when the server stopped answering.
pub(crate) const POOL_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// inbuxa: idle time before TCP keepalive probes start.
pub(crate) const POOL_KEEPALIVE_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
impl MysqlStore {
/// inbuxa: a pooled connection, or an error once POOL_WAIT_TIMEOUT has
/// passed without one.
pub(crate) async fn conn(&self) -> trc::Result<mysql_async::Conn> {
pool_conn(&self.conn_pool, POOL_WAIT_TIMEOUT).await
}
}
pub(crate) async fn pool_conn(
pool: &Pool,
wait: std::time::Duration,
) -> trc::Result<mysql_async::Conn> {
match tokio::time::timeout(wait, pool.get_conn()).await {
Ok(result) => result.map_err(into_error),
Err(_) => Err(trc::StoreEvent::MysqlError
.reason("Timed out waiting for a database connection")
.details(format!("No connection within {} s", wait.as_secs()))),
}
}
/// inbuxa: the error for an operation that ran past its time limit.
pub(crate) fn query_timeout_error(limit: Duration) -> trc::Error {
trc::StoreEvent::MysqlError
.reason("Query timed out")
.details(format!(
"No answer from the database within {} s",
limit.as_secs()
))
}
/// inbuxa: ends an operation run on `conn` under `limit`. When it ran out,
/// the connection is closed rather than returned to the pool: a query may
/// still be in flight on it, or a transaction open. Conn::disconnect marks
/// the connection closed before it sends anything, so even when the server
/// doesn't answer and the attempt is dropped, the pool discards it instead
/// of waiting to clean it up.
pub(crate) fn bounded<T>(
conn: mysql_async::Conn,
result: Result<trc::Result<T>, tokio::time::error::Elapsed>,
limit: Duration,
) -> trc::Result<T> {
match result {
Ok(result) => result,
Err(_) => {
discard(conn);
Err(query_timeout_error(limit))
}
}
}
/// inbuxa: closes a connection whose state is unknown (see bounded).
pub(crate) fn discard(conn: mysql_async::Conn) {
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(1), conn.disconnect()).await;
});
} }
#[inline(always)] #[inline(always)]
+115 -70
View File
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{MysqlStore, into_error, is_timeout_error}; use super::{MysqlStore, bounded, discard, into_error, is_timeout_error, query_timeout_error};
use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass}; use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass};
use futures::TryStreamExt; use futures::TryStreamExt;
use mysql_async::{Row, prelude::Queryable}; use mysql_async::{Row, prelude::Queryable};
@@ -14,41 +16,51 @@ impl MysqlStore {
where where
U: Deserialize + 'static, U: Deserialize + 'static,
{ {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep(format!( let result = tokio::time::timeout(limit, async {
"SELECT v FROM {} WHERE k = ?", let s = conn
char::from(key.subspace()) .prep(format!(
)) "SELECT v FROM {} WHERE k = ?",
.await char::from(key.subspace())
.map_err(into_error)?; ))
let key = key.serialize(0); .await
conn.exec_first::<Vec<u8>, _, _>(&s, (&key,)) .map_err(into_error)?;
.await let key = key.serialize(0);
.map_err(into_error) conn.exec_first::<Vec<u8>, _, _>(&s, (&key,))
.and_then(|r| { .await
if let Some(r) = r { .map_err(into_error)
Ok(Some(U::deserialize_owned_with_key(&key, r)?)) .and_then(|r| {
} else { if let Some(r) = r {
Ok(None) Ok(Some(U::deserialize_owned_with_key(&key, r)?))
} } else {
}) Ok(None)
}
})
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> { pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep(format!( let result = tokio::time::timeout(limit, async {
"SELECT 1 FROM {} WHERE k = ?", let s = conn
char::from(key.subspace()) .prep(format!(
)) "SELECT 1 FROM {} WHERE k = ?",
.await char::from(key.subspace())
.map_err(into_error)?; ))
let key = key.serialize(0); .await
conn.exec_first::<u8, _, _>(&s, (&key,)) .map_err(into_error)?;
.await let key = key.serialize(0);
.map_err(into_error) conn.exec_first::<u8, _, _>(&s, (&key,))
.map(|r| r.is_some()) .await
.map_err(into_error)
.map(|r| r.is_some())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn iterate<T: Key>( pub(crate) async fn iterate<T: Key>(
@@ -56,34 +68,42 @@ impl MysqlStore {
params: IterateParams<T>, params: IterateParams<T>,
mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send, mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send,
) -> trc::Result<()> { ) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let table = char::from(params.begin.subspace()); let table = char::from(params.begin.subspace());
let begin = params.begin.serialize(0); let begin = params.begin.serialize(0);
let end = params.end.serialize(0); let end = params.end.serialize(0);
let keys = if params.values { "k, v" } else { "k" }; let keys = if params.values { "k, v" } else { "k" };
let s = conn // inbuxa: a scan may run for hours, so the query limit bounds each
.prep(&match (params.first, params.ascending) { // wait for the database (preparing, the query starting, the next
(true, true) => { // row) rather than the scan. A wait that runs out closes the
format!( // connection.
"SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1" let limit = self.timeouts.query;
) let query = match (params.first, params.ascending) {
} (true, true) => {
(true, false) => { format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1")
format!( }
"SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1" (true, false) => {
) format!(
} "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1"
(false, true) => { )
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC") }
} (false, true) => {
(false, false) => { format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC")
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC") }
} (false, false) => {
}) format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC")
.await }
.map_err(into_error)?; };
let s = match tokio::time::timeout(limit, conn.prep(&query)).await {
Ok(s) => s.map_err(into_error)?,
Err(_) => {
discard(conn);
return Err(query_timeout_error(limit));
}
};
let mut from = begin; let mut from = begin;
let mut stalled = false;
let mut to = end; let mut to = end;
let mut resume_key = None; let mut resume_key = None;
@@ -92,13 +112,26 @@ impl MysqlStore {
let mut timed_out = false; let mut timed_out = false;
{ {
let mut rows = conn let mut rows = match tokio::time::timeout(
.exec_stream::<Row, _, _>(&s, (from.clone(), to.clone())) limit,
.await conn.exec_stream::<Row, _, _>(&s, (from.clone(), to.clone())),
.map_err(into_error)?; )
.await
{
Ok(rows) => rows.map_err(into_error)?,
// Leaves the scan loop for the timeout below
Err(_) => break,
};
loop { loop {
match rows.try_next().await { let next = match tokio::time::timeout(limit, rows.try_next()).await {
Ok(next) => next,
Err(_) => {
stalled = true;
break;
}
};
match next {
Ok(Some(mut row)) => { Ok(Some(mut row)) => {
let value = if params.values { let value = if params.values {
row.take_opt::<Vec<u8>, _>(1) row.take_opt::<Vec<u8>, _>(1)
@@ -134,6 +167,10 @@ impl MysqlStore {
} }
} }
if stalled {
break;
}
match last_key { match last_key {
Some(last_key) if timed_out => { Some(last_key) if timed_out => {
if params.ascending { if params.ascending {
@@ -146,6 +183,9 @@ impl MysqlStore {
_ => return Ok(()), _ => return Ok(()),
} }
} }
discard(conn);
Err(query_timeout_error(limit))
} }
pub(crate) async fn get_counter( pub(crate) async fn get_counter(
@@ -155,15 +195,20 @@ impl MysqlStore {
let key = key.into(); let key = key.into();
let table = char::from(key.subspace()); let table = char::from(key.subspace());
let key = key.serialize(0); let key = key.serialize(0);
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn let limit = self.timeouts.query;
.prep(format!("SELECT v FROM {table} WHERE k = ?")) let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prep(format!("SELECT v FROM {table} WHERE k = ?"))
match conn.exec_first::<i64, _, _>(&s, (key,)).await { .await
Ok(Some(num)) => Ok(num), .map_err(into_error)?;
Ok(None) => Ok(0), match conn.exec_first::<i64, _, _>(&s, (key,)).await {
Err(e) => Err(into_error(e)), Ok(Some(num)) => Ok(num),
} Ok(None) => Ok(0),
Err(e) => Err(into_error(e)),
}
})
.await;
bounded(conn, result, limit)
} }
} }
+175 -95
View File
@@ -2,14 +2,16 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
backend::{ backend::{
MAX_TOKEN_LENGTH, MAX_TOKEN_LENGTH,
mysql::{ mysql::{
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlSearchField, MysqlStore, into_error, DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlSearchField, MysqlStore, bounded,
is_timeout_error, into_error, is_timeout_error,
}, },
}, },
search::{ search::{
@@ -19,63 +21,68 @@ use crate::{
write::SearchIndex, write::SearchIndex,
}; };
use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable}; use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable};
use nlp::tokenizers::word::WordTokenizer; use nlp::{language::Language, tokenizers::word::WordTokenizer};
use std::fmt::Write; use std::fmt::Write;
impl MysqlStore { impl MysqlStore {
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> { pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let mut tx_opts = TxOpts::default(); let limit = self.timeouts.query;
tx_opts let result = tokio::time::timeout(limit, async {
.with_consistent_snapshot(false) let mut tx_opts = TxOpts::default();
.with_isolation_level(IsolationLevel::ReadCommitted); tx_opts
let mut trx = conn.start_transaction(tx_opts).await.map_err(into_error)?; .with_consistent_snapshot(false)
.with_isolation_level(IsolationLevel::ReadCommitted);
let mut trx = conn.start_transaction(tx_opts).await.map_err(into_error)?;
for document in documents { for document in documents {
let index = document.index; let index = document.index;
let primary_keys = index.primary_keys(); let primary_keys = index.primary_keys();
let all_fields = index.all_fields(); let all_fields = index.all_fields();
let mut fields = document.fields; let mut fields = document.fields;
let mut values = Vec::with_capacity(fields.len() + 2); let mut values = Vec::with_capacity(fields.len() + 2);
let mut query = format!("INSERT INTO {} (", index.mysql_table()); let mut query = format!("INSERT INTO {} (", index.mysql_table());
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
if i > 0 { if i > 0 {
query.push(','); query.push(',');
}
query.push_str(field.column());
} }
query.push_str(field.column());
query.push_str(") VALUES (");
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
if i > 0 {
query.push(',');
}
if let Some(value) = fields.remove(field) {
query.push('?');
values.push(value);
} else {
query.push_str("NULL");
}
}
query.push_str(") ON DUPLICATE KEY UPDATE ");
for (i, field) in all_fields.iter().enumerate() {
if i > 0 {
query.push(',');
}
let column = field.column();
let _ = write!(&mut query, "{column} = VALUES({column})");
}
let s = trx.prep(&query).await.map_err(into_error)?;
trx.exec_drop(&s, values).await.map_err(into_error)?;
} }
query.push_str(") VALUES ("); trx.commit().await.map_err(into_error)
})
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { .await;
if i > 0 { bounded(conn, result, limit)
query.push(',');
}
if let Some(value) = fields.remove(field) {
query.push('?');
values.push(value);
} else {
query.push_str("NULL");
}
}
query.push_str(") ON DUPLICATE KEY UPDATE ");
for (i, field) in all_fields.iter().enumerate() {
if i > 0 {
query.push(',');
}
let column = field.column();
let _ = write!(&mut query, "{column} = VALUES({column})");
}
let s = trx.prep(&query).await.map_err(into_error)?;
trx.exec_drop(&s, values).await.map_err(into_error)?;
}
trx.commit().await.map_err(into_error)
} }
pub async fn query<R: SearchDocumentId>( pub async fn query<R: SearchDocumentId>(
@@ -94,13 +101,18 @@ impl MysqlStore {
build_sort(&mut query, sort); build_sort(&mut query, sort);
} }
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn.prep(query).await.map_err(into_error)?; let limit = self.timeouts.query;
let result = tokio::time::timeout(limit, async {
let s = conn.prep(query).await.map_err(into_error)?;
conn.exec::<i64, _, _>(s, params) conn.exec::<i64, _, _>(s, params)
.await .await
.map(|r| r.into_iter().map(|r| R::from_u64(r as u64)).collect()) .map(|r| r.into_iter().map(|r| R::from_u64(r as u64)).collect())
.map_err(into_error) .map_err(into_error)
})
.await;
bounded(conn, result, limit)
} }
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> { pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
@@ -108,44 +120,65 @@ impl MysqlStore {
let mut query = format!("DELETE FROM {table} "); let mut query = format!("DELETE FROM {table} ");
let params = build_filter(&mut query, &filter.filters); let params = build_filter(&mut query, &filter.filters);
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let s = conn.prep(&query).await.map_err(into_error)?; let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
let s = conn.prep(&query).await.map_err(into_error)?;
match conn.exec_drop(s, params.clone()).await { match conn.exec_drop(s, params.clone()).await {
Ok(_) => return Ok(conn.affected_rows()), Ok(_) => return Ok(conn.affected_rows()),
Err(err) if is_timeout_error(&err) => (), Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)), Err(err) => return Err(into_error(err)),
} }
let mut chunk_size = DELETE_CHUNK_SIZE; let mut chunk_size = DELETE_CHUNK_SIZE;
let mut deleted = 0; let mut deleted = 0;
loop {
let s = conn
.prep(format!("{query} LIMIT {chunk_size}"))
.await
.map_err(into_error)?;
loop { loop {
match conn.exec_drop(&s, params.clone()).await { let s = conn
Ok(_) => { .prep(format!("{query} LIMIT {chunk_size}"))
let affected = conn.affected_rows(); .await
if affected == 0 { .map_err(into_error)?;
return Ok(deleted);
loop {
match conn.exec_drop(&s, params.clone()).await {
Ok(_) => {
let affected = conn.affected_rows();
if affected == 0 {
return Ok(deleted);
}
deleted += affected;
} }
deleted += affected; Err(err)
if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE =>
{
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
} }
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
} }
} }
} })
.await;
bounded(conn, result, limit)
} }
} }
// inbuxa: InnoDB's default full-text stopword list
// (INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD) and innodb_ft_min_token_size
// default; words outside these are not in a FULLTEXT index.
const FT_STOPWORDS: &[&str] = &[
"a", "about", "an", "are", "as", "at", "be", "by", "com", "de", "en", "for", "from", "how",
"i", "in", "is", "it", "la", "of", "on", "or", "that", "the", "this", "to", "was", "what",
"when", "where", "who", "will", "with", "und", "www",
];
const FT_MIN_TOKEN_SIZE: usize = 3;
fn is_ft_indexed(word: &str) -> bool {
word.chars().count() >= FT_MIN_TOKEN_SIZE && !FT_STOPWORDS.contains(&word)
}
fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> { fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
if filters.is_empty() { if filters.is_empty() {
return Vec::new(); return Vec::new();
@@ -171,30 +204,77 @@ fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains) if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains)
{ {
let (value, mode) = match (value, op) { let (value, mode, unindexed) = match (value, op) {
(SearchValue::Text { value, .. }, SearchOperator::Equal) => { (SearchValue::Text { value, .. }, SearchOperator::Equal) => (
(Value::Bytes(format!("{value:?}").into_bytes()), "BOOLEAN") Value::Bytes(format!("{value:?}").into_bytes()),
} "BOOLEAN",
(SearchValue::Text { value, .. }, ..) => { Vec::new(),
),
(SearchValue::Text { value, language }, ..) => {
let mut text_query = String::with_capacity(value.len() + 1); let mut text_query = String::with_capacity(value.len() + 1);
let mut unindexed = Vec::new();
for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) { for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) {
if !text_query.is_empty() { // inbuxa: InnoDB never indexes stopwords ("com",
text_query.push(' '); // "de", "www", ...) or words under
// innodb_ft_min_token_size, and a required
// (+word) term it has not indexed matches no row,
// so "example.com" or "[email protected]" found
// nothing. Such words are matched with a
// word-boundary REGEXP instead.
if is_ft_indexed(&item.word) {
if !text_query.is_empty() {
text_query.push(' ');
}
text_query.push('+');
text_query.push_str(&item.word);
} else {
unindexed.push(item.word);
} }
text_query.push('+');
text_query.push_str(&item.word);
} }
(Value::Bytes(text_query.into_bytes()), "BOOLEAN") // For language text (bodies, subjects) the unindexed
// words are noise words and only checked when nothing
// else is left to match; keyword text (addresses,
// contact fields) checks every word, as the other
// backends do.
if !text_query.is_empty() && !matches!(language, Language::None) {
unindexed.clear();
}
(Value::Bytes(text_query.into_bytes()), "BOOLEAN", unindexed)
} }
_ => { _ => {
debug_assert!(false, "Invalid search value for text field"); debug_assert!(false, "Invalid search value for text field");
continue; continue;
} }
}; };
let _ = write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column()); if unindexed.is_empty() {
values.push(value); let _ =
write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column());
values.push(value);
} else {
query.push('(');
let is_empty = matches!(&value, Value::Bytes(v) if v.is_empty());
if !is_empty {
let _ = write!(
query,
"MATCH({}) AGAINST(? IN {mode} MODE) AND ",
field.column()
);
values.push(value);
}
for (i, word) in unindexed.iter().enumerate() {
if i > 0 {
query.push_str(" AND ");
}
let _ = write!(query, "{} REGEXP ?", field.column());
values.push(Value::Bytes(
format!("(^|[^[:alnum:]]){word}([^[:alnum:]]|$)").into_bytes(),
));
}
query.push(')');
}
} else if let SearchValue::KeyValues(kv) = value { } else if let SearchValue::KeyValues(kv) = value {
let (key, value) = kv.iter().next().unwrap(); let (key, value) = kv.iter().next().unwrap();
+105 -87
View File
@@ -2,9 +2,13 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, into_error, is_timeout_error}; use super::{
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, bounded, into_error, is_timeout_error,
};
use crate::{ use crate::{
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA, IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_IDX,
@@ -29,42 +33,46 @@ impl MysqlStore {
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> { pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
let start = Instant::now(); let start = Instant::now();
let mut retry_count = 0; let mut retry_count = 0;
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let limit = self.timeouts.query;
loop { let result = tokio::time::timeout(limit, async {
let err = match self.write_trx(&mut conn, &mut batch).await { loop {
Ok(result) => { let err = match self.write_trx(&mut conn, &mut batch).await {
return Ok(result); Ok(result) => {
} return Ok(result);
Err(err) => err,
};
let _ = conn.query_drop("ROLLBACK;").await;
match err {
CommitError::Mysql(Error::Server(err))
if [1062, 1213].contains(&err.code)
&& retry_count < MAX_COMMIT_ATTEMPTS
&& start.elapsed() < MAX_COMMIT_TIME => {}
/*CommitError::Retry => {
if retry_count > MAX_COMMIT_ATTEMPTS || start.elapsed() > MAX_COMMIT_TIME {
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.caused_by(trc::location!()));
} }
}*/ Err(err) => err,
CommitError::Mysql(err) => { };
return Err(into_error(err));
}
CommitError::Internal(err) => {
return Err(err);
}
}
let backoff = rand::rng().random_range(50..=300); let _ = conn.query_drop("ROLLBACK;").await;
tokio::time::sleep(Duration::from_millis(backoff)).await;
retry_count += 1; match err {
} CommitError::Mysql(Error::Server(err))
if [1062, 1213].contains(&err.code)
&& retry_count < MAX_COMMIT_ATTEMPTS
&& start.elapsed() < MAX_COMMIT_TIME => {}
/*CommitError::Retry => {
if retry_count > MAX_COMMIT_ATTEMPTS || start.elapsed() > MAX_COMMIT_TIME {
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.caused_by(trc::location!()));
}
}*/
CommitError::Mysql(err) => {
return Err(into_error(err));
}
CommitError::Internal(err) => {
return Err(err);
}
}
let backoff = rand::rng().random_range(50..=300);
tokio::time::sleep(Duration::from_millis(backoff)).await;
retry_count += 1;
}
})
.await;
bounded(conn, result, limit)
} }
async fn write_trx( async fn write_trx(
@@ -382,72 +390,82 @@ impl MysqlStore {
} }
pub(crate) async fn purge_store(&self) -> trc::Result<()> { pub(crate) async fn purge_store(&self) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { let limit = self.timeouts.maintenance;
purge_table(&mut conn, char::from(subspace)).await?; let result = tokio::time::timeout(limit, async {
} for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
purge_table(&mut conn, char::from(subspace)).await?;
}
Ok(()) Ok(())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; let mut conn = self.conn().await?;
let table = char::from(from.subspace()); let limit = self.timeouts.maintenance;
let mut from = from.serialize(0); let result = tokio::time::timeout(limit, async {
let to = to.serialize(0); let table = char::from(from.subspace());
let mut from = from.serialize(0);
let to = to.serialize(0);
let delete = conn let delete = conn
.prep(format!("DELETE FROM {table} WHERE k >= ? AND k < ?")) .prep(format!("DELETE FROM {table} WHERE k >= ? AND k < ?"))
.await
.map_err(into_error)?;
match conn.exec_drop(&delete, (&from, &to)).await {
Ok(_) => return Ok(()),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
loop {
let boundary = conn
.prep(format!(
"SELECT k FROM {table} WHERE k >= ? AND k < ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
))
.await .await
.map_err(into_error)?; .map_err(into_error)?;
match conn.exec_drop(&delete, (&from, &to)).await {
Ok(_) => return Ok(()),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
loop { loop {
let next = match conn let boundary = conn
.exec_first::<Vec<u8>, _, _>(&boundary, (&from, &to)) .prep(format!(
"SELECT k FROM {table} WHERE k >= ? AND k < ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
))
.await .await
{ .map_err(into_error)?;
Ok(next) => next,
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
};
match conn loop {
.exec_drop(&delete, (&from, next.as_ref().unwrap_or(&to))) let next = match conn
.await .exec_first::<Vec<u8>, _, _>(&boundary, (&from, &to))
{ .await
Ok(_) => (), {
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { Ok(next) => next,
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
break; chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
} break;
Err(err) => return Err(into_error(err)), }
} Err(err) => return Err(into_error(err)),
};
match next { match conn
Some(next) => from = next, .exec_drop(&delete, (&from, next.as_ref().unwrap_or(&to)))
None => return Ok(()), .await
{
Ok(_) => (),
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
}
match next {
Some(next) => from = next,
None => return Ok(()),
}
} }
} }
} })
.await;
bounded(conn, result, limit)
} }
} }
+57 -40
View File
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::ops::Range; use std::ops::Range;
use crate::backend::postgres::into_pool_error; use crate::backend::postgres::into_pool_error;
use super::{PostgresStore, into_error}; use super::{PostgresStore, bounded, into_error};
impl PostgresStore { impl PostgresStore {
pub(crate) async fn get_blob( pub(crate) async fn get_blob(
@@ -17,53 +19,68 @@ impl PostgresStore {
range: Range<usize>, range: Range<usize>,
) -> trc::Result<Option<Vec<u8>>> { ) -> trc::Result<Option<Vec<u8>>> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached("SELECT v FROM t WHERE k = $1") let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prepare_cached("SELECT v FROM t WHERE k = $1")
conn.query_opt(&s, &[&key]) .await
.await .map_err(into_error)?;
.and_then(|row| { conn.query_opt(&s, &[&key])
if let Some(row) = row { .await
Ok(Some(if range.start == 0 && range.end == usize::MAX { .and_then(|row| {
row.try_get::<_, Vec<u8>>(0)? if let Some(row) = row {
Ok(Some(if range.start == 0 && range.end == usize::MAX {
row.try_get::<_, Vec<u8>>(0)?
} else {
let bytes = row.try_get::<_, &[u8]>(0)?;
bytes
.get(range.start..std::cmp::min(bytes.len(), range.end))
.unwrap_or_default()
.to_vec()
}))
} else { } else {
let bytes = row.try_get::<_, &[u8]>(0)?; Ok(None)
bytes }
.get(range.start..std::cmp::min(bytes.len(), range.end)) })
.unwrap_or_default() .map_err(into_error)
.to_vec() })
})) .await;
} else { bounded(conn, result, limit)
Ok(None)
}
})
.map_err(into_error)
} }
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached( let result = tokio::time::timeout(limit, async {
"INSERT INTO t (k, v) VALUES ($1, $2) ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v", let s = conn
) .prepare_cached(
.await "INSERT INTO t (k, v) VALUES ($1, $2) ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v",
.map_err(into_error)?; )
conn.execute(&s, &[&key, &data]) .await
.await .map_err(into_error)?;
.map_err(into_error) conn.execute(&s, &[&key, &data])
.map(|_| ()) .await
.map_err(into_error)
.map(|_| ())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> { pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached("DELETE FROM t WHERE k = $1") let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prepare_cached("DELETE FROM t WHERE k = $1")
conn.execute(&s, &[&key]) .await
.await .map_err(into_error)?;
.map_err(into_error) conn.execute(&s, &[&key])
.map(|hits| hits > 0) .await
.map_err(into_error)
.map(|hits| hits > 0)
})
.await;
bounded(conn, result, limit)
} }
} }
+32 -25
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{QueryResult, QueryType, backend::postgres::into_pool_error}; use crate::{QueryResult, QueryType, backend::postgres::into_pool_error};
@@ -12,7 +14,7 @@ use tokio_postgres::types::{FromSql, ToSql, Type};
use crate::IntoRows; use crate::IntoRows;
use super::{PostgresStore, into_error}; use super::{PostgresStore, bounded, into_error};
impl PostgresStore { impl PostgresStore {
pub(crate) async fn sql_query<T: QueryResult>( pub(crate) async fn sql_query<T: QueryResult>(
@@ -21,33 +23,38 @@ impl PostgresStore {
params_: &[crate::Value<'_>], params_: &[crate::Value<'_>],
) -> trc::Result<T> { ) -> trc::Result<T> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn.prepare_cached(query).await.map_err(into_error)?; let limit = self.timeouts.query;
let params = params_ let result = tokio::time::timeout(limit, async {
.iter() let s = conn.prepare_cached(query).await.map_err(into_error)?;
.map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) let params = params_
.collect::<Vec<_>>(); .iter()
.map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
.collect::<Vec<_>>();
match T::query_type() { match T::query_type() {
QueryType::Execute => conn QueryType::Execute => conn
.execute(&s, params.as_slice()) .execute(&s, params.as_slice())
.await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r as usize))),
QueryType::Exists => {
let rows = conn.query_raw(&s, params).await.map_err(into_error)?;
pin_mut!(rows);
rows.try_next()
.await .await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))) .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r as usize))),
QueryType::Exists => {
let rows = conn.query_raw(&s, params).await.map_err(into_error)?;
pin_mut!(rows);
rows.try_next()
.await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some())))
}
QueryType::QueryOne => conn
.query_opt(&s, params.as_slice())
.await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
QueryType::QueryAll => conn
.query(&s, params.as_slice())
.await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
} }
QueryType::QueryOne => conn })
.query_opt(&s, params.as_slice()) .await;
.await bounded(conn, result, limit)
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
QueryType::QueryAll => conn
.query(&s, params.as_slice())
.await
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
}
} }
} }
+192 -76
View File
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{PostgresStore, into_error}; use super::{PostgresStore, bounded, into_error};
use crate::{ use crate::{
backend::postgres::{ backend::postgres::{
PsqlSearchField, into_pool_error, PsqlSearchField, into_pool_error,
@@ -22,11 +22,34 @@ use crate::{
use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs}; use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs};
use ahash::AHashSet; use ahash::AHashSet;
use deadpool_postgres::{ use deadpool_postgres::{
Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime, Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime, Timeouts,
}; };
use std::time::Duration;
use tokio_postgres::NoTls; use tokio_postgres::NoTls;
use utils::tls::rustls_client_config; use utils::tls::rustls_client_config;
/// inbuxa: how long a request waits for a pooled connection.
pub(crate) const POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
/// inbuxa: how long opening a connection may take when the store sets no
/// timeout of its own.
pub(crate) const POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
/// inbuxa: how long checking a pooled connection before reuse may take.
pub(crate) const POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
/// inbuxa: idle time before TCP keepalive probes start.
pub(crate) const POOL_KEEPALIVE_IDLE: Duration = Duration::from_secs(60);
/// inbuxa: the pool's timeouts. Opening a connection is bounded by the
/// store's own timeout when it has one; waiting for one covers at least that
/// long, so a slow connect isn't cut short by the wait.
pub(crate) fn pool_timeouts(connect_timeout: Option<Duration>) -> Timeouts {
let create = connect_timeout.unwrap_or(POOL_CREATE_TIMEOUT);
Timeouts {
wait: POOL_WAIT_TIMEOUT.max(create).into(),
create: create.into(),
recycle: POOL_RECYCLE_TIMEOUT.into(),
}
}
impl PostgresStore { impl PostgresStore {
pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> { pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> {
// inbuxa: ST-15: where the primary is, to tell a replica from it // inbuxa: ST-15: where the primary is, to tell a replica from it
@@ -46,9 +69,20 @@ impl PostgresStore {
PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean, PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean,
}, },
}); });
if let Some(max_conn) = config.pool_max_connections { // inbuxa: upstream set no pool timeouts, so a request waited for a
cfg.pool = PoolConfig::new(max_conn as usize).into(); // free connection, or for one to be made or recycled, for as long as
} // it took: forever when the server stopped answering. A worker now
// gets an error instead and the task or request is retried.
let mut pool = config
.pool_max_connections
.map(|max_conn| PoolConfig::new(max_conn as usize))
.unwrap_or_default();
pool.timeouts = pool_timeouts(cfg.connect_timeout);
cfg.pool = pool.into();
// Notice a server that went away without closing the connection in
// minutes rather than the system default of two hours
cfg.keepalives = true.into();
cfg.keepalives_idle = POOL_KEEPALIVE_IDLE.into();
let primary_pool = if config.use_tls { let primary_pool = if config.use_tls {
cfg.create_pool( cfg.create_pool(
@@ -85,6 +119,7 @@ impl PostgresStore {
Store::PostgreSQL(Arc::new(PostgresStore { Store::PostgreSQL(Arc::new(PostgresStore {
conn_pool: pool, conn_pool: pool,
ts_configs: ts_configs.clone(), ts_configs: ts_configs.clone(),
timeouts: Default::default(),
})), })),
replica.host, replica.host,
replica.port as u16, replica.port as u16,
@@ -95,6 +130,7 @@ impl PostgresStore {
let primary = Store::PostgreSQL(Arc::new(PostgresStore { let primary = Store::PostgreSQL(Arc::new(PostgresStore {
conn_pool: primary_pool, conn_pool: primary_pool,
ts_configs, ts_configs,
timeouts: Default::default(),
})); }));
// ST-1: no replicas, no change // ST-1: no replicas, no change
@@ -113,84 +149,92 @@ impl PostgresStore {
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> { pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
for table in [
SUBSPACE_ACL,
SUBSPACE_TASK_QUEUE,
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
crate::SUBSPACE_INBUXA, // inbuxa: masked email
SUBSPACE_BLOB_LINK,
SUBSPACE_IN_MEMORY_VALUE,
SUBSPACE_PROPERTY,
SUBSPACE_REGISTRY,
SUBSPACE_REGISTRY_PK,
SUBSPACE_QUEUE_MESSAGE,
SUBSPACE_QUEUE_EVENT,
SUBSPACE_REPORT_OUT,
SUBSPACE_REPORT_IN,
SUBSPACE_LOGS,
SUBSPACE_BLOBS,
SUBSPACE_DIRECTORY,
SUBSPACE_TELEMETRY_SPAN,
SUBSPACE_TELEMETRY_METRIC,
] {
let table = char::from(table);
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {table} (
k BYTEA PRIMARY KEY,
v BYTEA NOT NULL
)"
),
&[],
)
.await
.map_err(into_error)?;
}
for table in [ for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
SUBSPACE_ACL, let table = char::from(table);
SUBSPACE_TASK_QUEUE, conn.execute(
SUBSPACE_DELETED_ITEMS, &format!(
SUBSPACE_SPAM_SAMPLES, "CREATE TABLE IF NOT EXISTS {table} (
crate::SUBSPACE_INBUXA, // inbuxa: masked email k BYTEA PRIMARY KEY
SUBSPACE_BLOB_LINK, )"
SUBSPACE_IN_MEMORY_VALUE, ),
SUBSPACE_PROPERTY, &[],
SUBSPACE_REGISTRY, )
SUBSPACE_REGISTRY_PK, .await
SUBSPACE_QUEUE_MESSAGE, .map_err(into_error)?;
SUBSPACE_QUEUE_EVENT, }
SUBSPACE_REPORT_OUT,
SUBSPACE_REPORT_IN, for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
SUBSPACE_LOGS, conn.execute(
SUBSPACE_BLOBS, &format!(
SUBSPACE_DIRECTORY, "CREATE TABLE IF NOT EXISTS {} (
SUBSPACE_TELEMETRY_SPAN,
SUBSPACE_TELEMETRY_METRIC,
] {
let table = char::from(table);
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {table} (
k BYTEA PRIMARY KEY, k BYTEA PRIMARY KEY,
v BYTEA NOT NULL v BIGINT NOT NULL DEFAULT 0
)" )",
), char::from(table)
&[], ),
) &[],
.await )
.map_err(into_error)?; .await
} .map_err(into_error)?;
}
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] { Ok(())
let table = char::from(table); })
conn.execute( .await;
&format!( bounded(conn, result, limit)
"CREATE TABLE IF NOT EXISTS {table} (
k BYTEA PRIMARY KEY
)"
),
&[],
)
.await
.map_err(into_error)?;
}
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {} (
k BYTEA PRIMARY KEY,
v BIGINT NOT NULL DEFAULT 0
)",
char::from(table)
),
&[],
)
.await
.map_err(into_error)?;
}
Ok(())
} }
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> { pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
create_search_tables::<EmailSearchField>(&conn).await?;
create_search_tables::<CalendarSearchField>(&conn).await?;
create_search_tables::<ContactSearchField>(&conn).await?;
//create_search_tables::<FileSearchField>(&conn).await?;
create_search_tables::<TracingSearchField>(&conn).await?;
create_search_tables::<EmailSearchField>(&conn).await?; Ok(())
create_search_tables::<CalendarSearchField>(&conn).await?; })
create_search_tables::<ContactSearchField>(&conn).await?; .await;
//create_search_tables::<FileSearchField>(&conn).await?; bounded(conn, result, limit)
create_search_tables::<TracingSearchField>(&conn).await?;
Ok(())
} }
} }
@@ -231,12 +275,21 @@ async fn create_search_tables<T: SearchableField + PsqlSearchField + 'static>(
for field in T::all_fields() { for field in T::all_fields() {
if field.is_text() || field.is_json() { if field.is_text() || field.is_json() {
let column_name = field.column(); let column_name = field.column();
// inbuxa: with GIN's default fastupdate=on, new entries wait in
// an unindexed pending list that every search scans in full
// until a VACUUM (or 4 MB of backlog) merges it. On a mailbox
// taking steady mail that list never drains and searches slow
// from milliseconds to hundreds of them. Pay the index update
// at insert time instead.
let index_name = format!("gin_{table_name}_{column_name}");
let create_index_query = format!( let create_index_query = format!(
"CREATE INDEX IF NOT EXISTS gin_{table_name}_{column_name} ON {table_name} USING GIN({column_name})", "CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} USING GIN({column_name}) WITH (fastupdate = off)",
); );
conn.execute(&create_index_query, &[]) conn.execute(&create_index_query, &[])
.await .await
.map_err(into_error)?; .map_err(into_error)?;
// Indexes made before this change keep fastupdate=on
disable_gin_fastupdate(conn, &index_name).await;
} }
if field.is_indexed() { if field.is_indexed() {
@@ -253,6 +306,69 @@ async fn create_search_tables<T: SearchableField + PsqlSearchField + 'static>(
Ok(()) Ok(())
} }
/// inbuxa: turns fastupdate off on a GIN index made with the default and
/// merges the pending list it has built up. Idempotent: an index that already
/// has the option is left alone, so this costs one catalog read per index at
/// startup. A failure is logged and startup goes on, since search still works,
/// only slower.
async fn disable_gin_fastupdate(conn: &Object, index_name: &str) {
if let Err(err) = try_disable_gin_fastupdate(conn, index_name).await {
trc::event!(
Store(trc::StoreEvent::PostgresqlError),
Details = format!("Failed to turn off fastupdate on search index {index_name}"),
Reason = err.to_string(),
);
}
}
async fn try_disable_gin_fastupdate(conn: &Object, index_name: &str) -> trc::Result<()> {
let options = conn
.query_opt(
"SELECT COALESCE(reloptions, '{}')::text[] FROM pg_class WHERE oid = to_regclass($1)",
&[&index_name],
)
.await
.map_err(into_error)?
.map(|row| row.try_get::<_, Vec<String>>(0))
.transpose()
.map_err(into_error)?;
let Some(options) = options else {
return Ok(());
};
if gin_fastupdate_is_off(&options) {
return Ok(());
}
// SET (fastupdate) takes a SHARE UPDATE EXCLUSIVE lock, which doesn't
// block reads or writes. Turning it off stops new entries going to the
// pending list but doesn't flush the entries already there.
conn.execute(
&format!("ALTER INDEX {index_name} SET (fastupdate = off)"),
&[],
)
.await
.map_err(into_error)?;
conn.query_one(
"SELECT gin_clean_pending_list($1::text::regclass)",
&[&index_name],
)
.await
.map_err(into_error)?;
Ok(())
}
/// Whether a relation's reloptions turn GIN's fastupdate off.
fn gin_fastupdate_is_off(options: &[String]) -> bool {
options.iter().any(|option| {
option.split_once('=').is_some_and(|(name, value)| {
name.trim().eq_ignore_ascii_case("fastupdate")
&& matches!(
value.trim().to_ascii_lowercase().as_str(),
"off" | "false" | "no" | "0" | "f" | "n"
)
})
})
}
async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> { async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> {
let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]); let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]);
+33 -1
View File
@@ -6,6 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::backend::query_timeout::QueryTimeouts;
use crate::{ use crate::{
search::{ search::{
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField, CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
@@ -14,7 +15,8 @@ use crate::{
write::SearchIndex, write::SearchIndex,
}; };
use ahash::AHashSet; use ahash::AHashSet;
use deadpool_postgres::Pool; use deadpool_postgres::{Object, Pool};
use std::time::Duration;
use tokio_postgres::error::SqlState; use tokio_postgres::error::SqlState;
pub mod blob; pub mod blob;
@@ -28,6 +30,8 @@ pub mod write;
pub struct PostgresStore { pub struct PostgresStore {
pub(crate) conn_pool: Pool, pub(crate) conn_pool: Pool,
pub(crate) ts_configs: AHashSet<&'static str>, pub(crate) ts_configs: AHashSet<&'static str>,
/// inbuxa: client-side query limits (see backend::query_timeout)
pub(crate) timeouts: QueryTimeouts,
} }
#[inline(always)] #[inline(always)]
@@ -72,6 +76,34 @@ pub(crate) fn is_timeout_error(err: &tokio_postgres::Error) -> bool {
}) })
} }
/// inbuxa: the error for an operation that ran past its time limit.
pub(crate) fn query_timeout_error(limit: Duration) -> trc::Error {
trc::StoreEvent::PostgresqlError
.reason("Query timed out")
.details(format!(
"No answer from the database within {} s",
limit.as_secs()
))
}
/// inbuxa: ends an operation run on `conn` under `limit`. When it ran out,
/// the connection is taken out of the pool and closed: a query may still be
/// in flight on it, or a transaction open, so it can't be handed to the
/// next caller.
pub(crate) fn bounded<T>(
conn: Object,
result: Result<trc::Result<T>, tokio::time::error::Elapsed>,
limit: Duration,
) -> trc::Result<T> {
match result {
Ok(result) => result,
Err(_) => {
drop(Object::take(conn));
Err(query_timeout_error(limit))
}
}
}
#[inline(always)] #[inline(always)]
pub(crate) fn into_pool_error(err: deadpool_postgres::PoolError) -> trc::Error { pub(crate) fn into_pool_error(err: deadpool_postgres::PoolError) -> trc::Error {
match err { match err {
+108 -63
View File
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{PostgresStore, into_error, is_timeout_error}; use super::{PostgresStore, bounded, into_error, is_timeout_error, query_timeout_error};
use crate::{ use crate::{
Deserialize, IterateParams, Key, ValueKey, backend::postgres::into_pool_error, Deserialize, IterateParams, Key, ValueKey, backend::postgres::into_pool_error,
write::ValueClass, write::ValueClass,
@@ -17,40 +19,50 @@ impl PostgresStore {
U: Deserialize + 'static, U: Deserialize + 'static,
{ {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached(&format!( let result = tokio::time::timeout(limit, async {
"SELECT v FROM {} WHERE k = $1", let s = conn
char::from(key.subspace()) .prepare_cached(&format!(
)) "SELECT v FROM {} WHERE k = $1",
.await char::from(key.subspace())
.map_err(into_error)?; ))
let key = key.serialize(0); .await
conn.query_opt(&s, &[&key]) .map_err(into_error)?;
.await let key = key.serialize(0);
.map_err(into_error) conn.query_opt(&s, &[&key])
.and_then(|r| { .await
if let Some(r) = r { .map_err(into_error)
Ok(Some(U::deserialize_with_key(&key, r.get(0))?)) .and_then(|r| {
} else { if let Some(r) = r {
Ok(None) Ok(Some(U::deserialize_with_key(&key, r.get(0))?))
} } else {
}) Ok(None)
}
})
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> { pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached(&format!( let result = tokio::time::timeout(limit, async {
"SELECT 1 FROM {} WHERE k = $1", let s = conn
char::from(key.subspace()) .prepare_cached(&format!(
)) "SELECT 1 FROM {} WHERE k = $1",
.await char::from(key.subspace())
.map_err(into_error)?; ))
let key = key.serialize(0); .await
conn.query_opt(&s, &[&key]) .map_err(into_error)?;
.await let key = key.serialize(0);
.map_err(into_error) conn.query_opt(&s, &[&key])
.map(|r| r.is_some()) .await
.map_err(into_error)
.map(|r| r.is_some())
})
.await;
bounded(conn, result, limit)
} }
pub(crate) async fn iterate<T: Key>( pub(crate) async fn iterate<T: Key>(
@@ -64,44 +76,65 @@ impl PostgresStore {
let end = params.end.serialize(0); let end = params.end.serialize(0);
let keys = if params.values { "k, v" } else { "k" }; let keys = if params.values { "k, v" } else { "k" };
let s = conn // inbuxa: a scan may run for hours, so the query limit bounds each
.prepare_cached(&match (params.first, params.ascending) { // wait for the database (preparing, the query starting, the next
(true, true) => { // row) rather than the scan. A wait that runs out closes the
format!( // connection.
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC LIMIT 1" let limit = self.timeouts.query;
) let query = match (params.first, params.ascending) {
} (true, true) => {
(true, false) => { format!(
format!( "SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC LIMIT 1"
)
}
(true, false) => {
format!(
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC LIMIT 1" "SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC LIMIT 1"
) )
} }
(false, true) => { (false, true) => {
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC") format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC")
} }
(false, false) => { (false, false) => {
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC") format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC")
} }
}) };
.await.map_err(into_error)?; let s = match tokio::time::timeout(limit, conn.prepare_cached(&query)).await {
Ok(s) => s.map_err(into_error)?,
Err(_) => {
drop(deadpool_postgres::Object::take(conn));
return Err(query_timeout_error(limit));
}
};
let mut from = begin; let mut from = begin;
let mut to = end; let mut to = end;
let mut resume_key: Option<Vec<u8>> = None; let mut resume_key: Option<Vec<u8>> = None;
let mut stalled = false;
loop { loop {
let mut last_key = None; let mut last_key = None;
let mut timed_out = false; let mut timed_out = false;
{ {
let rows = conn let rows =
.query_raw(&s, &[&from, &to]) match tokio::time::timeout(limit, conn.query_raw(&s, &[&from, &to])).await {
.await Ok(rows) => rows.map_err(into_error)?,
.map_err(into_error)?; // Leaves the scan loop for the timeout below
Err(_) => break,
};
pin_mut!(rows); pin_mut!(rows);
loop { loop {
match rows.try_next().await { let next = match tokio::time::timeout(limit, rows.try_next()).await {
Ok(next) => next,
Err(_) => {
stalled = true;
break;
}
};
match next {
Ok(Some(row)) => { Ok(Some(row)) => {
let key = row.try_get::<_, &[u8]>(0).map_err(into_error)?; let key = row.try_get::<_, &[u8]>(0).map_err(into_error)?;
let value = if params.values { let value = if params.values {
@@ -132,6 +165,10 @@ impl PostgresStore {
} }
} }
if stalled {
break;
}
match last_key { match last_key {
Some(last_key) if timed_out => { Some(last_key) if timed_out => {
if params.ascending { if params.ascending {
@@ -144,6 +181,9 @@ impl PostgresStore {
_ => return Ok(()), _ => return Ok(()),
} }
} }
drop(deadpool_postgres::Object::take(conn));
Err(query_timeout_error(limit))
} }
pub(crate) async fn get_counter( pub(crate) async fn get_counter(
@@ -155,14 +195,19 @@ impl PostgresStore {
let key = key.serialize(0); let key = key.serialize(0);
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.query;
.prepare_cached(&format!("SELECT v FROM {table} WHERE k = $1")) let result = tokio::time::timeout(limit, async {
.await let s = conn
.map_err(into_error)?; .prepare_cached(&format!("SELECT v FROM {table} WHERE k = $1"))
match conn.query_opt(&s, &[&key]).await { .await
Ok(Some(row)) => row.try_get(0).map_err(into_error), .map_err(into_error)?;
Ok(None) => Ok(0), match conn.query_opt(&s, &[&key]).await {
Err(e) => Err(into_error(e)), Ok(Some(row)) => row.try_get(0).map_err(into_error),
} Ok(None) => Ok(0),
Err(e) => Err(into_error(e)),
}
})
.await;
bounded(conn, result, limit)
} }
} }
+297 -117
View File
@@ -2,12 +2,17 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
backend::postgres::{ backend::{
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, into_error, MAX_TOKEN_LENGTH,
into_pool_error, is_timeout_error, postgres::{
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, bounded,
into_error, into_pool_error, is_timeout_error,
},
}, },
search::{ search::{
IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator, IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator,
@@ -15,7 +20,7 @@ use crate::{
}, },
write::SearchIndex, write::SearchIndex,
}; };
use nlp::language::Language; use nlp::{language::Language, tokenizers::space::SpaceTokenizer};
use std::fmt::Write; use std::fmt::Write;
use tokio_postgres::{ use tokio_postgres::{
IsolationLevel, IsolationLevel,
@@ -31,99 +36,135 @@ impl PostgresStore {
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> { pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?; let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let trx = conn let limit = self.timeouts.query;
.build_transaction() let result = tokio::time::timeout(limit, async {
.isolation_level(IsolationLevel::ReadCommitted) let trx = conn
.start() .build_transaction()
.await .isolation_level(IsolationLevel::ReadCommitted)
.map_err(into_error)?; .start()
.await
.map_err(into_error)?;
for document in documents { for document in documents {
let index = document.index; let index = document.index;
let primary_keys = index.primary_keys(); let primary_keys = index.primary_keys();
let all_fields = index.all_fields(); let all_fields = index.all_fields();
let fields = document.fields; let fields = document.fields;
let mut values = Vec::with_capacity(fields.len() + 2); // inbuxa: keyword text (addresses, contact fields, ...) is split into
let mut query = format!("INSERT INTO {} (", index.psql_table()); // words before it reaches the text parser, see keyword_terms();
// language text gets the words inside its URLs, host names and
// file names added, see url_terms().
let keywords = primary_keys
.iter()
.chain(all_fields)
.map(|field| match fields.get(field) {
Some(SearchValue::Text {
value,
language: Language::None,
}) if field.is_text() => Some(keyword_terms(value)),
Some(SearchValue::Text { value, .. }) if field.is_text() => {
url_terms(value)
}
_ => None,
})
.collect::<Vec<_>>();
let mut values = Vec::with_capacity(fields.len() + 2);
let mut query = format!("INSERT INTO {} (", index.psql_table());
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
if i > 0 { if i > 0 {
query.push(','); query.push(',');
} }
query.push_str(field.column()); query.push_str(field.column());
if let Some(sort_column) = field.sort_column() { if let Some(sort_column) = field.sort_column() {
query.push(','); query.push(',');
query.push_str(sort_column); query.push_str(sort_column);
} }
}
query.push_str(") VALUES (");
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
if i > 0 {
query.push(',');
} }
if let Some(value) = fields.get(field) { query.push_str(") VALUES (");
let value_ref = format!("${}", values.len() + 1);
let (text_len, language) = if let SearchValue::Text { value, language } = value
{
(value.len(), self.ts_config(language))
} else {
(0, PG_UNSTEMMED_LANG)
};
if field.is_text() { for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})"); if i > 0 {
} else if text_len > 512 { query.push(',');
query.push_str("left(");
query.push_str(&value_ref);
query.push_str(",512)");
} else {
query.push_str(&value_ref);
} }
if field.sort_column().is_some() { if let Some(value) = fields.get(field) {
if text_len > 255 { let value_ref = format!("${}", values.len() + 1);
query.push_str(",left("); let (text_len, language) =
if let SearchValue::Text { value, language } = value {
(value.len(), self.ts_config(language))
} else {
(0, PG_UNSTEMMED_LANG)
};
if let Some(keywords) = &keywords[i] {
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
values.push(keywords as &(dyn ToSql + Sync));
if field.sort_column().is_some() {
let value_ref = format!("${}", values.len() + 1);
if text_len > 255 {
let _ = write!(&mut query, ",left({value_ref},255)");
} else {
let _ = write!(&mut query, ",{value_ref}");
}
values.push(value as &(dyn ToSql + Sync));
}
continue;
} else if field.is_text() {
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
} else if text_len > 512 {
query.push_str("left(");
query.push_str(&value_ref); query.push_str(&value_ref);
query.push_str(",255)"); query.push_str(",512)");
} else { } else {
query.push(',');
query.push_str(&value_ref); query.push_str(&value_ref);
} }
}
values.push(value as &(dyn ToSql + Sync)); if field.sort_column().is_some() {
} else { if text_len > 255 {
query.push_str("NULL"); query.push_str(",left(");
if field.sort_column().is_some() { query.push_str(&value_ref);
query.push_str(",NULL"); query.push_str(",255)");
} else {
query.push(',');
query.push_str(&value_ref);
}
}
values.push(value as &(dyn ToSql + Sync));
} else {
query.push_str("NULL");
if field.sort_column().is_some() {
query.push_str(",NULL");
}
} }
} }
}
query.push_str(") ON CONFLICT ("); query.push_str(") ON CONFLICT (");
for (i, pkey) in primary_keys.iter().enumerate() { for (i, pkey) in primary_keys.iter().enumerate() {
if i > 0 { if i > 0 {
query.push(','); query.push(',');
}
query.push_str(pkey.column());
} }
query.push_str(pkey.column()); query.push_str(") DO UPDATE SET ");
} for (i, field) in all_fields.iter().enumerate() {
query.push_str(") DO UPDATE SET "); if i > 0 {
for (i, field) in all_fields.iter().enumerate() { query.push(',');
if i > 0 { }
query.push(','); let column = field.column();
let _ = write!(&mut query, "{column} = EXCLUDED.{column}");
} }
let column = field.column();
let _ = write!(&mut query, "{column} = EXCLUDED.{column}"); trx.execute(&query, &values).await.map_err(into_error)?;
} }
trx.execute(&query, &values).await.map_err(into_error)?; trx.commit().await.map_err(into_error)
} })
.await;
trx.commit().await.map_err(into_error) bounded(conn, result, limit)
} }
pub async fn query<R: SearchDocumentId>( pub async fn query<R: SearchDocumentId>(
@@ -134,20 +175,26 @@ impl PostgresStore {
) -> trc::Result<Vec<R>> { ) -> trc::Result<Vec<R>> {
let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table()); let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table());
let params = self.build_filter(&mut query, filters); let params = self.build_filter(&mut query, filters);
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
if !sort.is_empty() { if !sort.is_empty() {
build_sort(&mut query, sort); build_sort(&mut query, sort);
} }
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn.prepare_cached(&query).await.map_err(into_error)?; let limit = self.timeouts.query;
let result = tokio::time::timeout(limit, async {
let s = conn.prepare_cached(&query).await.map_err(into_error)?;
conn.query(&s, params.as_slice()) conn.query(&s, params.as_slice())
.await .await
.and_then(|rows| { .and_then(|rows| {
rows.into_iter() rows.into_iter()
.map(|row| row.try_get::<_, DocId>(0).map(|v| R::from_u64(v.0))) .map(|row| row.try_get::<_, DocId>(0).map(|v| R::from_u64(v.0)))
.collect::<Result<Vec<R>, _>>() .collect::<Result<Vec<R>, _>>()
}) })
.map_err(into_error) .map_err(into_error)
})
.await;
bounded(conn, result, limit)
} }
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> { pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
@@ -155,48 +202,54 @@ impl PostgresStore {
let table = filter.index.psql_table(); let table = filter.index.psql_table();
let mut where_clause = String::new(); let mut where_clause = String::new();
let params = self.build_filter(&mut where_clause, &filter.filters); let params = self.build_filter(&mut where_clause, &filter.filters);
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let limit = self.timeouts.maintenance;
.prepare_cached(&format!("DELETE FROM {table}{where_clause}")) let result = tokio::time::timeout(limit, async {
.await
.map_err(into_error)?;
match conn.execute(&s, params.as_slice()).await {
Ok(deleted) => return Ok(deleted),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
let mut deleted = 0;
loop {
let s = conn let s = conn
.prepare_cached(&format!( .prepare_cached(&format!("DELETE FROM {table}{where_clause}"))
"DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table}{where_clause} LIMIT {chunk_size})"
))
.await .await
.map_err(into_error)?; .map_err(into_error)?;
match conn.execute(&s, params.as_slice()).await {
Ok(deleted) => return Ok(deleted),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
let mut deleted = 0;
loop { loop {
match conn.execute(&s, params.as_slice()).await { let s = conn
Ok(0) => return Ok(deleted), .prepare_cached(&format!(
Ok(affected) => deleted += affected, "DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table}{where_clause} LIMIT {chunk_size})"
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { ))
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); .await
break; .map_err(into_error)?;
loop {
match conn.execute(&s, params.as_slice()).await {
Ok(0) => return Ok(deleted),
Ok(affected) => deleted += affected,
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
} }
Err(err) => return Err(into_error(err)),
} }
} }
} })
.await;
bounded(conn, result, limit)
} }
fn build_filter<'x>( fn build_filter<'x>(
&self, &self,
query: &mut String, query: &mut String,
filters: &'x [SearchFilter], filters: &'x [SearchFilter],
) -> Vec<&'x (dyn ToSql + Sync)> { ) -> Vec<SqlParam<'x>> {
if filters.is_empty() { if filters.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -237,28 +290,54 @@ impl PostgresStore {
if matches!(language, Language::None) { if matches!(language, Language::None) {
let _ = write!(query, "@@ {method}('{config}', ${value_pos})"); let _ = write!(query, "@@ {method}('{config}', ${value_pos})");
if let SearchValue::Text { value, .. } = value {
values.push(SqlParam::Owned(keyword_terms(value)));
continue;
}
} else { } else {
// inbuxa: a query word written as a URL, host,
// file or hyphenated word also matches as its word
// parts, which url_terms() indexes
let parts = match value {
SearchValue::Text { value, .. } => query_url_terms(value),
_ => None,
};
let parts_pos = value_pos + 1;
let _ = write!(query, "@@ ({method}('{config}', ${value_pos})"); let _ = write!(query, "@@ ({method}('{config}', ${value_pos})");
if parts.is_some() {
let _ = write!(query, " || {method}('{config}', ${parts_pos})");
}
for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] { for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] {
if fallback != config && self.ts_configs.contains(fallback) { if fallback != config && self.ts_configs.contains(fallback) {
let _ = let _ =
write!(query, " || {method}('{fallback}', ${value_pos})"); write!(query, " || {method}('{fallback}', ${value_pos})");
if parts.is_some() {
let _ = write!(
query,
" || {method}('{fallback}', ${parts_pos})"
);
}
} }
} }
query.push(')'); query.push(')');
values.push(SqlParam::Ref(value));
if let Some(parts) = parts {
values.push(SqlParam::Owned(parts));
}
continue;
} }
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} else if let SearchValue::KeyValues(kv) = value { } else if let SearchValue::KeyValues(kv) = value {
query.push_str(field.column()); query.push_str(field.column());
query.push(' '); query.push(' ');
let (key, value) = kv.iter().next().unwrap(); let (key, value) = kv.iter().next().unwrap();
values.push(key as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(key));
if !value.is_empty() { if !value.is_empty() {
let _ = write!(query, "->> ${value_pos} "); let _ = write!(query, "->> ${value_pos} ");
op.write_pqsql(query, values.len() + 1); op.write_pqsql(query, values.len() + 1);
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} else { } else {
let _ = write!(query, " ? ${value_pos}"); let _ = write!(query, " ? ${value_pos}");
} }
@@ -267,7 +346,7 @@ impl PostgresStore {
query.push(' '); query.push(' ');
op.write_pqsql(query, value_pos); op.write_pqsql(query, value_pos);
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} }
} }
SearchFilter::And | SearchFilter::Or => { SearchFilter::And | SearchFilter::Or => {
@@ -321,6 +400,107 @@ impl PostgresStore {
} }
} }
// inbuxa: PostgreSQL's text parser keeps "[email protected]" (and host names,
// URLs, file paths, ...) as a single token, so a search for "user" or
// "example.com" never matched an address. Keyword text is split into words the
// same way the built-in index splits it (SpaceTokenizer: lowercase runs of
// alphanumerics) on both the indexing and the query side, so a full address,
// its local part, its domain and the display-name words all match, as they do
// on the other backends.
pub(crate) fn keyword_terms(value: &str) -> String {
let mut terms = String::with_capacity(value.len());
for token in SpaceTokenizer::new(value, MAX_TOKEN_LENGTH) {
if !terms.is_empty() {
terms.push(' ');
}
terms.push_str(&token);
}
terms
}
// inbuxa: in language text (subject, body, attachments) PostgreSQL's parser
// keeps a URL, a host name, a path or a file name as tokens of its own:
// "https://x.example/shipping-support/" gives a url, a host and a url_path,
// "invoice-2024.pdf" a file, so a body search for "shipping" or "invoice"
// missed messages where the word appears only there, while the built-in index
// splits them into words. The text is indexed as it was, followed by the word
// parts of each such token (SpaceTokenizer, as keyword_terms() splits), so
// they go through the same configuration and stemming as the words around
// them. On sample mail the text vector grows by about 15% for a newsletter
// full of tracking links and 30% for a short order notice with three links.
// Plain words, and words that only carry punctuation ("end.", "(see"),
// add nothing; hyphenated words are already split by the parser. Returns None
// when there is nothing to add, so most text is indexed exactly as before.
/// Characters that join the parts of a URL, host, path, address or file name.
const URL_SEPARATORS: [char; 13] = [
'/', '.', '@', ':', '?', '=', '&', '#', '_', '%', '+', '~', '\\',
];
pub(crate) fn url_terms(value: &str) -> Option<String> {
let mut terms = String::new();
// Each word is added once: a phrase search still finds the first URL it
// is in, and a newsletter's hundred tracking links don't add a hundred
// positions for "utm" and "campaign"
let mut seen = std::collections::HashSet::new();
for token in value.split(|c: char| {
c.is_whitespace() || matches!(c, '<' | '>' | '"' | '(' | ')' | '[' | ']' | '{' | '}')
}) {
let token = token.trim_matches(|c: char| !c.is_alphanumeric());
if token.contains(URL_SEPARATORS) {
for word in SpaceTokenizer::new(token, MAX_TOKEN_LENGTH) {
if !seen.insert(word.clone()) {
continue;
}
if terms.is_empty() {
terms.reserve(value.len() + 64);
terms.push_str(value);
terms.push('\n');
} else {
terms.push(' ');
}
terms.push_str(&word);
}
}
}
(!terms.is_empty()).then_some(terms)
}
/// The query side of url_terms(): each query word that is a URL, host, file
/// name or hyphenated word replaced by its word parts, or None when there is
/// none. It is searched in addition to the query as written, so documents
/// indexed before url_terms() still match as they did.
pub(crate) fn query_url_terms(value: &str) -> Option<String> {
let mut terms = String::with_capacity(value.len());
let mut changed = false;
for token in value.split_whitespace() {
let word = token.trim_matches(|c: char| !c.is_alphanumeric());
if !terms.is_empty() {
terms.push(' ');
}
if word.contains(URL_SEPARATORS) || word.contains('-') {
changed = true;
terms.push_str(&keyword_terms(word));
} else {
terms.push_str(token);
}
}
changed.then_some(terms)
}
pub(super) enum SqlParam<'x> {
Ref(&'x (dyn ToSql + Sync)),
Owned(String),
}
impl SqlParam<'_> {
fn as_sql(&self) -> &(dyn ToSql + Sync) {
match self {
SqlParam::Ref(value) => *value,
SqlParam::Owned(value) => value,
}
}
}
fn build_sort(query: &mut String, sort: &[SearchComparator]) { fn build_sort(query: &mut String, sort: &[SearchComparator]) {
query.push_str(" ORDER BY "); query.push_str(" ORDER BY ");
for (i, comparator) in sort.iter().enumerate() { for (i, comparator) in sort.iter().enumerate() {
+106 -90
View File
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{PostgresStore, into_error, is_timeout_error}; use super::{PostgresStore, bounded, into_error, is_timeout_error};
use crate::{ use crate::{
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA, IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_IDX,
@@ -30,48 +32,53 @@ enum CommitError {
impl PostgresStore { impl PostgresStore {
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> { pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?; let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let start = Instant::now(); let limit = self.timeouts.query;
let mut retry_count = 0; let result = tokio::time::timeout(limit, async {
let start = Instant::now();
let mut retry_count = 0;
loop { loop {
match self.write_trx(&mut conn, &mut batch).await { match self.write_trx(&mut conn, &mut batch).await {
Ok(result) => { Ok(result) => {
return Ok(result); return Ok(result);
}
Err(err) => {
match err {
CommitError::Postgres(err) => match err.code() {
Some(
&SqlState::T_R_SERIALIZATION_FAILURE
| &SqlState::T_R_DEADLOCK_DETECTED,
) if retry_count < MAX_COMMIT_ATTEMPTS
&& start.elapsed() < MAX_COMMIT_TIME => {}
Some(&SqlState::UNIQUE_VIOLATION) => {
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.reason("Unique violation")
.caused_by(trc::location!()));
}
_ => return Err(into_error(err)),
},
CommitError::Internal(err) => return Err(err),
/*CommitError::Retry => {
if retry_count > MAX_COMMIT_ATTEMPTS
|| start.elapsed() > MAX_COMMIT_TIME
{
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.caused_by(trc::location!()));
}
}*/
} }
Err(err) => {
match err {
CommitError::Postgres(err) => match err.code() {
Some(
&SqlState::T_R_SERIALIZATION_FAILURE
| &SqlState::T_R_DEADLOCK_DETECTED,
) if retry_count < MAX_COMMIT_ATTEMPTS
&& start.elapsed() < MAX_COMMIT_TIME => {}
Some(&SqlState::UNIQUE_VIOLATION) => {
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.reason("Unique violation")
.caused_by(trc::location!()));
}
_ => return Err(into_error(err)),
},
CommitError::Internal(err) => return Err(err),
/*CommitError::Retry => {
if retry_count > MAX_COMMIT_ATTEMPTS
|| start.elapsed() > MAX_COMMIT_TIME
{
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.caused_by(trc::location!()));
}
}*/
}
let backoff = rand::rng().random_range(50..=300); let backoff = rand::rng().random_range(50..=300);
tokio::time::sleep(Duration::from_millis(backoff)).await; tokio::time::sleep(Duration::from_millis(backoff)).await;
retry_count += 1; retry_count += 1;
}
} }
} }
} })
.await;
bounded(conn, result, limit)
} }
async fn write_trx( async fn write_trx(
@@ -393,72 +400,81 @@ impl PostgresStore {
pub(crate) async fn purge_store(&self) -> trc::Result<()> { pub(crate) async fn purge_store(&self) -> trc::Result<()> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let limit = self.timeouts.maintenance;
let result = tokio::time::timeout(limit, async {
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
purge_table(&conn, char::from(subspace)).await?;
}
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { Ok(())
purge_table(&conn, char::from(subspace)).await?; })
} .await;
bounded(conn, result, limit)
Ok(())
} }
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let table = char::from(from.subspace()); let limit = self.timeouts.maintenance;
let mut from = from.serialize(0); let result = tokio::time::timeout(limit, async {
let to = to.serialize(0); let table = char::from(from.subspace());
let mut from = from.serialize(0);
let to = to.serialize(0);
let delete = conn let delete = conn
.prepare_cached(&format!("DELETE FROM {table} WHERE k >= $1 AND k < $2")) .prepare_cached(&format!("DELETE FROM {table} WHERE k >= $1 AND k < $2"))
.await
.map_err(into_error)?;
match conn.execute(&delete, &[&from, &to]).await {
Ok(_) => return Ok(()),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
loop {
let boundary = conn
.prepare_cached(&format!(
"SELECT k FROM {table} WHERE k >= $1 AND k < $2 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
))
.await .await
.map_err(into_error)?; .map_err(into_error)?;
match conn.execute(&delete, &[&from, &to]).await {
Ok(_) => return Ok(()),
Err(err) if is_timeout_error(&err) => (),
Err(err) => return Err(into_error(err)),
}
let mut chunk_size = DELETE_CHUNK_SIZE;
loop { loop {
let next = match conn.query_opt(&boundary, &[&from, &to]).await { let boundary = conn
Ok(next) => match next { .prepare_cached(&format!(
Some(row) => Some(row.try_get::<_, Vec<u8>>(0).map_err(into_error)?), "SELECT k FROM {table} WHERE k >= $1 AND k < $2 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
None => None, ))
},
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
};
match conn
.execute(&delete, &[&from, next.as_ref().unwrap_or(&to)])
.await .await
{ .map_err(into_error)?;
Ok(_) => (),
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
}
match next { loop {
Some(next) => from = next, let next = match conn.query_opt(&boundary, &[&from, &to]).await {
None => return Ok(()), Ok(next) => match next {
Some(row) => Some(row.try_get::<_, Vec<u8>>(0).map_err(into_error)?),
None => None,
},
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
};
match conn
.execute(&delete, &[&from, next.as_ref().unwrap_or(&to)])
.await
{
Ok(_) => (),
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
break;
}
Err(err) => return Err(into_error(err)),
}
match next {
Some(next) => from = next,
None => return Ok(()),
}
} }
} }
} })
.await;
bounded(conn, result, limit)
} }
} }
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Client-side limits on SQL queries.
//!
//! The pool timeouts bound getting a connection, not using one. A database
//! that stops answering while the TCP connection stays up (a paused
//! container, a hung server whose kernel still acknowledges keepalives)
//! left a query on a checked-out connection waiting for as long as it took.
//! A server-side statement_timeout can't help there: the server that would
//! enforce it is the one not answering. So each operation on a PostgreSQL
//! or MySQL connection runs under a time limit here, and a connection whose
//! operation ran out is closed rather than put back in the pool, since its
//! protocol state is unknown.
//!
//! Two limits:
//! - `query`, two minutes, for request-path work: reads, writes, blob
//! transfers, search queries and document indexing. Those take
//! milliseconds; two minutes leaves room for a large blob over a slow
//! link and still ends a hang.
//! - `maintenance`, thirty minutes, for work that legitimately runs long in
//! one statement: range deletes (account removal, purges), unindexing,
//! and creating tables and indexes at startup.
//!
//! Iterating over a range (exports, reindexing, maintenance scans) can run
//! for hours, so there the `query` limit applies to each wait for the next
//! row instead of the whole scan.
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QueryTimeouts {
pub query: Duration,
pub maintenance: Duration,
}
impl QueryTimeouts {
pub const QUERY: Duration = Duration::from_secs(120);
pub const MAINTENANCE: Duration = Duration::from_secs(30 * 60);
}
impl Default for QueryTimeouts {
fn default() -> Self {
Self {
query: Self::QUERY,
maintenance: Self::MAINTENANCE,
}
}
}
#[cfg(feature = "test_mode")]
impl crate::Store {
/// Sets the query limits of a SQL store that was just built (tests only:
/// the limits aren't configurable).
pub fn with_query_timeouts(self, timeouts: QueryTimeouts) -> Self {
match self {
#[cfg(feature = "postgres")]
crate::Store::PostgreSQL(mut store) => {
std::sync::Arc::get_mut(&mut store)
.expect("store already shared")
.timeouts = timeouts;
crate::Store::PostgreSQL(store)
}
#[cfg(feature = "mysql")]
crate::Store::MySQL(mut store) => {
std::sync::Arc::get_mut(&mut store)
.expect("store already shared")
.timeouts = timeouts;
crate::Store::MySQL(store)
}
store => store,
}
}
}
+42
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{RedisPool, RedisStore, into_error}; use super::{RedisPool, RedisStore, into_error};
@@ -79,6 +81,30 @@ impl RedisStore {
} }
} }
// inbuxa: see InMemoryStore::renew_lock
pub async fn renew_lock(&self, key: &[u8], expires: u64) -> trc::Result<bool> {
match &self.pool {
RedisPool::Single(pool) => {
with_conn(pool, async |conn| {
Self::renew_lock_(conn, key, expires).await
})
.await
}
RedisPool::Cluster(pool) => {
with_conn(pool, async |conn| {
Self::renew_lock_(conn, key, expires).await
})
.await
}
RedisPool::Sentinel(pool) => {
with_conn(pool, async |conn| {
Self::renew_lock_(conn, key, expires).await
})
.await
}
}
}
pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> { pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
@@ -226,6 +252,22 @@ impl RedisStore {
.map(|reply| reply.is_some()) .map(|reply| reply.is_some())
} }
async fn renew_lock_(
conn: &mut impl AsyncCommands,
key: &[u8],
expires: u64,
) -> RedisResult<bool> {
redis::cmd("SET")
.arg(key)
.arg(now() + expires)
.arg("XX")
.arg("EX")
.arg(expires as i64)
.query_async::<Option<String>>(conn)
.await
.map(|reply| reply.is_some())
}
async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> { async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
conn.del(key).await conn.del(key).await
} }
+51
View File
@@ -401,6 +401,57 @@ impl InMemoryStore {
} }
} }
/// inbuxa: extends a lock this node holds to `duration` seconds from now.
/// Returns false when the lock is gone or has expired: it may have been
/// taken by someone else since, so it is left alone.
pub async fn renew_lock(&self, prefix: u8, key: &[u8], duration: u64) -> trc::Result<bool> {
match self {
InMemoryStore::Store(store) => {
let key = KeyValue::<()>::build_key(prefix, key);
let key = ValueClass::InMemory(InMemoryClass::Key(key));
let Some(lock_expiry) = store
.get_value::<u64>(ValueKey::from(key.clone()))
.await
.caused_by(trc::location!())?
else {
return Ok(false);
};
let now = now();
if lock_expiry <= now {
return Ok(false);
}
let mut batch = BatchBuilder::new();
batch.assert_value(key.clone(), AssertValue::U64(lock_expiry));
batch.set(key, (now + duration).serialize());
match store.write(batch.build_all()).await {
Ok(_) => Ok(true),
Err(err) if err.is_assertion_failure() => Ok(false),
Err(err) => Err(err
.details("Failed to renew lock.")
.caused_by(trc::location!())),
}
}
InMemoryStore::Sharded(store) => {
Box::pin(
store
.member(&KeyValue::<()>::build_key(prefix, key))
.renew_lock(prefix, key, duration),
)
.await
}
#[cfg(feature = "redis")]
InMemoryStore::Redis(store) => {
store
.renew_lock(&KeyValue::<()>::build_key(prefix, key), duration)
.await
}
InMemoryStore::Static(_) | InMemoryStore::Http(_) => {
Err(trc::StoreEvent::NotSupported.into_err())
}
}
}
pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> { pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> {
self.key_delete(KeyValue::<()>::build_key(prefix, key)) self.key_delete(KeyValue::<()>::build_key(prefix, key))
.await .await
+7 -2
View File
@@ -10,8 +10,9 @@
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is // auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
// security.legacy-protocols-changed (LP-8) // security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster
pub const TOTAL_EVENT_COUNT: usize = 644; // coordinator's connection events
pub const TOTAL_EVENT_COUNT: usize = 647;
pub const TOTAL_METRIC_COUNT: usize = 369; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -150,6 +151,10 @@ pub enum ClusterEvent {
MessageSkipped = 47, MessageSkipped = 47,
MessageInvalid = 49, MessageInvalid = 49,
NodeIdRenewed = 275, NodeIdRenewed = 275,
// inbuxa: the coordinator's connection
CoordinatorConnected = 644,
CoordinatorDisconnected = 645,
CoordinatorError = 646,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+32
View File
@@ -81,6 +81,10 @@ impl EventType {
b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped), b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped),
b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid), b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid),
b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed), b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed),
// inbuxa: coordinator connection
b"cluster.coordinator-connected" => EventType::Cluster(ClusterEvent::CoordinatorConnected),
b"cluster.coordinator-disconnected" => EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
b"cluster.coordinator-error" => EventType::Cluster(ClusterEvent::CoordinatorError),
b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess), b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess),
b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure), b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure),
b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound), b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound),
@@ -742,6 +746,14 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped", EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped",
EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid", EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid",
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed", EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed",
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => {
"cluster.coordinator-connected"
}
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => {
"cluster.coordinator-disconnected"
}
EventType::Cluster(ClusterEvent::CoordinatorError) => "cluster.coordinator-error",
EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success", EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success",
EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure", EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure",
EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found", EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found",
@@ -1524,6 +1536,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => 47, EventType::Cluster(ClusterEvent::MessageSkipped) => 47,
EventType::Cluster(ClusterEvent::MessageInvalid) => 49, EventType::Cluster(ClusterEvent::MessageInvalid) => 49,
EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275, EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275,
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => 644,
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => 645,
EventType::Cluster(ClusterEvent::CoordinatorError) => 646,
EventType::Dane(DaneEvent::AuthenticationSuccess) => 67, EventType::Dane(DaneEvent::AuthenticationSuccess) => 67,
EventType::Dane(DaneEvent::AuthenticationFailure) => 66, EventType::Dane(DaneEvent::AuthenticationFailure) => 66,
EventType::Dane(DaneEvent::NoCertificatesFound) => 69, EventType::Dane(DaneEvent::NoCertificatesFound) => 69,
@@ -2176,6 +2192,10 @@ impl EventType {
47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)), 47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)),
49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)), 49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)),
275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)), 275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)),
// inbuxa: coordinator connection
644 => Some(EventType::Cluster(ClusterEvent::CoordinatorConnected)),
645 => Some(EventType::Cluster(ClusterEvent::CoordinatorDisconnected)),
646 => Some(EventType::Cluster(ClusterEvent::CoordinatorError)),
67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)), 67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)),
66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)), 66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)),
69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)), 69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)),
@@ -3114,6 +3134,10 @@ impl EventType {
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn, EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn, EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn, EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn,
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => Level::Info,
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => Level::Warn,
EventType::Cluster(ClusterEvent::CoordinatorError) => Level::Warn,
EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn, EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn,
EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn, EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn,
EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn, EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn,
@@ -3244,6 +3268,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped", EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped",
EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message", EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message",
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed", EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed",
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => "Coordinator connected",
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => "Coordinator unavailable",
EventType::Cluster(ClusterEvent::CoordinatorError) => "Coordinator error",
EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful", EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful",
EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed", EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed",
EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE", EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE",
@@ -4322,6 +4350,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped), EventType::Cluster(ClusterEvent::MessageSkipped),
EventType::Cluster(ClusterEvent::MessageInvalid), EventType::Cluster(ClusterEvent::MessageInvalid),
EventType::Cluster(ClusterEvent::NodeIdRenewed), EventType::Cluster(ClusterEvent::NodeIdRenewed),
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected),
EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
EventType::Cluster(ClusterEvent::CoordinatorError),
EventType::Dane(DaneEvent::AuthenticationSuccess), EventType::Dane(DaneEvent::AuthenticationSuccess),
EventType::Dane(DaneEvent::AuthenticationFailure), EventType::Dane(DaneEvent::AuthenticationFailure),
EventType::Dane(DaneEvent::NoCertificatesFound), EventType::Dane(DaneEvent::NoCertificatesFound),
+21 -3
View File
@@ -245,9 +245,27 @@ impl Collector {
Update::RegisterReceiver { receiver } => { Update::RegisterReceiver { receiver } => {
self.receivers.push(receiver); self.receivers.push(receiver);
} }
Update::RegisterSubscriber { subscriber } => { Update::RegisterSubscriber { mut subscriber } => {
ACTIVE_SUBSCRIBERS.lock().push(subscriber.id.clone()); // inbuxa: a subscriber registered under the id of a
self.subscribers.push(subscriber); // running one replaces it (a tracer whose settings
// changed). Every event collected so far went to the old
// one, every later event goes to the new one: the old
// one's batch is sent first (anything its full channel
// can't take moves over, rather than being dropped), and
// dropping it closes its channel, so its task writes
// what is queued and ends.
if let Some(old) = self.subscribers.iter_mut().find(|s| s.id == subscriber.id) {
let _ = old.send_batch();
if !old.batch.is_empty() {
let mut batch = std::mem::take(&mut old.batch);
batch.append(&mut subscriber.batch);
subscriber.batch = batch;
}
*old = subscriber;
} else {
ACTIVE_SUBSCRIBERS.lock().push(subscriber.id.clone());
self.subscribers.push(subscriber);
}
} }
Update::UnregisterSubscriber { id } => { Update::UnregisterSubscriber { id } => {
ACTIVE_SUBSCRIBERS.lock().retain(|s| s != &id); ACTIVE_SUBSCRIBERS.lock().retain(|s| s != &id);
+5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::sync::Arc; use std::sync::Arc;
@@ -105,6 +107,9 @@ impl SubscriberBuilder {
self self
} }
/// Registers the subscriber with the collector. inbuxa: one registered
/// under the id of a running subscriber replaces it, handing over at an
/// event boundary; the old one's channel then closes.
pub fn register(self) -> (mpsc::Sender<EventBatch>, mpsc::Receiver<EventBatch>) { pub fn register(self) -> (mpsc::Sender<EventBatch>, mpsc::Receiver<EventBatch>) {
let (tx, rx) = mpsc::channel(8192); let (tx, rx) = mpsc::channel(8192);
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.24" "2026.9.24.3"
}; };
} }
+4 -1
View File
@@ -102,7 +102,10 @@ Permissions: `sysSpamLlmGet`, `sysSpamLlmUpdate`.
- **Tags and scores.** The classifier's tags are ordinary spam tags, scored - **Tags and scores.** The classifier's tags are ordinary spam tags, scored
by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard` by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard`
or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and
`LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. `LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. The server ships
those entries in its bundled spam rules (`resources/spam-filter/`), loaded
on first boot and again when the bundled version changes, so an install
that predates them gains them on upgrade (added 2026-09-23).
- **`interactAi`** permission ("Interact with AI models"): lets an account's - **`interactAi`** permission ("Interact with AI models"): lets an account's
own Sieve scripts call `llm_prompt`. This repository's default roles give it own Sieve scripts call `llm_prompt`. This repository's default roles give it
to users, tenant administrators and superusers to users, tenant administrators and superusers
+8 -3
View File
@@ -212,10 +212,15 @@ unchanged.
- **MON-16.** With `indexTelemetry` on, storing a trace schedules an - **MON-16.** With `indexTelemetry` on, storing a trace schedules an
`IndexTrace` task. The task builds one document for `SearchIndex::Tracing` `IndexTrace` task. The task builds one document for `SearchIndex::Tracing`
with the fields named in `indexTracingFields`: with the fields named in `indexTracingFields`:
- `eventType`: every event type in the trace; - `eventType`: the trace's opening event, as its numeric id;
- `queueId`: every `queueId` value; - `queueId`: the first `queueId` value, as an integer;
- `keywords`: every address in `from` and `to`, each address's domain, every - `keywords`: every address in `from` and `to`, each address's domain, every
`domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value. `domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value,
and every `queueId` value.
The event type and queue id are single integer columns on every search
backend (BIGINT on PostgreSQL and MySQL), so the `queueId` filter matches
the column or any queue id in the keywords, and a session that queued
several messages is found by each of them.
So searching `example.org` finds every trace to or from that domain, as the So searching `example.org` finds every trace to or from that domain, as the
upstream suite expects. With `indexTelemetry` off nothing is indexed, and upstream suite expects. With `indexTelemetry` off nothing is indexed, and
the `text` and `queueId` filters are refused (see "Interfaces"). the `text` and `queueId` filters are refused (see "Interfaces").
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
rWqJwNJkqgKsbC1eqcEmmIAMtJPmnlDlThR2ZtW_N1c XFI3xuKC_rH1KZyaVBF0uTIiRDXRqyYboijquiGz2eg
+32
View File
@@ -0,0 +1,32 @@
# Bundled spam filter rules
`spam-filter-rules.json.gz` is the published rules file of
[spam-filter](https://github.com/stalwartlabs/spam-filter) **v3.0.2**,
unmodified. The server embeds it (`crates/common/src/manager/spam_rules.rs`)
and loads it whenever no other rules source is configured, so a release
scores mail with the rules it was tested with, offline and with nothing to
fetch. The rules URL setting stays an operator override.
The rules are dual-licensed MIT or Apache-2.0, Copyright (C) 2024, Stalwart
Labs LLC; the fork takes them under MIT, with the notice in `THIRD-PARTY.md`.
They include the scores for the AI classifier's tags (`LLM_*`, 3.0 for the
high-confidence spam categories, −3.0 for legitimate), which match
`docs/spec/features/ai-spam-classification.md`.
## Updating
The `upstream-watch` workflow opens an issue when spam-filter publishes a
newer release. To take it:
1. Download `spam-filter-rules.json.gz` from that release, pinned by tag
(`releases/download/vX.Y.Z/…`, not `latest`), over this file.
2. Set `BUNDLED_SPAM_RULES_VERSION` in `spam_rules.rs` and the version in
this README and in `THIRD-PARTY.md`.
3. Run the antispam test (`STORE=RocksDb RUST_MIN_STACK=16777216 cargo test
-p tests --lib -- smtp::inbound::antispam::antispam --exact`) and fix
expectations the new rules change, knowingly.
On the next start each server loads the new version once. Loading only adds
rules and tags that are missing; it never changes an existing one, so an
operator's own adjustments survive.
+44 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -11,6 +13,7 @@ use crate::{
server::TestServerBuilder, server::TestServerBuilder,
}, },
}; };
use common::BuildServer;
use imap_proto::ResponseType; use imap_proto::ResponseType;
use registry::{ use registry::{
schema::{ schema::{
@@ -18,7 +21,8 @@ use registry::{
prelude::{ObjectType, Property, SocketAddr}, prelude::{ObjectType, Property, SocketAddr},
structs::{ structs::{
ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup, ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup,
Coordinator, Imap, NatsCoordinator, NetworkListener, RedisStore, Coordinator, Imap, MtaDeliverySchedule, MtaVirtualQueue, NatsCoordinator,
NetworkListener, RedisStore,
}, },
}, },
types::map::Map, types::map::Map,
@@ -209,6 +213,45 @@ pub async fn cluster_tests() {
Some("John Doe") Some("John Doe")
); );
// inbuxa: a settings write applies on every node, no ReloadSettings
let queue_id = admin
.registry_create_object(MtaVirtualQueue {
name: "clusterq".into(),
threads_per_node: 1,
description: None,
})
.await;
admin
.registry_create_object(MtaDeliverySchedule {
name: "cluster-autoreload".into(),
queue_id,
..Default::default()
})
.await;
for (node_id, test) in servers.iter().enumerate() {
let started = std::time::Instant::now();
while !test
.server
.inner
.build_server()
.core
.smtp
.queue
.queue_strategy
.contains_key("cluster-autoreload")
{
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"node {node_id} didn't pick up the new delivery schedule"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
println!(
"Node {node_id} has the new delivery schedule after {} ms",
started.elapsed().as_millis()
);
}
// Run IMAP idle tests across nodes // Run IMAP idle tests across nodes
let mut node1_client = imap_client("[email protected]", "this is john's secret", 1).await; let mut node1_client = imap_client("[email protected]", "this is john's secret", 1).await;
let mut node2_client = imap_client("[email protected]", "this is john's secret", 2).await; let mut node2_client = imap_client("[email protected]", "this is john's secret", 2).await;
+145
View File
@@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A node that starts while its NATS coordinator is down joins the cluster
//! once NATS comes up, without a restart, and reports the coordinator's
//! connection on `/healthz/cluster` as it goes and comes back.
use crate::utils::server::TestServerBuilder;
use coordinator::Coordinator;
use registry::{
schema::{
enums::NetworkListenerProtocol,
structs::{Coordinator as CoordinatorSetting, NatsCoordinator},
},
types::map::Map,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
use testcontainers::{
GenericImage, ImageExt, core::IntoContainerPort, core::WaitFor, runners::AsyncRunner,
};
const HTTP_PORT: u16 = 11_310;
const TOPIC: &str = "inbuxa-coordinator-test";
#[tokio::test(flavor = "multi_thread")]
pub async fn coordinator_reconnect_tests() {
println!("Running coordinator reconnect tests...");
// A port with no NATS server behind it, yet
let nats_port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let config = NatsCoordinator {
addresses: Map::new(vec![format!("127.0.0.1:{nats_port}")]),
use_tls: false,
timeout_connection: 1_000u64.into(),
..Default::default()
};
// 1. The node starts, without a build error, while NATS is down, and
// says so
let test = TestServerBuilder::new("coordinator_reconnect_tests")
.await
.with_object(CoordinatorSetting::Nats(config.clone()))
.await
.with_listener(NetworkListenerProtocol::Http, "http", HTTP_PORT, true)
.await
.build()
.await;
let coordinator = test.server.core.storage.coordinator.clone();
assert!(
coordinator.is_enabled(),
"a coordinator, though not connected"
);
assert_eq!(coordinator.is_connected(), Some(false));
assert_eq!(
cluster_health().await,
(503, json!({"coordinator": "disconnected"}))
);
// A subscription made now, as the broadcast subscriber makes it at
// startup, has to work once NATS is up
let mut stream = coordinator.subscribe(TOPIC).await.unwrap();
// 2. NATS comes up: the node connects on its own
let nats = GenericImage::new("nats", "latest")
.with_wait_for(WaitFor::message_on_stderr("Server is ready"))
.with_mapped_port(nats_port, 4222.tcp())
.start()
.await
.expect("Failed to start NATS container");
wait_for_health(200, "connected").await;
let other_node = coordinator::backend::nats::NatsPubSub::open(config.clone())
.await
.unwrap();
wait_until_connected(&other_node).await;
round_trip(&other_node, &mut stream, b"after startup").await;
// 3. NATS goes away: the node reports it; and it comes back: the node
// reconnects and the same subscription carries on
nats.stop().await.unwrap();
wait_for_health(503, "disconnected").await;
nats.start().await.unwrap();
wait_for_health(200, "connected").await;
wait_until_connected(&other_node).await;
round_trip(&other_node, &mut stream, b"after reconnect").await;
drop(nats);
if test.is_reset() {
test.temp_dir.delete();
}
}
async fn cluster_health() -> (u16, Value) {
let response = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(5))
.build()
.unwrap()
.get(format!("https://127.0.0.1:{HTTP_PORT}/healthz/cluster"))
.send()
.await
.unwrap();
let status = response.status().as_u16();
(status, response.json().await.unwrap())
}
async fn wait_for_health(status: u16, state: &str) {
let started = Instant::now();
loop {
let health = cluster_health().await;
if health == (status, json!({"coordinator": state})) {
return;
}
assert!(
started.elapsed() < Duration::from_secs(30),
"expected {status} {state}, still {health:?}"
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
async fn wait_until_connected(coordinator: &Coordinator) {
let started = Instant::now();
while coordinator.is_connected() != Some(true) {
assert!(started.elapsed() < Duration::from_secs(30), "not connected");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Another node publishes; this one's subscription receives it.
async fn round_trip(from: &Coordinator, stream: &mut coordinator::PubSubStream, payload: &[u8]) {
from.publish(TOPIC, payload.to_vec()).await.unwrap();
let message = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("no message within 10 seconds")
.expect("subscription ended");
assert_eq!(message.payload(), payload);
}
+317
View File
@@ -0,0 +1,317 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! DMARC results recorded on a node without outboundMta reach the aggregate
//! report, which a node with outboundMta builds and sends. Before, a front
//! node's results were dropped (or, before live roles, left in a channel
//! nobody read), so the report covered only the mail the outbound nodes
//! received. Also checks that nodes appending to one report at once lose
//! nothing. Needs a store the nodes can share (STORE=PostgreSql or MySql).
use crate::{smtp::inbound::TestMessage, utils::server::TestServerBuilder};
use common::{Server, config::smtp::report::AggregateFrequency, ipc::DmarcEvent};
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
report::{ActionDisposition, DmarcResult, Record, Report},
};
use registry::{
schema::{
enums::ClusterTaskType,
prelude::{ObjectType, Property},
structs::{
ClusterListenerGroup, ClusterRole, ClusterTaskGroup, ClusterTaskGroupProperties,
DmarcInternalReport, DmarcReportSettings, Expression, Task, TaskDmarcReport,
TaskStatus,
},
},
types::{EnumImpl, map::Map},
};
use smtp::reporting::{dmarc::DmarcReporting, send::MtaReportSend};
use std::{
collections::BTreeSet,
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use store::{
ValueKey,
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, now},
};
use types::id::Id;
const FRONT_ROLE: &str = "front_reports_front";
const MTA_ROLE: &str = "front_reports_mta";
const DOMAIN: &str = "front-reports.example";
#[tokio::test(flavor = "multi_thread")]
pub async fn front_node_report_tests() {
if matches!(
std::env::var("STORE").as_deref(),
Ok("RocksDb" | "Sqlite") | Err(_)
) {
println!("Skipping front node report tests: they need a store the nodes can share.");
return;
}
println!(
"Running front node report tests on {}...",
std::env::var("STORE").unwrap_or_default()
);
// A front role without outboundMta, an MTA role with it
let seed = TestServerBuilder::new("front_reports_seed").await;
seed.insert_object(role(FRONT_ROLE, &[ClusterTaskType::PushNotifications]))
.await;
seed.insert_object(role(MTA_ROLE, &[ClusterTaskType::OutboundMta]))
.await;
seed.insert_object(DmarcReportSettings {
aggregate_max_report_size: Expression {
else_: "1048576".into(),
..Default::default()
},
..Default::default()
})
.await;
let seed = seed.disable_services().build().await;
// The front node receives mail from two sources: the events its SMTP
// sessions hand the report scheduler
let front = TestServerBuilder::new_with_role(
"front_reports_front",
"front.front-reports.example".into(),
Some(FRONT_ROLE.into()),
false,
)
.await
.build_with_opts(false)
.await;
let front_server = front.server.clone();
assert!(!front_server.core.network.roles.outbound_mta);
for ip in ["192.0.2.1", "192.0.2.2"] {
front_server.schedule_report(event(ip)).await;
}
// Both are recorded in the shared report. Upstream, and main after live
// roles, left the front node's results out
let report_id = wait_for_report(&front_server, 2).await;
// Make the report due now. The front node leaves it alone: building and
// sending it is the outbound MTA's
move_task(&front_server, report_id, TaskStatus::now()).await;
tokio::time::sleep(Duration::from_secs(3)).await;
front_server.notify_task_queue();
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
task_exists(&front_server, report_id).await,
"the front node ran the report task"
);
// Several writers append to the report at once, from both nodes: none
// of their records is lost. The report waits in the future meanwhile, or
// the MTA node would send it as soon as it starts
move_task(
&front_server,
report_id,
TaskStatus::at(now() as i64 + 3600),
)
.await;
let mut mta = TestServerBuilder::new_with_role(
"front_reports_mta",
"mta.front-reports.example".into(),
Some(MTA_ROLE.into()),
false,
)
.await
.capture_queue()
.build_with_opts(false)
.await;
let mta_server = mta.server.clone();
assert!(mta_server.core.network.roles.outbound_mta);
let concurrent: Vec<String> = (10..18).map(|n| format!("192.0.2.{n}")).collect();
let mut handles = Vec::new();
for (n, ip) in concurrent.iter().enumerate() {
let server = if n % 2 == 0 {
front_server.clone()
} else {
mta_server.clone()
};
let ip = ip.clone();
handles.push(tokio::spawn(async move {
server.schedule_dmarc(Box::new(event(&ip))).await;
}));
}
for handle in handles {
handle.await.unwrap();
}
// Due again, the MTA node sends the report with every record in it
move_task(&mta_server, report_id, TaskStatus::now()).await;
let message = mta.expect_message().await;
let report =
Report::parse_rfc5322(message.read_message(&mta).await.as_bytes(), usize::MAX).unwrap();
assert_eq!(report.domain(), DOMAIN);
let sent: BTreeSet<IpAddr> = report
.records()
.iter()
.map(|r| r.source_ip().unwrap())
.collect();
let expected: BTreeSet<IpAddr> = ["192.0.2.1", "192.0.2.2"]
.into_iter()
.map(String::from)
.chain(concurrent)
.map(|ip| ip.parse().unwrap())
.collect();
assert_eq!(sent, expected);
wait_for(Duration::from_secs(20), "report task to finish", || async {
!task_exists(&mta_server, report_id).await
})
.await;
assert!(reports(&mta_server).await.is_empty());
if seed.is_reset() {
seed.temp_dir.delete();
front.temp_dir.delete();
mta.temp_dir.delete();
}
}
fn role(name: &str, tasks: &[ClusterTaskType]) -> ClusterRole {
ClusterRole {
name: name.into(),
description: None,
listeners: ClusterListenerGroup::EnableAll,
tasks: ClusterTaskGroup::EnableSome(ClusterTaskGroupProperties {
task_types: Map::new(tasks.to_vec()),
}),
}
}
fn event(ip: &str) -> DmarcEvent {
DmarcEvent {
domain: DOMAIN.to_string(),
report_record: Record::new()
.with_source_ip(ip.parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Pass)
.with_envelope_from("sender.example")
.with_header_from("sender.example"),
dmarc_record: Arc::new(
Dmarc::parse(format!("v=DMARC1; p=reject; rua=mailto:reports@{DOMAIN}").as_bytes())
.unwrap(),
),
interval: AggregateFrequency::Daily,
span_id: 0,
}
}
async fn reports(server: &Server) -> Vec<(u64, DmarcInternalReport)> {
let ids = server
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::DmarcInternalReport).filter(
RegistryFilter::greater_than(
Property::Domain,
RegistryFilterValue::Bytes(vec![]),
true,
),
))
.await
.unwrap();
let mut reports = Vec::new();
for id in ids {
if let Some(report) = server
.store()
.get_value::<DmarcInternalReport>(ValueKey::from(ValueClass::Registry(
RegistryClass::Item {
object_id: ObjectType::DmarcInternalReport.to_id(),
item_id: id.id(),
},
)))
.await
.unwrap()
{
reports.push((id.id(), report));
}
}
reports
}
/// Waits for the report for `DOMAIN` to hold `records` records; returns its id.
async fn wait_for_report(server: &Server, records: usize) -> u64 {
let started = Instant::now();
loop {
let found = reports(server)
.await
.into_iter()
.find(|(_, report)| report.domain == DOMAIN);
if let Some((id, report)) = &found
&& report.report.records.len() == records
{
return *id;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"no report with {records} records for {DOMAIN}: {found:?}"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Reschedules the report's task.
async fn move_task(server: &Server, id: u64, status: TaskStatus) {
let task = server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
.expect("report task missing");
let mut batch = BatchBuilder::new();
batch
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id,
due: task.due_timestamp(),
}))
.schedule_task_with_id(
id,
Task::DmarcReport(TaskDmarcReport {
report_id: id.into(),
status,
}),
);
server.store().write(batch.build_all()).await.unwrap();
server.notify_task_queue();
}
async fn task_exists(server: &Server, id: u64) -> bool {
server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
.is_some()
}
async fn wait_for<F, Fut>(within: Duration, what: &str, mut check: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let started = Instant::now();
while !check().await {
assert!(
started.elapsed() < within,
"still waiting for the {what} after {:?}",
started.elapsed()
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
+297
View File
@@ -0,0 +1,297 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A node follows edits to its cluster role without a restart: outbound
//! delivery and report tasks start when the role gains outboundMta and stop
//! when it loses it. Upstream decided at boot whether the queue, report and
//! task managers ran at all. Needs a store the seed and the node can share
//! (STORE=PostgreSql or MySql).
use crate::utils::server::{TestServer, TestServerBuilder};
use common::Server;
use registry::{
schema::{
enums::ClusterTaskType,
prelude::{Object, ObjectType},
structs::{
ClusterListenerGroup, ClusterRole, ClusterTaskGroup, ClusterTaskGroupProperties, Task,
TaskStatus, TaskTlsReport,
},
},
types::{id::ObjectId, map::Map},
};
use smtp::{
queue::{Message, Status},
reporting::send::MtaReportSend,
};
use std::time::{Duration, Instant};
use store::{
Deserialize, IterateParams, ValueKey,
registry::write::{RegistryWrite, RegistryWriteResult},
write::{AlignedBytes, Archive, BatchBuilder, QueueClass, TaskQueueClass, ValueClass},
};
use types::id::Id;
use utils::snowflake::SnowflakeIdGenerator;
const BUSY_ROLE: &str = "live_role_busy";
const IDLE_ROLE: &str = "live_role_idle";
const WITH: &[ClusterTaskType] = &[
ClusterTaskType::PushNotifications,
ClusterTaskType::OutboundMta,
];
const WITHOUT: &[ClusterTaskType] = &[ClusterTaskType::PushNotifications];
const RCPT_DOMAIN: &str = "live-role.invalid";
#[tokio::test(flavor = "multi_thread")]
pub async fn live_role_tests() {
if matches!(
std::env::var("STORE").as_deref(),
Ok("RocksDb" | "Sqlite") | Err(_)
) {
println!("Skipping live role tests: they need a store the nodes can share.");
return;
}
println!(
"Running live role tests on {}...",
std::env::var("STORE").unwrap_or_default()
);
// Two roles: one with outboundMta, one with no task type at all
let seed = TestServerBuilder::new("live_roles_seed").await;
let busy_id = seed.insert_object(role(BUSY_ROLE, WITH)).await;
let idle_id = seed.insert_object(role(IDLE_ROLE, WITHOUT)).await;
let seed = seed.disable_services().build().await;
let registry = seed.server.clone();
// 1. The rehearsal case: a node started with outboundMta has it taken
// away. Upstream kept delivering, report messages included, until a
// restart.
let node = start_node("live_roles_busy", BUSY_ROLE).await;
let server = node.server.clone();
assert!(server.core.network.roles.outbound_mta);
let (msg, task) = queue_work(&server, "busy-before").await;
assert_runs(&server, &msg, task).await;
set_role(&registry, &server, busy_id, role(BUSY_ROLE, WITHOUT)).await;
let (msg, task) = queue_work(&server, "busy-off").await;
assert_idle(&server, &msg, task).await;
// Given back, it takes up the work left waiting
set_role(&registry, &server, busy_id, role(BUSY_ROLE, WITH)).await;
assert_runs(&server, &msg, task).await;
// Off again, so it leaves the next node's work alone
set_role(&registry, &server, busy_id, role(BUSY_ROLE, WITHOUT)).await;
// 2. A node started with no task type at all gains outboundMta.
// Upstream never started its queue, report or task manager, so the
// role did nothing until a restart.
let node2 = start_node("live_roles_idle", IDLE_ROLE).await;
let server2 = node2.server.clone();
assert!(!server2.core.network.roles.outbound_mta);
let (msg, task) = queue_work(&server2, "idle-off").await;
assert_idle(&server2, &msg, task).await;
set_role(&registry, &server2, idle_id, role(IDLE_ROLE, WITH)).await;
assert_runs(&server2, &msg, task).await;
set_role(&registry, &server2, idle_id, role(IDLE_ROLE, WITHOUT)).await;
if seed.is_reset() {
seed.temp_dir.delete();
node.temp_dir.delete();
node2.temp_dir.delete();
}
}
async fn start_node(name: &str, role: &str) -> TestServer {
TestServerBuilder::new_with_role(
name,
format!("{name}.example.com").replace('_', "-"),
Some(role.into()),
false,
)
.await
.build_with_opts(false)
.await
}
/// Neither the message nor the report task is touched.
async fn assert_idle(server: &Server, msg: &str, task: u64) {
tokio::time::sleep(Duration::from_secs(4)).await;
server.notify_task_queue();
tokio::time::sleep(Duration::from_secs(1)).await;
assert!(
!attempted(server, msg).await,
"delivery attempted without outboundMta"
);
assert!(
is_pending(server, task).await,
"report task claimed without outboundMta"
);
}
/// Delivery of the message is attempted and the report task runs.
async fn assert_runs(server: &Server, msg: &str, task: u64) {
wait_for(Duration::from_secs(20), "message delivery attempt", || {
attempted(server, msg)
})
.await;
wait_for(Duration::from_secs(20), "report task to run", || async {
!is_pending(server, task).await
})
.await;
}
fn role(name: &str, tasks: &[ClusterTaskType]) -> ClusterRole {
ClusterRole {
name: name.into(),
description: None,
listeners: ClusterListenerGroup::EnableAll,
tasks: ClusterTaskGroup::EnableSome(ClusterTaskGroupProperties {
task_types: Map::new(tasks.to_vec()),
}),
}
}
/// Stores a new version of a role and reloads the node's settings, as a
/// JMAP write to the role does.
async fn set_role(registry: &Server, node: &Server, id: Id, new: ClusterRole) {
let enabled = matches!(&new.tasks, ClusterTaskGroup::EnableSome(group)
if group.task_types.iter().any(|t| *t == ClusterTaskType::OutboundMta));
let old = registry
.registry()
.get(ObjectId::new(ObjectType::ClusterRole, id))
.await
.unwrap()
.expect("role not found");
let new = Object::from(new);
let result = registry
.registry()
.write(RegistryWrite::update(id, &new, &old))
.await
.unwrap();
assert!(
matches!(result, RegistryWriteResult::Success(_)),
"role update refused"
);
assert_eq!(
node.reload_after_write(ObjectType::ClusterRole).await,
Some(Ok(()))
);
assert_eq!(
node.inner.shared_core.load().network.roles.outbound_mta,
enabled
);
}
/// Queues a message to an unreachable domain and schedules a TLS report
/// task, both due now. Returns the recipient's local part and the task id.
async fn queue_work(server: &Server, name: &str) -> (String, u64) {
let local = format!("{name}-{}", SnowflakeIdGenerator::global_id().unwrap());
let rcpt = format!("{local}@{RCPT_DOMAIN}");
server
.send_autogenerated(
"[email protected]",
[rcpt.as_str()].into_iter(),
format!(
"From: [email protected]\r\nTo: {rcpt}\r\n\
Subject: live role test\r\n\r\nTest\r\n"
)
.into_bytes(),
None,
0,
)
.await;
assert!(
queued_recipient(server, &rcpt).await.is_some(),
"message to {rcpt} was not queued"
);
let task = SnowflakeIdGenerator::global_id().unwrap();
let mut batch = BatchBuilder::new();
batch.schedule_task_with_id(
task,
Task::TlsReport(TaskTlsReport {
report_id: u64::MAX.into(),
status: TaskStatus::now(),
}),
);
server.store().write(batch.build_all()).await.unwrap();
server.notify_task_queue();
(rcpt, task)
}
/// Whether delivery to `rcpt` was tried: the message is gone, or its
/// recipient is no longer scheduled or has a retry count.
async fn attempted(server: &Server, rcpt: &str) -> bool {
match queued_recipient(server, rcpt).await {
None => true,
Some((status_scheduled, retries)) => !status_scheduled || retries > 0,
}
}
/// The queued recipient `rcpt`: whether it is still scheduled, and how many
/// times delivery was retried.
async fn queued_recipient(server: &Server, rcpt: &str) -> Option<(bool, u32)> {
let mut found = None;
server
.store()
.iterate(
IterateParams::new(
ValueKey::from(ValueClass::Queue(QueueClass::Message(0))),
ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))),
),
|_, value| {
let message = <Archive<AlignedBytes> as Deserialize>::deserialize(value)?
.deserialize::<Message>()?;
if let Some(recipient) = message
.recipients
.iter()
.find(|recipient| recipient.address.as_ref() == rcpt)
{
found = Some((
matches!(recipient.status, Status::Scheduled),
recipient.retry.inner,
));
return Ok(false);
}
Ok(true)
},
)
.await
.unwrap();
found
}
async fn is_pending(server: &Server, id: u64) -> bool {
matches!(
server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
.map(|task| task.status().clone()),
Some(TaskStatus::Pending(_))
)
}
async fn wait_for<F, Fut>(within: Duration, what: &str, mut check: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let started = Instant::now();
while !check().await {
assert!(
started.elapsed() < within,
"still waiting for the {what} after {:?}",
started.elapsed()
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
+7
View File
@@ -2,7 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
pub mod broadcast; pub mod broadcast;
pub mod front_reports; // inbuxa: every node records DMARC and TLS results
pub mod live_roles; // inbuxa: role edits apply without a restart
#[cfg(feature = "nats")]
pub mod coordinator; // inbuxa: coordinator reconnects
pub mod stress; pub mod stress;
pub mod task_roles; // inbuxa: task types follow cluster roles
+218
View File
@@ -0,0 +1,218 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Two task managers with different cluster roles over one shared store:
//! each runs only the task types its role allows, and a task one node may
//! not run is left for the node that may. Needs a store both nodes can open
//! (STORE=PostgreSql or MySql).
use crate::utils::server::TestServerBuilder;
use common::Server;
use registry::{
schema::{
enums::{ClusterTaskType, IndexDocumentType},
structs::{
ClusterListenerGroup, ClusterRole, ClusterTaskGroup, ClusterTaskGroupProperties, Task,
TaskDnsManagement, TaskIndexDocument, TaskStatus, TaskTlsReport,
},
},
types::map::Map,
};
use std::time::{Duration, Instant};
use store::{
ValueKey,
write::{BatchBuilder, TaskQueueClass, ValueClass},
};
use utils::snowflake::SnowflakeIdGenerator;
const QUEUE_ROLE: &str = "tasks_queue";
const INDEX_MTA_ROLE: &str = "tasks_index_mta";
#[tokio::test(flavor = "multi_thread")]
pub async fn task_role_tests() {
if matches!(
std::env::var("STORE").as_deref(),
Ok("RocksDb" | "Sqlite") | Err(_)
) {
println!("Skipping task role tests: they need a store both nodes can open.");
return;
}
println!(
"Running task role tests on {}...",
std::env::var("STORE").unwrap_or_default()
);
// The roles, stored by a node that runs no services of its own (a node
// looks its role up when it starts)
let seed = TestServerBuilder::new("task_roles_seed")
.await
.with_object(role(QUEUE_ROLE, &[ClusterTaskType::TaskQueueProcessing]))
.await
.with_object(role(
INDEX_MTA_ROLE,
&[
ClusterTaskType::SearchIndexing,
ClusterTaskType::OutboundMta,
],
))
.await
.disable_services()
.build()
.await;
// Node A runs queue tasks (taskQueueProcessing) only
let node_a = TestServerBuilder::new_with_role(
"task_roles_a",
"node-a.example.com".into(),
Some(QUEUE_ROLE.into()),
false,
)
.await
.build_with_opts(false)
.await;
let server_a = node_a.server.clone();
let roles = &server_a.core.network.roles;
assert!(roles.task_manager && !roles.search_indexing && !roles.outbound_mta);
// A DNS task (taskQueueProcessing), an unindex task (searchIndexing) and
// a TLS report (outboundMta), all due now
let [dns, unindex, report] = new_task_ids();
let mut batch = BatchBuilder::new();
batch
.schedule_task_with_id(
dns,
Task::DnsManagement(TaskDnsManagement {
status: TaskStatus::now(),
..Default::default()
}),
)
.schedule_task_with_id(
unindex,
Task::UnindexDocument(TaskIndexDocument {
account_id: 0u32.into(),
document_id: u32::MAX.into(),
document_type: IndexDocumentType::File,
status: TaskStatus::now(),
}),
)
.schedule_task_with_id(
report,
Task::TlsReport(TaskTlsReport {
report_id: u64::MAX.into(),
status: TaskStatus::now(),
}),
);
server_a.store().write(batch.build_all()).await.unwrap();
server_a.notify_task_queue();
// Node A runs the DNS task and leaves the other two alone. Upstream ran
// the TLS report here too: report tasks ran on any node with a task
// manager.
wait_until_run(&server_a, &[dns], Duration::from_secs(20)).await;
tokio::time::sleep(Duration::from_secs(3)).await;
server_a.notify_task_queue();
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
is_pending(&server_a, unindex).await,
"unindex ran on node A"
);
assert!(
is_pending(&server_a, report).await,
"TLS report ran on node A"
);
// Node B (search indexing and outbound MTA) comes up and picks up what
// node A left
let node_b = TestServerBuilder::new_with_role(
"task_roles_b",
"node-b.example.com".into(),
Some(INDEX_MTA_ROLE.into()),
false,
)
.await
.build_with_opts(false)
.await;
let server_b = node_b.server.clone();
let roles = &server_b.core.network.roles;
assert!(!roles.task_manager && roles.search_indexing && roles.outbound_mta);
server_b.notify_task_queue();
wait_until_run(&server_b, &[unindex, report], Duration::from_secs(20)).await;
// A queue task scheduled now still runs, on node A: node B may not
// claim it
let [dns] = new_task_ids();
let mut batch = BatchBuilder::new();
batch.schedule_task_with_id(
dns,
Task::DnsManagement(TaskDnsManagement {
status: TaskStatus::now(),
..Default::default()
}),
);
server_b.store().write(batch.build_all()).await.unwrap();
server_b.notify_task_queue();
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(is_pending(&server_b, dns).await, "DNS task ran on node B");
server_a.notify_task_queue();
wait_until_run(&server_a, &[dns], Duration::from_secs(20)).await;
if seed.is_reset() {
seed.temp_dir.delete();
node_a.temp_dir.delete();
node_b.temp_dir.delete();
}
}
fn role(name: &str, tasks: &[ClusterTaskType]) -> ClusterRole {
ClusterRole {
name: name.into(),
description: None,
listeners: ClusterListenerGroup::EnableAll,
tasks: ClusterTaskGroup::EnableSome(ClusterTaskGroupProperties {
task_types: Map::new(tasks.to_vec()),
}),
}
}
fn new_task_ids<const N: usize>() -> [u64; N] {
std::array::from_fn(|_| SnowflakeIdGenerator::global_id().unwrap())
}
/// Still due and never run: present, and pending.
async fn is_pending(server: &Server, id: u64) -> bool {
matches!(
server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
.map(|task| task.status().clone()),
Some(TaskStatus::Pending(_))
)
}
async fn wait_until_run(server: &Server, ids: &[u64], within: Duration) {
let started = Instant::now();
loop {
let mut left = 0;
for id in ids {
if is_pending(server, *id).await {
left += 1;
}
}
if left == 0 {
return;
}
assert!(
started.elapsed() < within,
"{left} task(s) still pending after {:?}",
started.elapsed()
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
+4 -13
View File
@@ -86,19 +86,10 @@ async fn antispam() {
.registry_create_object(SpamSettings { .registry_create_object(SpamSettings {
score_spam: Float::new(5.0), score_spam: Float::new(5.0),
// inbuxa: the rules carry the scores the expectations are written // inbuxa: the rules carry the scores the expectations are written
// against, so they're pinned (spam-filter v3.0.2, beside the test // against. Unset, the server uses the rules bundled with it
// cases) rather than read from a developer's own checkout, which // (resources/spam-filter/), the path production takes;
// left every score at zero. SPAM_RULES_URL still overrides. // SPAM_RULES_URL tests another set.
spam_filter_rules_url: std::env::var("SPAM_RULES_URL") spam_filter_rules_url: std::env::var("SPAM_RULES_URL").ok(),
.unwrap_or_else(|_| {
concat!(
"file://",
env!("CARGO_MANIFEST_DIR"),
"/resources/smtp/antispam/spam-filter-rules.json.gz"
)
.to_string()
})
.into(),
..Default::default() ..Default::default()
}) })
.await; .await;
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -75,7 +77,7 @@ async fn milter_session() {
else_: "true".into(), else_: "true".into(),
..Default::default() ..Default::default()
}, },
hostname: "127.0.0.1".into(), hostname: "localhost".into(), // inbuxa: resolved when the session connects
port: 9332, port: 9332,
use_tls: false, use_tls: false,
stages: Map::new(vec![MtaStage::Data]), stages: Map::new(vec![MtaStage::Data]),
+40 -17
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::utils::server::TestServer; use crate::utils::server::TestServer;
@@ -40,15 +42,23 @@ pub mod vrfy;
const EVENT_TIMEOUT: Duration = Duration::from_secs(5); const EVENT_TIMEOUT: Duration = Duration::from_secs(5);
impl TestServer { impl TestServer {
// inbuxa: registry writes reload the settings, and each reload sends the
// queue a ReloadSettings; read_event, try_read_event and assert_no_events
// pass over those (expect_reload_settings still waits for one)
pub async fn read_event(&mut self) -> QueueEvent { pub async fn read_event(&mut self) -> QueueEvent {
if let Some(event) = self.queue_events.pop_front() { while let Some(event) = self.queue_events.pop_front() {
return event; if !event.is_reload_settings() {
return event;
}
} }
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { loop {
Ok(Some(event)) => event, match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
Ok(None) => panic!("Channel closed."), Ok(Some(event)) if event.is_reload_settings() => (),
Err(_) => panic!("No queue event received."), Ok(Some(event)) => return event,
Ok(None) => panic!("Channel closed."),
Err(_) => panic!("No queue event received."),
}
} }
} }
@@ -78,26 +88,39 @@ impl TestServer {
} }
pub async fn try_read_event(&mut self) -> Option<QueueEvent> { pub async fn try_read_event(&mut self) -> Option<QueueEvent> {
if let Some(event) = self.queue_events.pop_front() { while let Some(event) = self.queue_events.pop_front() {
return Some(event); if !event.is_reload_settings() {
return Some(event);
}
} }
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { loop {
Ok(Some(event)) => Some(event), match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
Ok(None) => panic!("Channel closed."), Ok(Some(event)) if event.is_reload_settings() => (),
Err(_) => None, Ok(Some(event)) => return Some(event),
Ok(None) => panic!("Channel closed."),
Err(_) => return None,
}
} }
} }
pub fn assert_no_events(&mut self) { pub fn assert_no_events(&mut self) {
if let Some(event) = self.queue_events.pop_front() { if let Some(event) = self
.queue_events
.iter()
.find(|event| !event.is_reload_settings())
{
panic!("Expected empty queue but got {event:?}"); panic!("Expected empty queue but got {event:?}");
} }
self.queue_events.clear();
match self.queue_rx.try_recv() { loop {
Err(TryRecvError::Empty) => (), match self.queue_rx.try_recv() {
Ok(event) => panic!("Expected empty queue but got {event:?}"), Ok(event) if event.is_reload_settings() => (),
Err(err) => panic!("Queue error: {err:?}"), Err(TryRecvError::Empty) => break,
Ok(event) => panic!("Expected empty queue but got {event:?}"),
Err(err) => panic!("Queue error: {err:?}"),
}
} }
} }
+3
View File
@@ -2,9 +2,12 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
pub mod analyze; pub mod analyze;
pub mod dmarc; pub mod dmarc;
pub mod reschedule; // inbuxa: report reschedules and unreadable queue rows
pub mod scheduler; pub mod scheduler;
pub mod tls; pub mod tls;
+370
View File
@@ -0,0 +1,370 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Rescheduling an internal DMARC or TLS report over JMAP moves its task: the
//! task runs at the new time, x:Task/get shows the new due, and tasks due
//! after it still run. A task queue row whose type can't be read is logged
//! and repaired rather than stopping every task due after it, including the
//! rows an earlier reschedule wrote with the report's object type.
use crate::utils::server::{TestServer, TestServerBuilder};
use common::{
Server,
config::smtp::report::AggregateFrequency,
ipc::{DmarcEvent, PolicyType, TlsEvent},
};
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
mta_sts::TlsRpt,
report::{ActionDisposition, DmarcResult, Record},
};
use registry::{
schema::{
enums::{TaskStoreMaintenanceType, TaskType},
prelude::{ObjectType, Property},
structs::{
DmarcInternalReport, DmarcReportSettings, Expression, Task, TaskStatus,
TaskStoreMaintenance, TlsInternalReport, TlsReportSettings,
},
},
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime},
};
use serde_json::json;
use smtp::reporting::{index::InternalReportIndex, send::MtaReportSend};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use store::{
SerializeInfallible, ValueKey,
write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, now},
};
use types::id::Id;
use utils::snowflake::SnowflakeIdGenerator;
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn report_reschedule() {
let mut test = TestServerBuilder::new("smtp_report_reschedule")
.await
.with_http_listener(19057)
.await
.capture_queue()
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(TlsReportSettings {
max_report_size: Expression {
else_: "1024".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(DmarcReportSettings {
aggregate_max_report_size: Expression {
else_: "1024".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
let admin = test.account("admin");
// A daily DMARC and TLS report, due a day from now
schedule_dmarc(&test, "foobar.org").await;
schedule_tls(&test, "foobar.org").await;
let dmarc_id = wait_for_report::<DmarcInternalReport>(&test, "foobar.org").await;
let tls_id = wait_for_report::<TlsInternalReport>(&test, "foobar.org").await;
// Reschedule both to a few seconds from now, with a task due after them
let at = now() + 3;
let later = marker_task(&test.server, at + 3).await;
for (object, id, task_type) in [
(
ObjectType::DmarcInternalReport,
dmarc_id,
TaskType::DmarcReport,
),
(ObjectType::TlsInternalReport, tls_id, TaskType::TlsReport),
] {
admin
.registry_update_object(
object,
id,
json!({
Property::DeliverAt: UTCDateTime::from_timestamp(at as i64),
}),
)
.await;
// x:Task/get shows the new due, and the queue row carries the task's
// type. Upstream wrote the report's object type there and left the
// task at its old due
let task = admin.registry_get::<Task>(id).await;
assert_eq!(task.object_type(), task_type);
assert_eq!(
task.due_timestamp(),
at,
"{object:?} task due not moved: {task:?}"
);
assert_eq!(
queue_row(&test.server, id.id(), at).await,
Some(task_type.to_id().serialize()),
"{object:?} queue row"
);
}
// Both reports go out at the new time, and the later task still runs
wait_until_run(&test.server, &[dmarc_id.id(), tls_id.id(), later]).await;
assert!(now() >= at, "the reports went out before their new time");
assert!(
admin
.registry_get_all::<DmarcInternalReport>()
.await
.is_empty()
);
assert!(
admin
.registry_get_all::<TlsInternalReport>()
.await
.is_empty()
);
// Rows an earlier reschedule may have left in a store: one with the
// report's object type and the task left at its old due, and one that
// is unreadable and has no task behind it. Neither may hold back a task
// due after them.
schedule_dmarc(&test, "foobar.net").await;
let dmarc_id = wait_for_report::<DmarcInternalReport>(&test, "foobar.net").await;
let at = now() + 2;
let old_due = old_style_reschedule(&test.server, dmarc_id.id(), at).await;
let orphan = SnowflakeIdGenerator::global_id().unwrap();
let mut batch = BatchBuilder::new();
batch.set(
ValueClass::TaskQueue(TaskQueueClass::Due {
id: orphan,
due: at,
}),
vec![0xff, 0xff],
);
test.server.store().write(batch.build_all()).await.unwrap();
let later = marker_task(&test.server, at + 2).await;
wait_until_run(&test.server, &[dmarc_id.id(), later]).await;
assert!(
admin
.registry_get_all::<DmarcInternalReport>()
.await
.is_empty()
);
assert_eq!(queue_row(&test.server, orphan, at).await, None);
assert_eq!(queue_row(&test.server, dmarc_id.id(), at).await, None);
assert_eq!(queue_row(&test.server, dmarc_id.id(), old_due).await, None);
// x:Task/query by type skips an unreadable row rather than failing
let mut batch = BatchBuilder::new();
let due = now() + 3600;
batch.set(
ValueClass::TaskQueue(TaskQueueClass::Due { id: orphan, due }),
vec![0xff, 0xff],
);
test.server.store().write(batch.build_all()).await.unwrap();
admin
.registry_query_ids(
ObjectType::Task,
vec![(Property::Type, TaskType::DmarcReport.as_str())],
Vec::<&str>::new(),
)
.await;
let mut batch = BatchBuilder::new();
batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: orphan,
due,
}));
test.server.store().write(batch.build_all()).await.unwrap();
if test.is_reset() {
test.temp_dir.delete();
}
}
async fn schedule_dmarc(test: &TestServer, domain: &str) {
test.server
.schedule_report(DmarcEvent {
domain: domain.to_string(),
report_record: Record::new()
.with_source_ip("192.168.1.2".parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_envelope_from("[email protected]")
.with_envelope_to("[email protected]")
.with_header_from("[email protected]"),
dmarc_record: Arc::new(
Dmarc::parse(format!("v=DMARC1; p=reject; rua=mailto:reports@{domain}").as_bytes())
.unwrap(),
),
interval: AggregateFrequency::Daily,
span_id: 0,
})
.await;
}
async fn schedule_tls(test: &TestServer, domain: &str) {
test.server
.schedule_report(TlsEvent {
domain: domain.to_string(),
policy: PolicyType::None,
failure: None,
tls_record: Arc::new(
TlsRpt::parse(format!("v=TLSRPTv1;rua=mailto:reports@{domain}").as_bytes())
.unwrap(),
),
interval: AggregateFrequency::Daily,
span_id: 0,
})
.await;
}
trait ReportDomain: ObjectImpl {
fn report_domain(&self) -> &str;
}
impl ReportDomain for DmarcInternalReport {
fn report_domain(&self) -> &str {
&self.domain
}
}
impl ReportDomain for TlsInternalReport {
fn report_domain(&self) -> &str {
&self.domain
}
}
async fn wait_for_report<T: ReportDomain>(test: &TestServer, domain: &str) -> Id {
let admin = test.account("admin");
for _ in 0..100 {
if let Some((id, _)) = admin
.registry_get_all::<T>()
.await
.into_iter()
.find(|(_, report)| report.report_domain() == domain)
{
return id;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("No {} for {domain}", T::OBJECT.as_str());
}
/// A task that succeeds when it runs, due at `due`.
async fn marker_task(server: &Server, due: u64) -> u64 {
let id = SnowflakeIdGenerator::global_id().unwrap();
let mut batch = BatchBuilder::new();
batch.schedule_task_with_id(
id,
Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: TaskStoreMaintenanceType::RemoveLockDav,
shard_index: Some(0),
status: TaskStatus::at(due as i64),
}),
);
server.store().write(batch.build_all()).await.unwrap();
server.notify_task_queue();
id
}
/// What the reschedule before this fix wrote: the report's object type in
/// the new queue row, and the task row left at its old due. Returns that
/// old due.
async fn old_style_reschedule(server: &Server, item_id: u64, at: u64) -> u64 {
let object_id = ObjectType::DmarcInternalReport.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
let mut report = server
.store()
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone()))
.await
.unwrap()
.unwrap();
let old_due = report.deliver_at().timestamp() as u64;
report.set_deliver_at(UTCDateTime::from_timestamp(at as i64));
let mut batch = BatchBuilder::new();
batch
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id,
due: old_due,
}))
.set(
ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id,
due: at,
}),
object_id.serialize(),
)
.set(key, report.to_pickled_vec());
server.store().write(batch.build_all()).await.unwrap();
server.notify_task_queue();
old_due
}
struct RawValue(Vec<u8>);
impl store::Deserialize for RawValue {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(RawValue(bytes.to_vec()))
}
}
async fn queue_row(server: &Server, id: u64, due: u64) -> Option<Vec<u8>> {
server
.store()
.get_value::<RawValue>(ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
id,
due,
})))
.await
.unwrap()
.map(|raw| raw.0)
}
async fn task_exists(server: &Server, id: u64) -> bool {
server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
.is_some()
}
async fn wait_until_run(server: &Server, ids: &[u64]) {
let started = Instant::now();
loop {
let mut pending = Vec::new();
for id in ids {
if task_exists(server, *id).await {
pending.push(*id);
}
}
if pending.is_empty() {
return;
}
if started.elapsed() > Duration::from_secs(30) {
panic!("tasks {pending:?} never ran");
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
+253 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::utils::{ use crate::utils::{
@@ -9,14 +11,22 @@ use crate::utils::{
server::TestServer, server::TestServer,
temp_dir::TempDir, temp_dir::TempDir,
}; };
use ::registry::schema::enums::CompressionAlgo; use ::registry::schema::{
enums::{CompressionAlgo, TaskStoreMaintenanceType},
prelude::ObjectType,
structs::Task,
};
use ahash::AHashSet; use ahash::AHashSet;
use common::{DATABASE_SCHEMA_VERSION, manager::backup::BackupParams}; use common::{
DATABASE_SCHEMA_VERSION,
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, backup::BackupParams},
};
use store::{ use store::{
rand, rand,
write::{ write::{
AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent, AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent,
RegistryClass, ValueClass, key::KeySerializer, RegistryClass, TaskQueueClass, ValueClass,
key::{DeserializeBigEndian, KeySerializer},
}, },
*, *,
}; };
@@ -167,6 +177,50 @@ pub async fn test(test: &TestServer) {
} }
db.write(batch.build_all()).await.unwrap(); db.write(batch.build_all()).await.unwrap();
// inbuxa: registry objects kept outside the registry subspace (archived
// items for undelete, spam training samples, directory entries) and the
// fork's own subspace. Exports used to leave the first two behind.
println!("Creating archived items, spam samples and fork data...");
let mut batch = BatchBuilder::new();
for item_id in [1u64, 2, 3] {
for object in [
ObjectType::ArchivedItem,
ObjectType::SpamTrainingSample,
ObjectType::Account,
] {
batch.set(
ValueClass::Registry(RegistryClass::Item {
object_id: object as u16,
item_id,
}),
random_bytes(item_id as usize * 64),
);
}
batch.set(
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: [b'U', b'x']
.into_iter()
.chain(item_id.to_be_bytes())
.collect(),
}),
random_bytes(32),
);
}
db.write(batch.build_all()).await.unwrap();
// inbuxa: the trained spam classifier lives in blobs with fixed names
let mut named_blobs = Vec::new();
for key in [SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY] {
let data = random_bytes(4096);
test.server
.blob_store()
.put_blob(key, &data, CompressionAlgo::Lz4)
.await
.unwrap();
named_blobs.push((key, data));
}
// Create directory data // Create directory data
println!("Creating directory data..."); println!("Creating directory data...");
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
@@ -185,6 +239,17 @@ pub async fn test(test: &TestServer) {
println!("Calculating store hash..."); println!("Calculating store hash...");
let snapshot = Snapshot::new(&db).await; let snapshot = Snapshot::new(&db).await;
assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",); assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",);
for subspace in [
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
SUBSPACE_INBUXA,
] {
assert!(
snapshot.keys.iter().any(|k| k.subspace == subspace),
"No test data in subspace {}",
char::from(subspace)
);
}
// Export store // Export store
println!("Exporting store..."); println!("Exporting store...");
@@ -210,22 +275,188 @@ pub async fn test(test: &TestServer) {
.finalize(), .finalize(),
); );
db.write(batch.build_all()).await.unwrap(); db.write(batch.build_all()).await.unwrap();
test.server.core.restore(temp_dir.path.clone()).await; for (key, _) in &named_blobs {
test.server.blob_store().delete_blob(key).await.unwrap();
}
let imported = test.server.core.restore(temp_dir.path.clone()).await;
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.clear(ValueClass::NodeId(0)); batch.clear(ValueClass::NodeId(0));
db.write(batch.build_all()).await.unwrap(); db.write(batch.build_all()).await.unwrap();
for subspace in [
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
SUBSPACE_INBUXA,
] {
assert!(
imported.contains(&subspace),
"Subspace {} was not exported",
char::from(subspace)
);
}
// Verify hash // Verify hash
print!("Verifying store hash..."); print!("Verifying store hash...");
snapshot.assert_is_eq(&Snapshot::new(&db).await); snapshot.assert_is_eq(&Snapshot::new(&db).await);
assert_named_blobs(test.server.blob_store(), &named_blobs).await;
println!(" GREAT SUCCESS!"); println!(" GREAT SUCCESS!");
// inbuxa: import the same export into a fresh store of another backend,
// the way a move from one database to another does it
#[cfg(all(feature = "rocks", feature = "sqlite"))]
cross_backend(test, &db, &temp_dir, &named_blobs).await;
// Destroy store // Destroy store
for (key, _) in &named_blobs {
test.server.blob_store().delete_blob(key).await.unwrap();
}
store_destroy(&db).await; store_destroy(&db).await;
store_assert_is_empty(&db, db.clone().into(), true).await; store_assert_is_empty(&db, db.clone().into(), true).await;
temp_dir.delete(); temp_dir.delete();
} }
#[cfg(all(feature = "rocks", feature = "sqlite"))]
async fn cross_backend(
test: &TestServer,
source: &Store,
export: &TempDir,
named_blobs: &[(&[u8], Vec<u8>)],
) {
let source_type = std::env::var("STORE").unwrap();
let target_type = if source_type.eq_ignore_ascii_case("sqlite") {
"RocksDb"
} else {
"Sqlite"
};
println!("Importing the export into a fresh {target_type} store...");
let target_dir = TempDir::new("art_vandelay_cross_backend", true);
let target = Store::build(
crate::utils::storage::build_data_store(target_type, &target_dir.path.to_string_lossy())
.await,
)
.await
.unwrap();
target.create_tables().await.unwrap();
store_destroy(&target).await;
let mut core = test.server.core.as_ref().clone();
core.storage.data = target.clone();
core.storage.blob = target.clone().into();
let imported = core.restore(export.path.clone()).await;
// Counters are stored differently by the SQL and key-value backends, so
// compare their keys here and their values through the counter API.
print!("Verifying {target_type} store hash...");
Snapshot::new_portable(source)
.await
.assert_is_eq(&Snapshot::new_portable(&target).await);
for subspace in [SUBSPACE_COUNTER, SUBSPACE_QUOTA] {
let mut keys = Vec::new();
source
.iterate(
IterateParams::new(
AnyKey {
subspace,
key: vec![0u8],
},
AnyKey {
subspace,
key: vec![u8::MAX; 10],
},
)
.no_values(),
|key, _| {
keys.push(key.to_vec());
Ok(true)
},
)
.await
.unwrap();
for key in keys {
let class = || {
ValueClass::Any(AnyClass {
subspace,
key: key.clone(),
})
};
assert_eq!(
source.get_counter(class()).await.unwrap(),
target.get_counter(class()).await.unwrap(),
"Counter mismatch in {} for {key:?}",
char::from(subspace)
);
}
}
assert_named_blobs(&core.storage.blob, named_blobs).await;
println!(" GREAT SUCCESS!");
// The search index isn't exported; the import queues its rebuild
let queued = core.queue_reindex(&imported).await;
let expected = [
TaskStoreMaintenanceType::ReindexAccounts,
TaskStoreMaintenanceType::ReindexTelemetry,
];
assert_eq!(queued, expected);
let mut task_ids = Vec::new();
target
.iterate(
IterateParams::new(
AnyKey {
subspace: SUBSPACE_TASK_QUEUE,
key: vec![0u8],
},
AnyKey {
subspace: SUBSPACE_TASK_QUEUE,
key: vec![u8::MAX; 20],
},
)
.no_values(),
|key, _| {
if key.deserialize_be_u64(0)? == 0 {
task_ids.push(key.deserialize_be_u64(U64_LEN)?);
}
Ok(true)
},
)
.await
.unwrap();
let mut found = Vec::new();
for id in task_ids {
match target
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id },
)))
.await
.unwrap()
{
Some(Task::StoreMaintenance(task)) => found.push(task.maintenance_type),
other => panic!("Unexpected task {other:?}"),
}
}
found.sort_by_key(|t| *t as u16);
assert_eq!(found, expected, "Queued tasks don't match");
store_destroy(&target).await;
drop(core);
drop(target);
target_dir.delete();
}
async fn assert_named_blobs(blob_store: &BlobStore, named_blobs: &[(&[u8], Vec<u8>)]) {
for (key, data) in named_blobs {
assert_eq!(
blob_store
.get_blob(key, 0..usize::MAX)
.await
.unwrap()
.as_ref(),
Some(data),
"Blob {} was not restored",
String::from_utf8_lossy(key)
);
}
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
struct Snapshot { struct Snapshot {
keys: AHashSet<KeyValue>, keys: AHashSet<KeyValue>,
@@ -240,7 +471,19 @@ struct KeyValue {
impl Snapshot { impl Snapshot {
async fn new(db: &Store) -> Self { async fn new(db: &Store) -> Self {
let is_sql = db.is_sql(); Self::build(db, !db.is_sql(), true).await
}
/// Comparable across backends: no counter values, which the SQL and
/// key-value stores encode differently, and no blobs, which only live in
/// the data store when it doubles as the blob store.
#[cfg(all(feature = "rocks", feature = "sqlite"))]
async fn new_portable(db: &Store) -> Self {
Self::build(db, false, false).await
}
async fn build(db: &Store, counter_values: bool, with_blobs: bool) -> Self {
let is_sql = !counter_values;
let mut keys = AHashSet::new(); let mut keys = AHashSet::new();
@@ -265,7 +508,12 @@ impl Snapshot {
(SUBSPACE_QUOTA, !is_sql), (SUBSPACE_QUOTA, !is_sql),
(SUBSPACE_REPORT_OUT, true), (SUBSPACE_REPORT_OUT, true),
(SUBSPACE_REPORT_IN, true), (SUBSPACE_REPORT_IN, true),
(SUBSPACE_DIRECTORY, true),
(SUBSPACE_INBUXA, true),
] { ] {
if subspace == SUBSPACE_BLOBS && !with_blobs {
continue;
}
let from_key = AnyKey { let from_key = AnyKey {
subspace, subspace,
key: vec![0u8], key: vec![0u8],
+5
View File
@@ -10,6 +10,8 @@ pub mod blob;
pub mod import_export; pub mod import_export;
pub mod lookup; pub mod lookup;
pub mod ops; pub mod ops;
#[cfg(any(feature = "postgres", feature = "mysql"))]
pub mod pool_timeout; // inbuxa: SQL pools give up instead of hanging
pub mod query; pub mod query;
pub mod registry; pub mod registry;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
@@ -19,8 +21,11 @@ pub mod replica_mysql; // inbuxa: read replicas on MySQL
#[cfg(all(feature = "postgres", feature = "redis"))] #[cfg(all(feature = "postgres", feature = "redis"))]
pub mod replica_cluster; // inbuxa: read replicas across nodes pub mod replica_cluster; // inbuxa: read replicas across nodes
pub mod scaleout; // inbuxa: scale-out storage pub mod scaleout; // inbuxa: scale-out storage
#[cfg(feature = "postgres")]
pub mod search_gin; // inbuxa: GIN indexes without a pending list
#[cfg(any(feature = "postgres", feature = "mysql"))] #[cfg(any(feature = "postgres", feature = "mysql"))]
pub mod sql_timeout; pub mod sql_timeout;
pub mod task_locks; // inbuxa: task locks across nodes
use crate::utils::server::TestServerBuilder; use crate::utils::server::TestServerBuilder;
use std::io::Read; use std::io::Read;
+376
View File
@@ -0,0 +1,376 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A database that accepts connections and then says nothing (a hung or
//! half-dead server, a black-holed failover) gives a worker an error within
//! the pool's timeouts. Upstream's pools had none, so the worker waited for
//! good. No database is needed: a local listener that never answers plays
//! the server.
//!
//! inbuxa: the same for a database that stops answering while connections
//! are already open (a paused container): a query on a checked-out
//! connection ends within the query limit, the store works again once the
//! database is back, and /healthz/ready says 503 in between while
//! /healthz/live stays 200. These need the local test databases; a proxy
//! that can stop forwarding plays the pause.
use registry::schema::structs::DataStore;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use store::{
IterateParams, Store, ValueKey,
backend::query_timeout::QueryTimeouts,
write::{BatchBuilder, ValueClass},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
/// Accepts connections on a local port and never sends a byte.
async fn silent_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((socket, _)) = listener.accept().await {
held.push(socket);
}
});
port
}
/// Builds the store and reads a key; both must end, with an error for the
/// read, well within `limit`.
async fn assert_times_out(data_store: DataStore, limit: Duration) {
let started = Instant::now();
let result = tokio::time::timeout(limit, async {
match Store::build(data_store).await {
Ok(store) => store
.get_value::<u64>(ValueKey::from(ValueClass::Property(0)))
.await
.map(|_| ())
.map_err(|err| err.to_string()),
Err(err) => Err(err.to_string()),
}
})
.await;
let elapsed = started.elapsed();
match result {
Ok(Err(err)) => println!("Got {err} after {elapsed:?}"),
Ok(Ok(())) => panic!("a silent server answered?"),
Err(_) => panic!("still waiting for a connection after {elapsed:?}"),
}
}
#[cfg(feature = "postgres")]
#[tokio::test(flavor = "multi_thread")]
pub async fn postgres_pool_timeout() {
use registry::schema::structs::PostgreSqlStore;
let port = silent_server().await;
println!("Running PostgreSQL pool timeout test...");
// The store's own timeout bounds opening a connection, handshake
// included (tokio-postgres's connect_timeout covers only the TCP connect)
assert_times_out(
DataStore::PostgreSql(PostgreSqlStore {
host: "127.0.0.1".into(),
port: port as u64,
database: "none".into(),
timeout: Some(Duration::from_secs(2).into()),
use_tls: false,
..Default::default()
}),
Duration::from_secs(20),
)
.await;
}
#[cfg(feature = "mysql")]
#[tokio::test(flavor = "multi_thread")]
pub async fn mysql_pool_timeout() {
use registry::schema::structs::MySqlStore;
let port = silent_server().await;
println!("Running MySQL pool timeout test...");
// mysql_async has no pool timeout; the store waits 30 s for a connection
assert_times_out(
DataStore::MySql(MySqlStore {
host: "127.0.0.1".into(),
port: port as u64,
database: "none".into(),
use_tls: false,
..Default::default()
}),
Duration::from_secs(60),
)
.await;
}
/// A TCP proxy to a local port that can stop forwarding, in both
/// directions, while keeping every connection open: a paused server whose
/// kernel still keeps the connections up.
struct PausableProxy {
port: u16,
paused: Arc<AtomicBool>,
}
impl PausableProxy {
async fn start(upstream: u16) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let paused = Arc::new(AtomicBool::new(false));
let paused_ = paused.clone();
tokio::spawn(async move {
while let Ok((client, _)) = listener.accept().await {
let Ok(server) = TcpStream::connect(("127.0.0.1", upstream)).await else {
continue;
};
let (client_rx, client_tx) = client.into_split();
let (server_rx, server_tx) = server.into_split();
tokio::spawn(forward(client_rx, server_tx, paused_.clone()));
tokio::spawn(forward(server_rx, client_tx, paused_.clone()));
}
});
PausableProxy { port, paused }
}
fn pause(&self, paused: bool) {
self.paused.store(paused, Ordering::SeqCst);
}
}
async fn forward(
mut from: tokio::net::tcp::OwnedReadHalf,
mut to: tokio::net::tcp::OwnedWriteHalf,
paused: Arc<AtomicBool>,
) {
let mut buf = vec![0u8; 16384];
loop {
while paused.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
let n = match from.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
// Hold what arrived while paused until the pause ends
while paused.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
if to.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
const TEST_LIMITS: QueryTimeouts = QueryTimeouts {
query: Duration::from_secs(2),
maintenance: Duration::from_secs(3),
};
/// Opens `connections` pooled connections at once, so the operations that
/// follow find one idle and check it out.
async fn warm(store: &Store, connections: usize) {
let reads = (0..connections).map(|_| async {
store
.get_value::<u64>(ValueKey::from(ValueClass::Property(0)))
.await
.unwrap();
});
futures::future::join_all(reads).await;
}
/// With the database paused, reads, scans and writes on connections the
/// pool already holds end in an error within the query limit; once it is
/// back, the store works again.
async fn assert_queries_time_out(store: Store, proxy: &PausableProxy) {
store.create_tables().await.unwrap();
warm(&store, 4).await;
// mysql_async resets a connection on its way back to the pool; let
// those finish, or the connections are stuck in the reset when the
// pause starts and the pool's own wait timeout answers instead
tokio::time::sleep(Duration::from_secs(1)).await;
proxy.pause(true);
let key = || ValueKey::from(ValueClass::Property(0));
let limit = TEST_LIMITS.query;
for (what, op) in [("read", 0), ("scan", 1), ("write", 2)] {
let started = Instant::now();
let result = tokio::time::timeout(Duration::from_secs(20), async {
match op {
0 => store.get_value::<u64>(key()).await.map(|_| ()),
1 => {
store
.iterate(
IterateParams::new(
ValueKey::from(ValueClass::Property(0)),
ValueKey::from(ValueClass::Property(u8::MAX)),
),
|_, _| Ok(true),
)
.await
}
_ => {
let mut batch = BatchBuilder::new();
batch
.with_account_id(u32::MAX - 7)
.with_collection(types::collection::Collection::Email)
.with_document(0)
.set(ValueClass::Property(0), 1u64.to_be_bytes().to_vec());
store.write(batch.build_all()).await.map(|_| ())
}
}
})
.await;
let elapsed = started.elapsed();
match result {
Ok(Err(err)) => {
let err = format!("{err:?}");
println!("Paused database, {what}: {err} after {elapsed:?}");
assert!(err.contains("Query timed out"), "{what}: {err}");
assert!(
elapsed >= limit && elapsed < limit * 3,
"{what} ended after {elapsed:?}"
);
}
Ok(Ok(())) => panic!("{what} succeeded against a paused database"),
Err(_) => panic!("{what} still waiting after {elapsed:?}"),
}
}
proxy.pause(false);
tokio::time::timeout(Duration::from_secs(20), store.get_value::<u64>(key()))
.await
.expect("still waiting after the database came back")
.expect("the store didn't recover");
}
#[cfg(feature = "postgres")]
#[tokio::test(flavor = "multi_thread")]
pub async fn postgres_query_timeout() {
println!("Running PostgreSQL query timeout test...");
let DataStore::PostgreSql(mut config) =
crate::utils::storage::build_data_store("PostgreSql", "").await
else {
unreachable!()
};
let proxy = PausableProxy::start(config.port as u16).await;
config.host = "127.0.0.1".into();
config.port = proxy.port as u64;
// New connections through the paused proxy give up as quickly
config.timeout = Some(TEST_LIMITS.query.into());
let store = Store::build(DataStore::PostgreSql(config))
.await
.unwrap()
.with_query_timeouts(TEST_LIMITS);
assert_queries_time_out(store, &proxy).await;
}
#[cfg(feature = "mysql")]
#[tokio::test(flavor = "multi_thread")]
pub async fn mysql_query_timeout() {
println!("Running MySQL query timeout test...");
let DataStore::MySql(mut config) = crate::utils::storage::build_data_store("MySql", "").await
else {
unreachable!()
};
let proxy = PausableProxy::start(config.port as u16).await;
config.host = "127.0.0.1".into();
config.port = proxy.port as u64;
let store = Store::build(DataStore::MySql(config))
.await
.unwrap()
.with_query_timeouts(TEST_LIMITS);
assert_queries_time_out(store, &proxy).await;
}
/// /healthz/ready follows the data store; /healthz/live doesn't.
#[cfg(feature = "postgres")]
#[tokio::test(flavor = "multi_thread")]
pub async fn postgres_readiness() {
use crate::utils::server::TestServerBuilder;
use registry::schema::enums::NetworkListenerProtocol;
const HTTP_PORT: u16 = 11_320;
if std::env::var("STORE").as_deref() != Ok("PostgreSql") {
println!("Skipping the readiness test: it runs with STORE=PostgreSql.");
return;
}
println!("Running readiness test...");
let test = TestServerBuilder::new("postgres_readiness")
.await
.with_listener(NetworkListenerProtocol::Http, "http", HTTP_PORT, true)
.await
.build()
.await;
// Point the running node's data store at the database through the proxy
let DataStore::PostgreSql(mut config) =
crate::utils::storage::build_data_store("PostgreSql", "").await
else {
unreachable!()
};
let proxy = PausableProxy::start(config.port as u16).await;
config.host = "127.0.0.1".into();
config.port = proxy.port as u64;
config.timeout = Some(TEST_LIMITS.query.into());
let store = Store::build(DataStore::PostgreSql(config))
.await
.unwrap()
.with_query_timeouts(TEST_LIMITS);
let inner = &test.server.inner;
let mut core = inner.shared_core.load_full().as_ref().clone();
core.storage.data = store;
inner.shared_core.store(Arc::new(core));
let health = |path: &'static str| async move {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.build()
.unwrap()
.get(format!("https://127.0.0.1:{HTTP_PORT}/healthz/{path}"))
.send()
.await
.unwrap()
.status()
.as_u16()
};
let wait_for = |path: &'static str, status: u16| async move {
let started = Instant::now();
loop {
let got = health(path).await;
if got == status {
println!("/healthz/{path}: {got} after {:?}", started.elapsed());
return;
}
assert!(
started.elapsed() < Duration::from_secs(20),
"/healthz/{path} still {got}, expected {status}"
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
};
wait_for("ready", 200).await;
proxy.pause(true);
wait_for("ready", 503).await;
assert_eq!(health("live").await, 200);
proxy.pause(false);
wait_for("ready", 200).await;
assert_eq!(health("live").await, 200);
if test.is_reset() {
test.temp_dir.delete();
}
}
+401
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{store::deflate_test_resource, utils::server::TestServer}; use crate::{store::deflate_test_resource, utils::server::TestServer};
@@ -122,6 +124,20 @@ pub async fn test(test: &TestServer) {
println!("Running global id filtering tests..."); println!("Running global id filtering tests...");
test_global(store.clone()).await; test_global(store.clone()).await;
// inbuxa: trace documents as the index task builds them
println!("Running trace document tests...");
test_trace_documents(store.clone()).await;
// inbuxa: address fields match by full address, local part, domain and
// display name on every backend
println!("Running address search tests...");
test_address_search(store.clone()).await;
// inbuxa: words inside URLs, host names and file names in body text
// are found on every backend
println!("Running URL word search tests...");
test_url_word_search(store.clone()).await;
// Large document insert test // Large document insert test
println!("Running large document insert tests..."); println!("Running large document insert tests...");
let mut large_text = String::with_capacity(20 * 1024 * 1024); let mut large_text = String::with_capacity(20 * 1024 * 1024);
@@ -809,3 +825,388 @@ async fn test_global(store: SearchStore) {
AHashSet::from_iter([3, 4, 5]) AHashSet::from_iter([3, 4, 5])
); );
} }
// inbuxa: MON-16: documents built by the index task from stored traces go
// into every search backend (the SQL backends type etyp and qid as BIGINT)
// and are found again by queue id and keyword.
async fn test_trace_documents(store: SearchStore) {
use registry::schema::{
enums::SearchTracingField,
structs::{
Trace, TraceEvent, TraceKeyValue, TraceValue, TraceValueString,
TraceValueUnsignedInt,
},
};
use services::task_manager::index::trace_search_document;
use trc::{DeliveryEvent, EventType, Key, SmtpEvent};
let kv_u = |key: Key, value: u64| TraceKeyValue {
key,
value: TraceValue::UnsignedInt(TraceValueUnsignedInt { value }),
};
let kv_s = |key: Key, value: &str| TraceKeyValue {
key,
value: TraceValue::String(TraceValueString {
value: value.to_string(),
}),
};
let event = |event: EventType, key_values: Vec<TraceKeyValue>| TraceEvent {
event,
key_values: key_values.into(),
..Default::default()
};
let fields = [
SearchTracingField::EventType,
SearchTracingField::QueueId,
SearchTracingField::Keywords,
];
// An SMTP session that queued two messages, and a delivery attempt
let session = Trace {
events: vec![
event(
EventType::Smtp(SmtpEvent::ConnectionStart),
vec![kv_s(Key::RemoteIp, "192.0.2.7")],
),
event(
EventType::Smtp(SmtpEvent::MailFrom),
vec![kv_s(Key::From, "[email protected]")],
),
event(
EventType::Smtp(SmtpEvent::RcptTo),
vec![kv_u(Key::QueueId, 9_000_000_001), kv_s(Key::To, "[email protected]")],
),
event(
EventType::Smtp(SmtpEvent::RcptTo),
vec![kv_u(Key::QueueId, 9_000_000_002)],
),
]
.into(),
};
let delivery = Trace {
events: vec![event(
EventType::Delivery(DeliveryEvent::AttemptStart),
vec![kv_u(Key::QueueId, 9_000_000_003), kv_s(Key::Hostname, "relay.example.net")],
)]
.into(),
};
let documents = vec![
trace_search_document(100, &session, &fields),
trace_search_document(101, &delivery, &fields),
];
assert!(
documents
.iter()
.all(|d| d.has_field(&SearchField::Tracing(TracingSearchField::QueueId))
&& d.has_field(&SearchField::Tracing(TracingSearchField::EventType))),
"trace documents carry a queue id and an event type"
);
store.index(documents).await.unwrap();
if let SearchStore::ElasticSearch(store) = &store {
store.refresh_index(SearchIndex::Tracing).await.unwrap();
}
let query = |filters: Vec<SearchFilter>| {
let store = store.clone();
async move {
store
.query_global(
SearchQuery::new(SearchIndex::Tracing)
.with_filter(SearchFilter::ge(SearchField::Id, 100u64))
.with_filters(filters),
)
.await
.unwrap()
.into_iter()
.collect::<AHashSet<_>>()
}
};
// By queue id, the way x:Trace/query asks: the queue id column, or any
// queue id in the keywords
let by_queue_id = |queue_id: u64| {
vec![
SearchFilter::Or,
SearchFilter::eq(TracingSearchField::QueueId, queue_id),
SearchFilter::has_text(
TracingSearchField::Keywords,
queue_id.to_string(),
Language::None,
),
SearchFilter::End,
]
};
assert_eq!(query(by_queue_id(9_000_000_001)).await, AHashSet::from_iter([100]));
assert_eq!(query(by_queue_id(9_000_000_002)).await, AHashSet::from_iter([100]));
assert_eq!(query(by_queue_id(9_000_000_003)).await, AHashSet::from_iter([101]));
assert_eq!(query(by_queue_id(9_000_000_004)).await, AHashSet::new());
assert_eq!(
query(vec![SearchFilter::eq(TracingSearchField::QueueId, 9_000_000_003u64)]).await,
AHashSet::from_iter([101])
);
// By opening event type
assert_eq!(
query(vec![SearchFilter::eq(
TracingSearchField::EventType,
EventType::Delivery(DeliveryEvent::AttemptStart).to_id() as u64,
)])
.await,
AHashSet::from_iter([101])
);
// By keyword: an address, lowercased, and its domain
assert_eq!(
query(vec![SearchFilter::has_text(
TracingSearchField::Keywords,
"example.org",
Language::None,
)])
.await,
AHashSet::from_iter([100])
);
assert_eq!(
query(vec![SearchFilter::has_text(
TracingSearchField::Keywords,
"relay.example.net",
Language::None,
)])
.await,
AHashSet::from_iter([101])
);
for id in [100u64, 101] {
store
.unindex(
SearchQuery::new(SearchIndex::Tracing)
.with_filter(SearchFilter::eq(SearchField::Id, id)),
)
.await
.unwrap();
}
}
// inbuxa: the message indexer passes each display name and each address of
// From/To/Cc/Bcc as keyword text (Language::None). The built-in index splits
// that text into words, so an address is found by its full form, its local
// part, its domain or a display-name word; PostgreSQL kept the whole address
// as one token and MySQL dropped stopwords such as "com" and words under three
// characters. The expected results below are the built-in (RocksDB/SQLite)
// results and must be the same on every backend.
async fn test_address_search(store: SearchStore) {
const ACCOUNT_ID: u32 = 7;
let messages: [[&[(&str, &str)]; 4]; 5] = [
// From, To, Cc, Bcc
[
&[("Amazon.com", "[email protected]")],
&[("Jane Doe", "[email protected]")],
&[],
&[],
],
[
&[("", "[email protected]")],
&[("", "[email protected]")],
&[("Jane Doe", "[email protected]")],
&[],
],
[
&[("GitHub", "[email protected]")],
&[("Jo Li", "[email protected]")],
&[],
&[("Audit", "[email protected]")],
],
[
&[("Jane Doe", "[email protected]")],
&[("Amazon Web Services", "[email protected]")],
&[("Bob", "[email protected]")],
&[("", "[email protected]")],
],
[
&[("Newsletter", "[email protected]")],
&[("", "[email protected]")],
&[],
&[],
],
];
let fields = [
EmailSearchField::From,
EmailSearchField::To,
EmailSearchField::Cc,
EmailSearchField::Bcc,
];
let mut documents = Vec::new();
let mut mask = RoaringBitmap::new();
for (document_id, message) in messages.iter().enumerate() {
let mut document = IndexDocument::new(SearchIndex::Email)
.with_account_id(ACCOUNT_ID)
.with_document_id(document_id as u32);
for (field, addresses) in fields.iter().zip(message.iter()) {
for (name, address) in addresses.iter() {
if !name.is_empty() {
document.index_text(field.clone(), name, Language::None);
}
document.index_text(field.clone(), address, Language::None);
}
}
document.index_unsigned(EmailSearchField::ReceivedAt, document_id as u64);
documents.push(document);
mask.insert(document_id as u32);
}
store.index(documents).await.unwrap();
if let SearchStore::ElasticSearch(store) = &store {
store.refresh_index(SearchIndex::Email).await.unwrap();
}
for (field, text, expected) in [
// full address
(EmailSearchField::From, "[email protected]", vec![0u32]),
(EmailSearchField::To, "[email protected]", vec![0]),
(EmailSearchField::Cc, "[email protected]", vec![1]),
(EmailSearchField::Bcc, "[email protected]", vec![3]),
(EmailSearchField::To, "[email protected]", vec![1, 2]),
// local part
(EmailSearchField::From, "noreply", vec![0, 2]),
(EmailSearchField::To, "jo", vec![1, 2]),
(EmailSearchField::Cc, "bob", vec![3]),
(EmailSearchField::Bcc, "audit", vec![2]),
// domain
(EmailSearchField::From, "amazon.com", vec![0, 1]),
(EmailSearchField::From, "amazon", vec![0, 1]),
(EmailSearchField::To, "example.org", vec![0, 4]),
(EmailSearchField::To, "io.de", vec![1, 2]),
(EmailSearchField::Cc, "example.net", vec![3]),
(EmailSearchField::Bcc, "example.org", vec![2]),
(EmailSearchField::From, "www.example.com", vec![4]),
(EmailSearchField::From, "com", vec![0, 1, 2, 4]),
// display name
(EmailSearchField::From, "Jane", vec![3]),
(EmailSearchField::From, "jane doe", vec![3]),
(EmailSearchField::To, "Web Services", vec![3]),
(EmailSearchField::To, "Li", vec![2]),
(EmailSearchField::Cc, "Doe", vec![1]),
(EmailSearchField::Bcc, "Audit", vec![2]),
// hyphenated local part
(EmailSearchField::From, "shipment-tracking", vec![1]),
(EmailSearchField::From, "tracking", vec![1]),
// no match
(EmailSearchField::From, "amazon.org", vec![]),
(EmailSearchField::To, "noreply", vec![]),
(EmailSearchField::Bcc, "jane", vec![]),
] {
let ids = store
.query_account(
SearchQuery::new(SearchIndex::Email)
.with_filters(vec![
SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID),
SearchFilter::has_keyword(field.clone(), text),
])
.with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt))
.with_mask(mask.clone()),
)
.await
.unwrap();
assert_eq!(ids, expected, "{field:?} {text:?}");
}
// TEXT-style search across all address fields
let ids = store
.query_account(
SearchQuery::new(SearchIndex::Email)
.with_filters(vec![
SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID),
SearchFilter::Or,
SearchFilter::has_keyword(EmailSearchField::From, "example.org"),
SearchFilter::has_keyword(EmailSearchField::To, "example.org"),
SearchFilter::has_keyword(EmailSearchField::Cc, "example.org"),
SearchFilter::has_keyword(EmailSearchField::Bcc, "example.org"),
SearchFilter::End,
])
.with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt))
.with_mask(mask.clone()),
)
.await
.unwrap();
assert_eq!(ids, vec![0, 1, 2, 3, 4]);
store
.unindex(
SearchQuery::new(SearchIndex::Email)
.with_filter(SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID)),
)
.await
.unwrap();
}
async fn test_url_word_search(store: SearchStore) {
const ACCOUNT_ID: u32 = 8;
let bodies = [
"Track your parcel here: https://x.example/shipping-support/ and reply.",
"Reset it at https://mail.example.com/login/?password=reset&user=jane now.",
"Attached is invoice-2024.pdf for your records.",
"Shipping was fast, thanks again.",
"Nothing to see at www.example.org/about-us, really.",
];
let mut documents = Vec::new();
let mut mask = RoaringBitmap::new();
for (document_id, body) in bodies.iter().enumerate() {
let mut document = IndexDocument::new(SearchIndex::Email)
.with_account_id(ACCOUNT_ID)
.with_document_id(document_id as u32);
document.index_text(EmailSearchField::Body, body, Language::English);
document.index_unsigned(EmailSearchField::ReceivedAt, document_id as u64);
documents.push(document);
mask.insert(document_id as u32);
}
store.index(documents).await.unwrap();
if let SearchStore::ElasticSearch(store) = &store {
store.refresh_index(SearchIndex::Email).await.unwrap();
}
for (text, expected) in [
// only inside a URL path, a query string or a file name
("shipping", vec![0u32, 3]),
("support", vec![0]),
("password", vec![1]),
("login", vec![1]),
("jane", vec![1]),
("invoice", vec![2]),
("pdf", vec![2]),
("2024", vec![2]),
// host names
("example", vec![0, 1, 4]),
("mail", vec![1]),
// written as they appear
("https://x.example/shipping-support/", vec![0]),
("shipping-support", vec![0]),
("invoice-2024.pdf", vec![2]),
("mail.example.com", vec![1]),
// plain words are unaffected
("parcel", vec![0]),
("records", vec![2]),
("thanks", vec![3]),
// no match
("billing", vec![]),
("example.net", vec![]),
] {
let ids = store
.query_account(
SearchQuery::new(SearchIndex::Email)
.with_filters(vec![
SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID),
SearchFilter::has_english_text(EmailSearchField::Body, text),
])
.with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt))
.with_mask(mask.clone()),
)
.await
.unwrap();
assert_eq!(ids, expected, "Body {text:?}");
}
store
.unindex(
SearchQuery::new(SearchIndex::Email)
.with_filter(SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID)),
)
.await
.unwrap();
}
+159
View File
@@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! PostgreSQL full-text GIN indexes are built with fastupdate off, and an
//! index made earlier with the default is switched over at startup. With
//! fastupdate on, new entries wait in a pending list that every search scans
//! in full until VACUUM merges it.
use crate::utils::storage::build_data_store;
use registry::schema::structs::DataStore;
use store::{Rows, SearchStore, Store};
const SCHEMA: &str = "gin_fastupdate_test";
#[tokio::test(flavor = "multi_thread")]
pub async fn postgres_gin_fastupdate() {
println!("Running PostgreSQL GIN fastupdate test...");
// Work in a schema of our own so the shared search tables are untouched
let admin = Store::build(build_data_store("PostgreSql", "").await)
.await
.expect("Failed to connect to PostgreSQL");
for query in [
format!("DROP SCHEMA IF EXISTS {SCHEMA} CASCADE"),
format!("CREATE SCHEMA {SCHEMA}"),
] {
admin.sql_query::<usize>(&query, vec![]).await.unwrap();
}
let DataStore::PostgreSql(mut config) = build_data_store("PostgreSql", "").await else {
unreachable!()
};
config.options = Some(format!("-c search_path={SCHEMA}"));
let store = Store::build(DataStore::PostgreSql(config))
.await
.expect("Failed to connect to PostgreSQL");
let search = SearchStore::Store(store.clone());
// A fresh schema
search.create_indexes().await.unwrap();
let indexes = gin_indexes(&admin).await;
assert!(
indexes.len() >= 4,
"expected the search GIN indexes, found {indexes:?}"
);
for (name, options) in &indexes {
assert!(
options.contains("fastupdate=off"),
"fresh index {name} has options {options:?}"
);
}
// A schema from before the change: the same indexes, made with the
// default fastupdate=on, and a pending list with something in it
for (name, _) in &indexes {
admin
.sql_query::<usize>(
&format!("ALTER INDEX {SCHEMA}.{name} RESET (fastupdate)"),
vec![],
)
.await
.unwrap();
}
for (name, options) in gin_indexes(&admin).await {
assert!(
!options.contains("fastupdate"),
"index {name} still has options {options:?}"
);
}
admin
.sql_query::<usize>(
&format!(
"INSERT INTO {SCHEMA}.s_email (accid, docid, subj, body) \
SELECT 1, n, to_tsvector('simple', 'pending subject ' || n), \
to_tsvector('simple', 'pending body text ' || n) \
FROM generate_series(1, 500) n"
),
vec![],
)
.await
.unwrap();
assert!(
pending_tuples(&admin, "gin_s_email_body").await > 0,
"no pending list to merge"
);
// Startup on the existing schema switches every index over and merges
// what was pending
search.create_indexes().await.unwrap();
for (name, options) in gin_indexes(&admin).await {
assert!(
options.contains("fastupdate=off"),
"existing index {name} has options {options:?} after startup"
);
}
assert_eq!(pending_tuples(&admin, "gin_s_email_body").await, 0);
// And a second startup changes nothing
search.create_indexes().await.unwrap();
for (name, options) in gin_indexes(&admin).await {
assert!(options.contains("fastupdate=off"), "{name}: {options:?}");
}
admin
.sql_query::<usize>(&format!("DROP SCHEMA {SCHEMA} CASCADE"), vec![])
.await
.unwrap();
}
/// The GIN indexes in the test schema with their reloptions.
async fn gin_indexes(admin: &Store) -> Vec<(String, String)> {
admin
.sql_query::<Rows>(
&format!(
"SELECT c.relname::text, COALESCE(array_to_string(c.reloptions, ','), '') \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_am a ON a.oid = c.relam \
WHERE n.nspname = '{SCHEMA}' AND c.relkind = 'i' AND a.amname = 'gin' \
ORDER BY 1"
),
vec![],
)
.await
.unwrap()
.rows
.into_iter()
.map(|row| {
let mut values = row.values.into_iter();
(
values.next().unwrap().to_str().into_owned(),
values.next().unwrap().to_str().into_owned(),
)
})
.collect()
}
/// Tuples waiting in a GIN index's pending list (pgstattuple is a contrib
/// extension the test database has).
async fn pending_tuples(admin: &Store, index: &str) -> i64 {
admin
.sql_query::<usize>("CREATE EXTENSION IF NOT EXISTS pgstattuple", vec![])
.await
.unwrap();
admin
.sql_query::<Rows>(
&format!("SELECT pending_tuples FROM pgstatginindex('{SCHEMA}.{index}'::regclass)"),
vec![],
)
.await
.unwrap()
.rows
.into_iter()
.next()
.and_then(|row| row.values.into_iter().next())
.map(|value| value.to_str().parse::<i64>().unwrap())
.unwrap()
}
+210
View File
@@ -0,0 +1,210 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Task locks across nodes: tasks claimed by a node that then disappears
//! run elsewhere once its locks expire, and a node that stops gracefully
//! hands its locks back at once. The other node is played by writing its
//! locks straight into the shared in-memory store, as a node that claimed
//! the tasks and died leaves them.
use crate::utils::server::TestServerBuilder;
use common::{KV_LOCK_TASK, Server};
use registry::schema::{
enums::IndexDocumentType,
structs::{Task, TaskIndexDocument, TaskStatus},
};
use services::task_manager::lock::{TaskLockManager, release_task_locks};
use std::time::{Duration, Instant};
use store::{
ValueKey,
write::{BatchBuilder, TaskQueueClass, ValueClass},
};
use utils::snowflake::SnowflakeIdGenerator;
// Short enough for a test, long enough that the recheck interval (a twelfth
// of it) is well below it
const LOCK_EXPIRY: u64 = 12;
#[tokio::test(flavor = "multi_thread")]
pub async fn task_lock_tests() {
let test = TestServerBuilder::new("task_lock_tests")
.await
.build()
.await;
let server = test.server.clone();
println!(
"Running task lock tests on {}...",
std::env::var("STORE").unwrap_or_default()
);
server.inner.ipc.task_locks.set_expiry(LOCK_EXPIRY);
// 1. Another node claimed the tasks and died. Its locks block them until
// they expire; then this node runs them, without waiting for anything
// else to wake it
let ids = new_task_ids(4);
for id in &ids {
assert!(foreign_lock(&server, *id, LOCK_EXPIRY).await);
}
schedule(&server, &ids).await;
let started = Instant::now();
server.notify_task_queue();
tokio::time::sleep(Duration::from_secs(3)).await;
assert_eq!(pending(&server, &ids).await, ids.len(), "held by the other node");
wait_until_done(&server, &ids, Duration::from_secs(LOCK_EXPIRY + 10)).await;
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_secs(LOCK_EXPIRY - 2),
"ran before the other node's locks expired: {elapsed:?}"
);
// 2. The other node's locks outlive what this node expects: claimed just
// after this node looked, or by a node whose clock runs ahead. This node
// keeps checking at the recheck interval, so the tasks run soon after
// those locks expire, not a whole lock lifetime later
let held_for = LOCK_EXPIRY + LOCK_EXPIRY / 2;
let ids = new_task_ids(4);
for id in &ids {
assert!(foreign_lock(&server, *id, held_for).await);
}
schedule(&server, &ids).await;
let started = Instant::now();
server.notify_task_queue();
wait_until_done(&server, &ids, Duration::from_secs(held_for + 8)).await;
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_secs(held_for - 2),
"ran before the other node's locks expired: {elapsed:?}"
);
// 3. A task that runs longer than a lock lifetime keeps its claim: the
// task manager renews the lease while this node holds it, and the claim
// ends when the task does. (Before, a lock simply lasted an hour.)
let [id] = new_task_ids(1)[..] else {
unreachable!()
};
assert!(server.try_lock_task(id).await, "claim {id}");
tokio::time::sleep(Duration::from_secs(LOCK_EXPIRY + LOCK_EXPIRY / 2)).await;
assert!(
!foreign_lock(&server, id, LOCK_EXPIRY).await,
"lease lapsed while the task ran"
);
server.remove_index_lock(id).await;
assert!(
foreign_lock(&server, id, LOCK_EXPIRY).await,
"released when the task ended"
);
let _ = server
.in_memory_store()
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
.await;
assert!(
common::ipc::TaskLocks::DEFAULT_EXPIRY <= 5 * 60,
"a dead node's tasks wait no more than a few minutes"
);
// 4. A graceful stop releases the locks this node holds: another node
// can claim those tasks at once, and this one claims nothing more
let ids = new_task_ids(3);
for id in &ids {
assert!(server.try_lock_task(*id).await, "claim {id}");
}
assert_eq!(server.inner.ipc.task_locks.held(), ids.len());
for id in &ids {
assert!(
!foreign_lock(&server, *id, LOCK_EXPIRY).await,
"held while this node runs"
);
}
assert_eq!(release_task_locks(&server).await, ids.len());
assert_eq!(server.inner.ipc.task_locks.held(), 0);
for id in &ids {
assert!(
foreign_lock(&server, *id, LOCK_EXPIRY).await,
"released on stop: {id}"
);
}
let [id] = new_task_ids(1)[..] else {
unreachable!()
};
assert!(!server.try_lock_task(id).await, "a stopping node claims nothing");
for id in ids {
let _ = server
.in_memory_store()
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
.await;
}
if test.is_reset() {
test.temp_dir.delete();
}
}
fn new_task_ids(count: usize) -> Vec<u64> {
(0..count)
.map(|_| SnowflakeIdGenerator::global_id().unwrap())
.collect()
}
/// The other node's claim on a task, as its task manager takes it.
async fn foreign_lock(server: &Server, id: u64, seconds: u64) -> bool {
server
.in_memory_store()
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), seconds)
.await
.unwrap()
}
/// Unindex tasks for files that don't exist: files aren't search-indexed and
/// there is no undelete note, so running one only drops it from the queue.
async fn schedule(server: &Server, ids: &[u64]) {
let mut batch = BatchBuilder::new();
for (n, id) in ids.iter().enumerate() {
batch.schedule_task_with_id(
*id,
Task::UnindexDocument(TaskIndexDocument {
account_id: 0u32.into(),
document_id: (u32::MAX - n as u32).into(),
document_type: IndexDocumentType::File,
status: TaskStatus::now(),
}),
);
}
server.store().write(batch.build_all()).await.unwrap();
}
async fn pending(server: &Server, ids: &[u64]) -> usize {
let mut count = 0;
for id in ids {
if server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: *id },
)))
.await
.unwrap()
.is_some()
{
count += 1;
}
}
count
}
async fn wait_until_done(server: &Server, ids: &[u64], within: Duration) {
let started = Instant::now();
loop {
let left = pending(server, ids).await;
if left == 0 {
return;
}
assert!(
started.elapsed() < within,
"{left} task(s) still pending after {:?}",
started.elapsed()
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
+220
View File
@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
// inbuxa: a registry write to an object the running settings are built from
// applies without an x:Action ReloadSettings, and the set response says so.
use crate::utils::{
jmap::JmapResponse,
server::{TestServer, TestServerBuilder},
};
use common::BuildServer;
use registry::{
schema::{
enums::TracingLevel,
prelude::ObjectType,
structs::{
AllowedIp, CertificateManagement, DkimManagement, DnsManagement, Domain, Expression,
MtaDeliverySchedule, MtaStageAuth, MtaVirtualQueue, Tracer, TracerStdout,
},
},
types::ipmask::IpAddrOrMask,
};
use serde_json::Value;
#[tokio::test(flavor = "multi_thread")]
pub async fn settings_reload_tests() {
let mut test = TestServerBuilder::new("settings_reload_tests")
.await
.with_default_listeners()
.await
.with_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await
.build()
.await;
let admin = test
.create_user_account(
"admin",
"[email protected]",
"these_pretzels_are_making_me_thirsty",
&[],
"Admin",
)
.await;
test.account("admin")
.assign_roles_to_account(admin.id(), &["user", "system"])
.await;
test.insert_account(admin);
test_write_applies(&test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
async fn test_write_applies(test: &TestServer) {
println!("Running settings reload after registry writes...");
let admin = test.account("[email protected]");
// A delivery schedule is in use as soon as it is saved
let response = admin
.registry_create([MtaVirtualQueue {
name: "autorld".into(),
threads_per_node: 2,
description: None,
}])
.await;
assert_applied(&response);
let queue_id = response.created_id(0);
assert!(!has_schedule(test, "autoreload-schedule"));
let response = admin
.registry_create([MtaDeliverySchedule {
name: "autoreload-schedule".into(),
queue_id,
..Default::default()
}])
.await;
assert_applied(&response);
assert!(has_schedule(test, "autoreload-schedule"));
// Destroyed, it's gone at once too
let schedule_id = response.created_id(0);
let response = admin
.registry_destroy(ObjectType::MtaDeliverySchedule, [schedule_id])
.await;
assert_applied(&response);
assert!(!has_schedule(test, "autoreload-schedule"));
// Concurrent writes all end up in the running settings
let names = (0..8)
.map(|i| format!("autoreload-{i}"))
.collect::<Vec<_>>();
let mut writes = Vec::new();
for name in &names {
writes.push(admin.registry_create([MtaDeliverySchedule {
name: name.clone(),
queue_id,
..Default::default()
}]));
}
let mut schedule_ids = Vec::new();
for response in futures::future::join_all(writes).await {
assert_applied(&response);
schedule_ids.push(response.created_id(0));
}
for name in &names {
assert!(has_schedule(test, name), "{name} missing");
}
// Several objects in one request: one reload
let response = admin
.registry_destroy(ObjectType::MtaDeliverySchedule, schedule_ids.iter())
.await;
assert_applied(&response);
for name in &names {
assert!(!has_schedule(test, name), "{name} still present");
}
// A write whose reload fails is stored, and the response says the
// settings weren't reloaded: only one console tracer is allowed.
let response = admin
.registry_create([
Tracer::Stdout(TracerStdout {
enable: true,
level: TracingLevel::Error,
..Default::default()
}),
Tracer::Stdout(TracerStdout {
enable: true,
level: TracingLevel::Error,
..Default::default()
}),
])
.await;
let reload = settings_reload(&response).expect("x:settingsReload missing");
assert_eq!(reload["applied"], Value::Bool(false), "{response:?}");
let description = reload["description"].as_str().unwrap_or_default();
assert!(
description.starts_with("Saved, but the running settings were not reloaded. ")
&& description.contains("Only one console tracer is allowed"),
"{description}"
);
let tracer_ids = [response.created_id(0), response.created_id(1)];
let response = admin
.registry_destroy(ObjectType::Tracer, tracer_ids.iter())
.await;
assert_applied(&response);
// An allowed IP is live as soon as it is saved, and gone once
// destroyed. It lives in the core's security settings, which the
// blocked-IP reload it used to get doesn't rebuild.
let ip: std::net::IpAddr = "198.51.100.7".parse().unwrap();
assert!(!is_allowed(test, ip));
let response = admin
.registry_create([AllowedIp {
address: IpAddrOrMask::from_ip(ip),
reason: Some("autoreload".into()),
..Default::default()
}])
.await;
assert_applied(&response);
assert!(
is_allowed(test, ip),
"allowed IP not in the running settings"
);
let allowed_id = response.created_id(0);
let response = admin
.registry_destroy(ObjectType::AllowedIp, [allowed_id])
.await;
assert_applied(&response);
assert!(!is_allowed(test, ip), "destroyed allowed IP still live");
// Data that isn't part of the running settings doesn't reload them
let response = admin
.registry_create([Domain {
name: "autoreload.example.org".into(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
}])
.await;
assert!(settings_reload(&response).is_none(), "{response:?}");
}
fn settings_reload(response: &JmapResponse) -> Option<&Value> {
response.pointer("/methodResponses/0/1/x:settingsReload")
}
fn assert_applied(response: &JmapResponse) {
assert_eq!(
settings_reload(response),
Some(&serde_json::json!({"applied": true})),
"{response:?}"
);
}
fn has_schedule(test: &TestServer, name: &str) -> bool {
test.server
.inner
.build_server()
.core
.smtp
.queue
.queue_strategy
.contains_key(name)
}
fn is_allowed(test: &TestServer, ip: std::net::IpAddr) -> bool {
test.server.inner.build_server().is_ip_allowed(ip)
}

Some files were not shown because too many files have changed in this diff Show More