Architecture · Developer guide
New here? Start with the Storage guide, which builds up the same model step by step with pictures; this document is the reference.
Physical model
The canonical account name selects a RADOS namespace inside shared pools. Namespaces organize data; they are not per-user Ceph credentials in their own right. Access control has to be correct earlier, in the protocol/auth/ACL path. The server's Ceph account can reach several user namespaces.
| Object/structure | Meaning | Primary source |
|---|---|---|
mailboxes |
Registry of names and stable mailbox IDs; quota, delivery intents and the intents of the scrub (bi:, bimp:, up:, sub:, ci:, dm:, ps:) |
user.go, intents.go |
account |
Account-wide change counter and change log (JMAP state); watched by JMAP push | account.go, watch.go |
ix/<space>/<nn> |
Front-end indexes derived from the rest (JMAP locator), 32 shards | index.go |
mbox/<id> |
Header, UID counter, MODSEQ, change/vanished log and the first UID shard | user.go |
mbox/<id>/<n> |
Further message entries | user.go |
msg/<guid>/<n> |
Immutable payload chunks; chunk 0 carries refs |
blob.go |
fts/<shard>, ftst/<shard>, ftsmap |
Posting lists, trigram dictionary, GUID/doc ID mapping and layout record (layout 2); ftsd/<shard> is the dictionary layout before 0.35.0 |
fts.go |
ftx/<gen>/p<i>, d<j>, h<k> |
Posting lists, dictionary and header texts of a layout 3 index generation (layout 3) | ftslayout.go |
| Script/metadata objects | Sieve texts, the active script, private/shared metadata | scripts.go, metadata.go |
scrub |
Candidates and repair state | scrub.go |
pubseen/<id> |
Private \Seen marks of the public folder <id> (registry key ps:<id>) |
acl.go |
~accounts / ix.uploads/… in the index or system pool |
Due queue of the JMAP uploads, by expiry | uploads.go |
~accounts / ix.purged/… in the index or system pool |
Accounts a purge deleted, for the sweep to clean after (removed by a repairing sweep) | purged.go |
~accounts / sweep/state, sweep/lock, sweep/<run>/<pool>/<nnnn> in the index pool |
Checkpoint, lock and spill objects of a sweep in progress (shardpost scrub --sweep); always in the index pool, also with a system pool |
sweep.go |
~learning / learning/<job-id> in the data pool |
The independent message plus the job xattr | learning.go |
~learning / ix.learning/…, ix.learning/owner/… in the index or system pool |
Due queue and owner index of the learning queue | learnindex |
~accounts / ix.accounts/head, ix.accounts/<nn> in the index or system pool |
The account directory: one entry per account with a registry, 256 shards | directory.go, sortedindex |
~accounts / ix.fts_layout/…, ix.fts_due/… in the index or system pool |
Presence index of the accounts with a layout 3 full-text index; reshard queue of the fts_due worker |
ftslayout.go, ftsdue.go |
~admin / kv.<key>, ix.…, auth-flush in the index or system pool |
The admin store (jobs, plans, push subscriptions, registrations, …) with its hint indexes and due queues, the auth flush log | adminkv.go |
~server / metadata in the index or system pool |
The shared server METADATA entries | metadata.go |
~admin / system.moved in the index pool, system.pool in the system pool |
The placement fence and mark | placement.go |
The index pool is replicated because its omap structures require it.
The data pool is EC 2+1 in the existing manifest. That is one concrete
operational configuration, not a general recommendation for every cluster size.
By default, messages consist of 4 MiB chunks; the count is part of
the metadata. readBlob uses the configured chunk size to
compute the offsets. It must not be changed casually while data exists.
The FTS shard count likewise affects the object mapping of layout 2;
a layout 3 index records its own counts.
The mailbox shard size, by contrast, is stored in the mailbox header and is
fixed at creation time. For a UID, shard = (uid - 1) / shardSize.
Stable mailbox IDs decouple RENAME from the payload objects; names are
registry keys, not reliable physical object paths.
System pool and placement
The store's own namespaces hold the state of the whole cluster: the
admin store (~admin: jobs, plans, push subscriptions, server
registrations, the auth flush log, the queues of the job system), the
account directory with its indexes and queues (~accounts), the indexes
of the learning queue (~learning) and the server METADATA (~server).
Their index part is small (well under 5 GB at a million accounts) and
hot: a few hundred objects that every server polls, watches or locks.
With ceph.system_pool it lives in a replicated pool of its own, on
fast OSDs, apart from the index pool of the accounts
(placement.go):
| Namespace | Index pool | System pool |
|---|---|---|
~admin |
the fence system.moved |
everything else |
~accounts |
the sweep's sweep/* (its spill holds a name per object of both pools) |
the directory, ix.purged, ix.fts_layout, the queues |
~learning |
— | due queue, owner index, the worker lock mbox/learning-worker |
~server |
— | metadata |
~public and the accounts |
everything | — |
The data part of ~learning (the learning copies learning/<id>)
stays in the data pool. Without ceph.system_pool everything stays in
the index pool, as before 0.47.0. One function decides
(Store.indexPoolOf): the store's view of a namespace, its sorted
indexes and its locks follow it, and the long-lived views (the admin
store, the queues) follow it across key rotations. The sweep, the bench
and the placement tools reach the pools themselves.
One placement per cluster. Which pool holds the state is decided by
data, not by configuration discipline. Two omap objects in ~admin,
outside kv. and ix. (no server reads them as records or indexes):
| Object | Pool | Values |
|---|---|---|
fence system.moved |
index | state (leased for a fence a tool created only to hold its lease, initializing, migrating, done, native, reverted), the system pool (to_id, to_name), index_id, fsid, run, at, by, the tool lease (tool_instance, tool_epoch, tool_seen); after a migration direction (migrate, revert), fenced_digest, heads, cleaned_at |
mark system.pool |
system | format 1, state (active, retired), origin (native, migrated), the ids and names of the index, data and system pool, fsid, run, at, objects, digest |
run state system.migrate |
system | run, phase (M1 to M7, abort, revert), abort_from, digest_shards, m4_digest, fenced_digest, heads, the original values of every poisoned head (head:<ns>/<object>), the report |
digests system.migrate/<ns>/<nnn> |
system | the digest of every object a migration copied, by object name; nnn is the name's hash modulo digest_shards (about 40 000 names per object, at most 256 objects per namespace) |
Pools are compared by id and cluster fsid; the names are for messages (a pool can be renamed). The fence is the only arbiter: a tool creates it before anything else, writes the mark as its commit and only then the final state.
The check. Opening the store (every shardpost command but the
system tools and the bench, which check it themselves) reads the fence
and, with a system pool, the mark, and decides:
ceph.system_pool |
Fence | Mark | Result |
|---|---|---|---|
| empty | none, reverted or leased |
– | start (none) |
| empty | any other | – | refused: set ceph.system_pool |
| X | none, reverted or leased |
active | refused: a mark without a fence |
| X | none, reverted or leased |
none | refused: run shardpost system init --pool X (no system state in the index pool) or shardpost system migrate --to X (an existing cluster; a few stats tell which) |
| X | initializing for X |
any | refused: run the init again |
| X | migrating for X |
any | refused: a migration runs |
| X | done/native for X |
active, of this index pool and cluster | start (migrated/native) |
| X | done/native for X |
none | refused: a fence without a mark |
| X | for another pool | any | refused |
| X | any | retired, or of another index pool or cluster |
refused |
The check never lists and never writes. A server and the push worker
repeat it every minute: a placement read positively that contradicts
the configuration (a migration began, the mark was retired, a fence
appeared) logs event=system_placement_lost and ends the process after
draining; a read that fails is a warning and
imapceph_system_placement{state="unknown"}, never an exit, so that a
Ceph outage does not become a crash loop. The same holds at startup for
these two: a check that cannot read lets them start in the state
unknown, and the periodic check decides. A short-lived command
without the periodic check refuses to start when it cannot read. The
check stops in Store.Drain and Store.Close.
Init. A new cluster gets its system pool from shardpost system init; the routine (system.go) is
the one the bench uses for its own pools:
- Nothing to do when the fence is
native/donefor the pool and the mark is active for this index pool and cluster (it only reads then). - The tool lease is taken (below). An existing fence keeps its state;
where there is none, one is created exclusively in the state
leased, which the servers treat as no fence. - The index pool must hold nothing of the four namespaces but the fence
and the sweep's objects (else the cluster exists: migrate it), the
system pool nothing but the index heads of this same run, or the
content of an earlier run (a retired mark with its copies, the run
state of a migration that was aborted, the heads of an aborted init),
which is deleted when that run is confirmed and the objects belong to
it (the run state
system.migrateof its migration names it, or every object is an index head of that run), after the fence is set, together with that run's quarantine in the index pool (its run state and mark last, so that a deletion that stopped is taken up by confirming the run again). - A probe object with omap is written and removed (an erasure coded pool fails here).
- The fence becomes
initializingwith a new run, by compare-and-swap. - The completion markers of the backfills are written into the heads of
the empty indexes (
backfill.kvin~admin/ix.job,backfill.accountsin~accounts/ix.accounts,backfill.learningin~learning/ix.learning), as the JSON report the gates read, so thatadmin.kv_indexandimapsieve.learning.indexinreadmode read from the first start without a listing. - The mark is created exclusively: the commit.
- The fence becomes
nativeand the lease is given up.
Every failure but a lost lease gives the lease up and leaves the fence
as it was (a leased fence the init created is removed). A failure or
crash between 5 and 8 leaves initializing: servers refuse until the
init has run again and completed it; the lease of an init that died is
taken over by one that is asked to wait (below).
The tool lease. Every write of the fence is a compare-and-swap on
its version, renewals and state changes alike, through one writer per
process. Taking it changes only the lease fields of an existing fence,
and giving it up removes them (or the fence the lease created). A free
lease is taken at once. One held by another instance refuses the tool
at once, naming the holder, unless the tool was asked to wait; then it
is taken once its tool_instance and tool_epoch were seen unchanged
for three minutes of the taker's own monotonic clock (a tool_seen more
than 30 s ahead of that clock is refused either way). The holder renews every 30 s and
trusts the lease only for 90 s after it sent its last successful write;
then it stops writing and ends (running the tool again continues). A
tool needs an OSD operation timeout of at most 60 s and throttles that
let its operations start; it refuses ceph.client_options that do not
allow that.
Heads of a newer format. Index.Head() refuses a head whose format
is newer than the server knows, as reads and writes always did. A gate
(the backfill markers) therefore never takes the markers of a head that
moved away; it reports the error once and behaves as in write mode.
Shard counts. New heads get 16 shards for ix.job and ix.plan
(1 before 0.47.0) and 256 for ix.fts_layout (16 before); an existing
head keeps its count.
Migration. An existing cluster moves its state with shardpost system migrate (migrate.go,
shardpost-system(1)), in a maintenance
window with every process stopped; the run state records the phase
reached, and every run continues where the last one stopped:
| Phase | What happens |
|---|---|
| M0 | Under the tool lease: the target is a replicated pool of its own and holds nothing but this run's work (or, confirmed, an earlier run's copies); no registration of a running process, no valid lease of the push worker or a job, no held lock, no watcher on auth-flush, ix.jobs/claimable/00, ix.learning/00, ix.submission/00 (a notify with a 2 s timeout) |
| M1 | The fence becomes migrating: servers refuse to start, running ones end within a minute |
| M2 | One listing of the index pool: the source (from M5 on, the old place and the quarantine together) |
| M3 | The copy, records first, then index shards, then index heads; with a digest per object |
| M4 | The target holds exactly the source, and both match the digests; m4_digest per namespace and in total |
| M5 | The quarantine: every old object is copied to <ns>.moved/<run> in the index pool (for example ~admin.moved/<run>), the copy checked, then the original deleted; every old index head is copied there, its values saved in the run state, and then poisoned: format 1000 and phase moved:<pool id>. fenced_digest is the digest of the poisoned heads |
| M6 | The mark: the commit |
| M7 | The fence becomes done with fenced_digest and the list of the heads |
The copy reads an object whole: a stat, the data in chunks of 4 MiB, a
second stat (the size did not change), the xattrs without the cls_lock
state lock.*, the omap in pages of 1000 keys. It writes the first
chunk, the xattrs and the first omap page in one exclusive create, the
rest with the object asserted to exist. The digest is a SHA-256 over
the name, the size, the data, the sorted xattrs and the sorted omap,
each field with its length. Not copied: the object version (no stored
value of the store names a version of these objects; compare-and-swap
reads it anew in every round), the mtime (only account objects use it),
the omap header (the store writes none) and the lock state (a held lock
refuses the migration; an empty lock object is copied as an empty
object). Processes cache index heads under the pool's name, so the
copies need every process restarted, which the window does anyway.
Why a quarantine. After M5 nothing is left at the old place that a
process could read: a server before 0.47.0 finds no records, no flush
log, no directory, and fails on the poisoned heads (directory hook,
queues, hint indexes) instead of reading an empty index. A gate of the
backfill markers falls back to write mode and would write plain
records at the old place; shardpost system verify --source-only finds
every such object. Poisoning the records themselves instead is no
option: the push worker of such a version takes an unreadable lease as
free.
The old place after the migration holds the fence, the poisoned
heads (for good), the sweep's objects under ~accounts/sweep/ and,
until shardpost system cleanup deletes it after seven days, the
quarantine. abort takes a migration back before the servers use the
system pool (from the quarantine and the saved heads, checked against
m4_digest; the quarantine stays), revert after (from the system
pool, which is the truth then; the quarantine goes). Both, and cleanup,
continue when run again after a stop; the operations guide has the
runbook (Moving the store-wide state).
Do not confuse identities
| Term | Scope | Meaning |
|---|---|---|
| Account | Auth/store contract | Canonical namespace owner |
| Mailbox ID | Registry of one account | Stable identity independent of the display name |
| UIDVALIDITY + UID | One mailbox | Identity of a message as seen by the IMAP client |
| GUID | A blob and its index references | Reference to shared message data |
| MODSEQ | Change history of a mailbox | Ordering value for synchronization and conditional changes |
| Account sequence | Change history of a whole account | Ordering value across mailboxes; what a JMAP state token names |
| FTS doc ID | Search index of one account | Compact posting list identity, not an IMAP UID |
| session_id | One network/backend session | Log correlation, not a persistent mail identity |
The atomicity boundary
RADOS makes operations atomic within a single object. The writer reads the
state together with its version and writes with AssertVersion; on a conflict
it re-reads and decides again. The CAS helpers and the
version context pool prevent a
concurrent operation from using a version read by someone else.
There is no shared transaction across registry, mailbox header, higher shard, blob, FTS and external program. Every change therefore has to define which state remains visible after an abort and which repair path can resolve it again.
Writing a message
DeliverGUID checks the GUID and the size, resolves the mailbox and reserves
quota. It then writes the blob chunks, FTS and finally the mailbox entry.
A successful index entry must never point at payload data that has not been
written yet. Errors before linking trigger the designated cleanup and
quota refund paths; a process abort can leave residual state behind.
Shard 0 can change the header and the entry in a single CAS. For higher shards,
the header first announces the change as pending. The entry is then written
and the journal is completed. Readers must not move their
MODSEQ watermark past incomplete changes.
PendingFloor and the synchronization in the protocol layer honor this.
If the entry is already durable but the concluding pending
commit fails, the caller must not delete the blob the way it would after an
early error: that would leave a visible entry without data.
Exactly this distinction is documented in the link path.
COPY, MOVE and EXPUNGE
COPY creates an additional index reference and increments the blob reference count. The payload does not have to be written again within this store path. MOVE is not merely an atomic pointer switch: the code creates a move intent, temporarily increments references, links the target, removes the source and releases its reference. An abort leaves the intent in place for repair. After a successful MOVE, the net reference count is unchanged.
EXPUNGE removes mailbox entries and releases blob references. The last
reference leads to the deletion of the chunks. This is not a backup or
retention feature. \Deleted, a trash mailbox and physical deletion
are different states.
When several local delivery actions are involved, a delivery intent protects their coherence. Its entries name the stable mailbox ID and the GUID before the message is written. Orphaned intents are repaired or rolled back after the designated age threshold. Intents do not guarantee exactly-once delivery to an external client whose success response can be lost on the network.
Intents: debris that a scrub finds without listing
A crash between two writes can leave an object that nothing points at: a blob written before its entry, a mailbox object created before its name was registered, the shards of a mailbox whose deletion stopped half way. Until 0.41 the scrub found such objects by listing the account's namespace in both pools, which costs a walk over every placement group of the pool per account. Since 0.42.0 every write that can leave such an object first records an intent in an object the scrub reads anyway, and removes it once the write is complete (intents.go):
| Intent | Where | Set | Removed |
|---|---|---|---|
bi:<guid> |
registry | in the reservation CAS of DeliverGUID (delivery, APPEND, CATENATE, REPLACE, Sieve fileinto, COPY into another account), before the blob is written; since 0.52.0 with the mailbox id (m) |
once the entry is linked or the blob deleted again; batched, see below |
qi:<id> |
registry | in the reservation CAS of a COPY or a MOVE out of an ignored mailbox, and in the rename CAS of a rename across the quota rules (since 0.52.0, see below) | with the reservation (at once when something is given back), or in the booking of the rename |
bimp:<batch> |
registry | in the reservation CAS of an import step: each message's GUID and a salt from which its two fallback GUIDs follow (about 35 bytes per message, 350 KiB for the largest step of 10 000 messages) | after the header write of the step |
up:<guid> |
registry | before a JMAP upload's blob is written, after its entry in the uploads due queue | when the upload expires (jmap.upload_ttl_seconds) |
sub:<guid> |
registry | before the message copy of a delayed JMAP submission is written, with the submission id and an expiry seven days after the release time | when the submission ends (sent, canceled, failed), by the submission worker or the cancel; see Delayed submissions |
ci:<id> |
registry | in a CAS of its own before mbox/<id> is created |
in the CAS that registers the name (or an import replacement's swap) |
dm:<id> |
registry | in the CAS that unregisters a mailbox, with its UIDNEXT and shard size; since 0.52.0 with q (0 until the usage is released, 1 after) |
after the blobs are released and the shards deleted (highest first, shard 0 last) |
ps:<id> |
registry | before the first private \Seen mark of the public folder <id> is written |
when the scrub removes the marks of a folder that is gone |
r:<uid> |
shard 0 of the mailbox | in the header CAS that removes the entry (EXPUNGE, MOVE, the scrub's own removals; for entries above UIDNEXT on a higher shard in a write before the removal), with the blob's GUID, size and chunk count; since 0.52.0 also whether the writer removed the entry itself (d, with its nonce n) and whether its usage is released (f, written right after the release CAS: one extra write per release) |
after the blob reference is released, by the session that removed the entry |
A DELETE also retires the header of shard 0 after unregistering the
mailbox (h:uidnext 0, the old value kept as h:retired), so that a
delivery that looked the mailbox up before can no longer allocate a UID
in it; entries such a delivery added between the read of the messages
and the retirement are released as well. A delivery whose entry for a
higher shard was still on its way when the header was retired keeps the
tombstone for the scrub, rewritten with the range the header had then;
a release that failed keeps the tombstone and the shards.
An account that only stores Sieve scripts (ManageSieve, the admin API)
gets a registry with its first script (the key created), and with it
a directory entry; before 0.42.0 such accounts had none.
DELETE releases what the shards hold once the header is retired, not the list it read before unregistering: an EXPUNGE or MOVE of another session in between released its entries itself. An entry whose removal intent is on shard 0 belongs to that session; its release may still come, so the shards and the tombstone then stay for the scrub.
What the scrub does with an intent. An intent older than
scrub.intent_grace_seconds (3600) belongs to a write that will not
finish (scrubintents.go).
The scrub settles what it names against its index scan of the account
(the omap reads of every registered mailbox it does anyway, no listing):
- a blob that an entry references is fine and the intent goes; a blob
nothing references and whose chunk 0 is older than
scrub.grace_secondsis an orphan: its reference count is set to 0 under the object version read (a COPY in between makes this fail, and the blob stays), then it is unindexed and its chunks are deleted. - a blob that a young intent names (a removal whose release may
still come, a deletion between its unregistration and its releases, a
running MOVE or delivery) keeps its reference count:
fixRefsleaves it alone, since a release that follows would otherwise be applied on top of the corrected count and could take the blob from a copy. So do the blobs of entries an import left above UIDNEXT that the run did not remove. Without a listing the scrub never collects anything younger than ten minutes, whateverscrub.grace_secondssays; the repairing sweep keeps the same minimum. r:anddm:are never settled by releasing again: the scan counts every reference anew (fixRefs, with the same mtime guard andAssertVersion), so a release that did happen before the crash is not repeated, and a second decrement can never take the blob away from a copy in another mailbox. The mailbox of adm:is not registered, so its entries do not count; its range is the larger of its retired header (a shard 0 without header keys says nothing) and the tombstone, and shards above that range that exist are probed one by one.- a
dm:whose blobs are settled loses its shards (highest first, shard 0 last), then the tombstone; aci:whose id is not registered loses its object; aps:whose folder is gone loses its marks. - the METADATA entries of mailboxes that are no longer registered go as well (the registry is read after the entries; ids are never used again).
The age of an intent is the writer's clock (the time in its value)
against the scrubber's: the servers' clocks must agree (NTP) to well
within scrub.intent_grace_seconds; a writer whose clock runs ahead
only delays the scrub, one that runs behind by more than the grace
period lets a scrub take an intent of a write in flight for a crash.
Object mtimes, which the scrub and the sweep compare with their own
clock, have whole seconds and come from the writer's clock as well; the
sweep's line for a namespace it read lies scrub.sweep_clock_margin_seconds
(60) before the read, on a whole second, so a write in the same second
or from a clock a little behind is never taken for older.
Where the scrub looks is scrub.orphan_source:
| Value | Listing | Acts on | Intents |
|---|---|---|---|
listing |
both pools, per account | the listing (two-run rule for blobs) | dropped once what they name is gone or referenced |
shadow (default) |
as listing |
the listing | judged first, then compared with the listing; differences go to the report (Shadow), the log and imapceph_scrub_shadow_differences_total{kind} |
intents |
none | the intents | settled as above |
In shadow mode orphan_missed, unknown_missed and marks_missed
count what the listing found and no intent names: debris from before
0.42.0, or of a server of an older version during a rolling update, or a
write path without an intent (which would be a bug). orphan_extra
counts blobs the intents take for orphans and the listing does not; it
must stay 0. The switch to intents is safe only once every server runs
0.42.0 or later (servers do not report their versions to each other, so
nothing enforces this: older servers write no removal intents, and a
blob they move or expunge can then look like an orphan), orphan_extra stayed 0, and what *_missed still
reports is debris from before (the sweep removes it, see
operations).
What it costs on the hot paths (round trips to the OSDs added per operation, against 0.41):
| Operation | Set | Remove |
|---|---|---|
| LMTP delivery (one view per delivery) | 0 (in the reservation CAS) | 1 key removal when the view closes |
| LMTP delivery to several mailboxes (Sieve), MULTIAPPEND | 0 | 0 (with the CAS that ends the delivery intent) |
| IMAP APPEND in a session | 0 | 0 (with the next reservation of the session), at the latest one write after 30 s or at logout |
| Delivery into a mailbox the quota rules ignore | 1 CAS | as above |
| EXPUNGE, MOVE (per command, not per message) | 0 (in the header CAS) | 1 key removal on shard 0 |
| CREATE | 1 CAS | 0 (in the CAS that registers the name) |
| DELETE | 0 (in the unregister CAS) | 1 CAS that retires the header, 1 key removal |
| JMAP upload | 1 due queue write + 1 CAS | the expiry |
First private \Seen mark of a public folder |
1 read per session and folder, 1 CAS per account and folder | — |
| Import step | 0 (in the reservation CAS) | 1 key removal |
A key removal is one write op without a read; it bumps the object
version, so a concurrent CAS on the same object retries once. The
skip-if-idle check of the batch scrub reads the registry intents in one
read op (one bounded step per prefix) and each mailbox's i: and r:
in one more.
Measured on the test cluster (TestHotPathOps in
opcount_test.go, run with
SHARDPOST_TEST_OPCOUNT=1 through hack/ceph-test-cluster.sh; the OSD's
op_r and op_w; four full-text shards, a 500-byte message), against
the same test on 0.41.0 and 0.43.0:
| Operation | Reads 0.41 → 0.43 → 0.44 | Writes 0.41 → 0.43 → 0.44 |
|---|---|---|
| LMTP delivery, a view per message | 16.4 → 17.1 → 12.8 | 14.0 → 15.0 → 15.0 |
| APPEND, ten in one session | 15.6 → 15.6 → 11.5 | 12.2 → 12.2 → 12.2 |
| EXPUNGE of one message | 15.0 → 15.0 → 15.0 | 12.4 → 13.4 → 13.4 |
| CREATE and DELETE of an empty mailbox | 16.0 → 20.0 → 20.0 | 9.0 → 12.0 → 12.0 |
| JMAP upload | 0.0 → 1.2 → 1.2 | 1.0 → 3.2 → 3.2 |
The extra reads of the LMTP delivery in 0.42 and 0.43 are CAS retries:
the removal a closed view sends in the background meets the next
delivery's reservation on the same registry object. In 0.44 a posting
shard costs one read instead of two and the id map no read of its own
(see full-text write path): one read less per
full-text shard and one per LMTP delivery, 16 + 1 with the default
sixteen shards. Measured in the R4 bench setup: 245.6 → 226.4
operations per delivery (fts 48 → 32, ftsmap 3 → 2; the trigram
dictionary still writes about 174 objects). The full-text index
dominates a delivery either way.
The uploads. A JMAP upload is a blob that nothing points at, so it
costs an entry in the due queue ix.uploads (namespace ~accounts), the
intent up: and the blob, in that order. The upload expiry worker (one
server of the cluster holds its lease) deletes what is due every
minute; the scrub does the same for an expired upload the worker did not
get to, and a purge finds the uploads through their intents. Upload blobs
are never the blob of a message: Email/import and Email/set read
them and store a new message (model.Blobs has no way to link one;
TestBlobsCannotLink, TestUploadBlobsAreNeverLinked).
Batched removal. A view collects the keys of finished intents and removes them with its next registry write that can carry them (a reservation, the end of a delivery), after 30 seconds at the latest, and when it is closed; LMTP, which opens a view per delivery, pays one write per delivery, an IMAP session that appends many messages none.
Purge without listing
With the intents, everything an account holds is named by its data model, so a purge deletes it object by object instead of listing the account's namespace in both pools (purge.go):
- the mailboxes of the registry, of creation intents (
ci:) and of deletion tombstones (dm:), each with its shards from the header of shard 0 (retired or not) or the tombstone, and the shards above that range that exist (entries of an import step that stopped); - the blobs of their entries, of the removal and MOVE intents on shard
0, of
bi:,bimp:,up:,sub:andd:in the registry and of the orphan candidates in thescrubobject; a blob whose chunk count is not recorded is sized from its length attribute; - the fixed objects: the full-text index (
fts/,ftst/,ftsh/,ftsd/,ftsmap; the configured shard count, at least 16), the front-end indexes (ix/<space>/<nn>of every known space),account,metadata,sieve(with the vacation records),scrub; pubseen/<id>for everyps:<id>;- the registry, last (it holds the maintenance lock).
Blobs go first, while the entries still name them, then the index
objects (the shards of a mailbox highest first), the registry last; a
purge that starts again finds what is left. The admin store records of
the account (jmap/push/acct/, jmap/push/seen/, jmap/submission/)
are removed in a phase of their own after the directory entry, through
the admin store, which keeps their hint indexes in step. The phase is
the last one on purpose: the phases are checkpointed by number, and an
older server that takes a purge over knows only the earlier ones. Such a
takeover (or a crash between the two phases that no server resumes)
leaves the records; they belong to no account any more and a later
purge of the same name removes them. Accepted for the rolling update.
purge.listing (default true in 0.42.0) keeps the purge of earlier
versions: it lists both namespaces and deletes what it finds, and it
also reads the data model first and counts the listed objects the model
does not name (imapceph_scrub_purge_unnamed_objects_total, with a
warning naming a few). What the model cannot name: debris that servers
before 0.42.0 left without an intent, objects of an account that was
never scrubbed since, full-text shards above a lowered fts_shards.
A purge without listing leaves those behind in a namespace that then has
neither a registry nor a directory entry. So that the sweep can tell such
a namespace from an account that only kept Sieve scripts before 0.42.0,
every purge first records the account in the hint index ix.purged
(~accounts); the sweep deletes what is left in a recorded namespace
without registry and without an active directory entry (an active entry
always wins: a lost registry is no debris). Before it deletes anything
in such a namespace it checks again that there is still no registry and
that the namespace may still be cleaned, and it takes no object written
after it read the namespace for a remnant: an account created again
while the sweep looks at it keeps everything. When a repairing sweep
ends, it removes every record of a purge older than its start less its
grace period, except those of namespaces in which it left something
(an object younger than the grace period, a failed delete, a namespace
it skipped); a purged namespace with no object left at all loses its
record as well. An account registered again under the name, set active
again by a directory repair, or whose stale entry a repair removed,
loses its record at once.
The sweep
shardpost scrub --sweep (sweep.go)
is the tool for what no intent names: it lists both pools in all
namespaces, so it costs a walk over every placement group of each, and
runs as a one-off, not as a routine.
- Pass 1 lists a pool and spills the object names into spill objects
in
~accounts(sweep/<run>/<pool>/<nnnn>, one omap key<namespace>␟<object>per object, the spill object chosen by the namespace,scrub.sweep_spill_shardsof them). The iterator's position (the PG hash position of go-ceph'sIter.Token) is checkpointed insweep/stateevery 10 000 objects; a sweep that stopped seeks there. When the pool'spg_numchanged meanwhile, the position means nothing any more and that pool's listing starts over (a name spilled twice changes nothing). - Pass 2 reads each spill object in key order, so the names of one
namespace come together, and compares them with what the account's data
model (the purge's enumeration). What it does not name is a
remnant; with
--repair, a remnant older thanscrub.grace_seconds(at least ten minutes) is deleted (a blob through its reference count, under the version of its chunk 0). A namespace without registry is deleted only when the account directory says the account is being purged, or the purge recorded it inix.purgedand the directory holds no entry for it; any other one (a lost registry, an account that only kept Sieve scripts before 0.42.0, the leftovers of a purge whose directory entry is gone) is reported (registry_missing,unregistered, classnamespace) and left alone. An account whose data model cannot be read in full (a registry record that does not parse, an unreadable header, a registered mailbox without shard 0) and accounts under an import lock are left alone. A full-text shard above the configured count in an account is reported and never deleted (it may belong to servers configured with more shards); any other remnant is deleted only at the object version that was judged. Private\Seenmarks of a live public folder that predate the intents are adopted: theirps:key is written.
One sweep runs at a time (a cls_lock on sweep/lock); the report is
printed as JSON and logged. Lock, checkpoint and spill stay in the index
pool when the rest of ~accounts lives in the
system pool; the account directory and
ix.purged are read where they are. The store's own namespaces and any
other reserved one (a ~ name, such as the quarantine of a migration)
are never swept.
The account change log
A MODSEQ counts inside one mailbox and says nothing about the mailbox
next to it. That is what RFC 7162 specifies and it is all IMAP needs.
JMAP (RFC 8620) asks a different question — what changed anywhere in
this account since state token N — and answers it with a single number
per account and data type. The account object holds that number and
the log behind it:
| omap key | Meaning |
|---|---|
h:seq |
The account counter; the highest sequence ever allocated |
h:logstart |
The oldest sequence the log still holds |
p:<seq> |
A change that has been announced and not yet recorded |
a:<seq> |
The record of a change: kind, operation, mailbox, messages |
The log is a second record beside the mailbox change log, never a second number space. An entry says which object changed and carries the account sequence; the mailbox MODSEQ stays exactly what it is, and the mailbox log remains authoritative for what happened inside a mailbox.
A mailbox object and the account object are two objects, so the
atomicity boundary applies: they cannot be written together. Every
change is therefore three steps, the same two-phase shape a higher-shard
write already uses:
- the writer announces the change (
p:<seq>, one CAS onaccount), - the mailbox change happens as before,
- the writer records what it did and drops the announcement.
A reader never reads past the lowest announcement that has no record yet, so a state token cannot advance over a change that is still being written. An announcement whose writer died is turned into a partial entry after 60 seconds: the mailbox is named, its messages are not, and a client resynchronises that one mailbox. The paths that repair or import (the scrubber, the account import) write partial entries directly, because they run again if they die and no client is waiting on their state token.
The direction of the guarantee is the point: the log may report a change that did not happen, or report one without its details, but it must never stay silent about a change that did. A client acts on a spurious report by fetching something unchanged; it acts on a missing report by never learning about a message.
The log is bounded like the mailbox change log
(server.account_log_retention, 10000 entries by default). A client
whose token is older than the oldest entry is told to resynchronise
instead of being handed a delta with a hole in it.
Two limits are deliberate at this stage. The private \Seen marks of a
public folder are recorded in the log of the public account, not in the
log of the account the mark belongs to; JMAP sharing (RFC 9670) is where
that is resolved. And METADATA annotations have no entry at all, because
RFC 8621 has no object for them.
Front-end indexes
A front-end may keep an index of its own in the store: data it derives
from the rest and can always rebuild from it. The JMAP locator (GUID →
mailbox and UID) is the one that exists. Such an index carries no
consistency obligation towards the rest of the store — a lost write costs
a rebuild, never a message — and nothing on the IMAP write path writes to
it. It lives in ix/<space>/<nn>, spread over 32 shards by a hash of the
key, so that an account with a million messages keeps each object at
about thirty thousand keys, below the size at which Ceph warns about
large omap objects. The shard count is fixed for good: changing it would
move every key.
The scrubber leaves these objects alone, and an account purge removes them with the rest of the namespace.
Store-wide sorted indexes and the account directory
Listing a RADOS namespace visits every placement group of the pool, so
its cost grows with everything in the pool, not with what the namespace
holds. Enumerations that run routinely therefore read sorted indexes
instead; a listing is acceptable only in a repeatable backfill or a
manual tool. The metric imapceph_rados_listings_total{site} counts the
listings that remain (see Observability).
Sorted index. A store-wide map from ids to small values (sortedindex), stored in a system namespace of the index pool:
| Object | Content |
|---|---|
ix.<name>/head |
omap shards, format, phase (stable, or copying:<n> while a later reshard copies), mark:<name> markers |
ix.<name>/<nn> |
one omap key per id whose FNV-1a(id) mod shards is nn |
The names never start with kv. (older servers read those as admin
records) and are distinct from the per-account ix/<space>/<nn> above.
The first writer creates the head; readers without a head use the
defaults. Each process caches the head for ten seconds. A write refuses
while the phase is not stable (resharding is a later stage and this
version does not write the new layout); a newer format is refused
altogether. One entry is read, set or removed with one operation on its
shard object; an update is a compare-and-swap on the shard object's
version, because go-ceph offers no omap comparison; a short random
backoff separates the attempts, and each lost round counts as
imapceph_rados_cas_retries_total{kind="index"}. A listing returns the
entries in (shard, id) order, page by page, with the cursor
<shard>:<id>; a page reads the shard of the cursor and continues with
the following shards until it is full.
Account directory. The sorted index accounts in the namespace
~accounts, 256 shards. An account is in it exactly when its registry
object (mailboxes) exists; this includes ~public and accounts that
only received a shared-folder pointer, and never the store's own
namespaces (~admin, ~learning, ~server, ~accounts). The value is
JSON:
| Field | Meaning |
|---|---|
created |
When the registry was last created (the hook writes it on every creation); the registry's last change for accounts a backfill entered. Concurrent creators race, the last one wins: it is only a time. |
state |
active or purging |
session |
The purge job's maintenance lock session while purging |
cluster |
fsid of the Ceph cluster that holds the account |
shard |
Reserved for a later stage (placement), empty |
scrub |
The record of the last batch scrub of the account: at, state (seq and reg, see below), complete, findings; absent before the first. A new registration of the account drops it. |
The store registers an account in the compare-and-swap that creates its
registry (casOmapOpAs, version 0, the account's own index context),
right before the exclusive create: one read and one write (an active
entry is written again with the new created). That happens once per
account lifetime, so an existing account pays nothing. shardpost index backfill accounts enters the
accounts that existed before the directory (see
Operations).
Store.User refuses the empty name and reserved names other than
~public, ~server, ~admin, ~learning and ~accounts: the empty
name would put a registry into the default namespace, where it belongs
to nobody.
Purge. The import lock that keeps protocols out of an account lives
in its registry, so it disappears when the purge deletes the registry.
Without the directory, a late session or delivery could then create the account
again. Now the purge job sets the entry to purging with its lock
session (job-<id>) while it still holds the lock, before it deletes the
namespace. From then on, while the registry is missing, the registry
hook refuses every creation with ErrAccountPurging, a kind of
ErrImportLocked (LMTP answers 451, IMAP NO [UNAVAILABLE]), except an
ImportLock of the job's own session, so that a restarted job can take
its lock again. That
restart leaves an empty registry with only the lock key behind, which is
why the last purge phase stops renewing the lock, fences, deletes the
registry once more (absent is fine) and only then removes the entry.
After that the name is free; a later account of the same name is a new
account. While the registry exists, purging does not block anything
beyond the import lock itself.
A grant (SETACL, the admin ACL operations, JMAP sharing) to an account
that is purging without registry is refused with ErrAccountPurging
(an ErrImportLocked; the admin API answers 409 account_purging)
before the ACL is written: the pointer write into the grantee's registry
would fail, but the ACL entry would stay and pass to whoever gets the
name next. A removal of rights goes through and writes no pointer. The
check narrows the window, it does not close it: a grant whose check ran
just before the purge deleted the grantee's registry still writes its
ACL entry, and its pointer write then fails (ErrPointerSync). Closing
it would need the ACL write and the grantee's state in one atomic step,
which two objects in two namespaces cannot give.
Repair. shardpostctl account repair and the backfill apply these
rules:
| Entry | Condition | Action |
|---|---|---|
purging |
a purge job of its session still runs | none (purge_running) |
purging |
no such job, registry missing | entry removed (removed) |
purging |
no such job, registry exists | back to active (activated) |
active |
older than 10 minutes, registry missing | entry removed (stale_removed) |
A stale active entry comes from a purge by an older version during the
rollout, from a crash between the registry hook and the registry
creation, or from a creation whose write failed. The grace keeps a
repair away from an account that is being created right now. The
removal is a compare-and-swap on the entry the repair read, including
its created, and the hook writes the entry (with a new created)
before it creates the registry. So a creation racing the repair always
keeps its entry: if the hook writes first, the repair finds the entry
changed and leaves it; if the repair removes first, the hook finds no
entry and writes one.
While an account is purging without registry, no exclusive job can
take its lock, a second purge included. A job that finds the account
left so by an ended purge fails at once with "run shardpostctl account
repair" instead of waiting for the lock.
Rollback. Older servers ignore ~accounts (the scrub's namespace
listing skips reserved names), and an older purge sets no purging, so
for its purges the behaviour before the directory applies; it leaves the
entry active, which the repair later removes as stale.
Reshard (later stage). Resharding the directory will announce itself
in the head (phase: copying:<n>). Servers of this version refuse every
directory write in that phase, so creating an account then fails with a
temporary error (LMTP 451). A reshard therefore requires every server to
run a version that writes during the copy phase first.
Batches over all accounts and the batch scrub
A batch is a maintenance job without account (Account "", kind
batch) that runs one kind on every account of the directory
(jobs/batch.go). Only a batch may be
account-less, and Store.User("") stays refused: the runner opens no
account for it, the batch opens each account in turn. The kinds a
batch can run are index_check, scrub, fts_rebuild and
attachment_backfill; they do not depend on a plan of the account (a
batch has one plan for all of them). The role and the exclusivity of a
batch are those of its kind. A batch is planned only while the
directory carries a completed backfill marker, since otherwise accounts
from before the directory would be missed.
The batch walks the directory in its order (shard, account) a page at a
time; a page holds four accounts per worker (at least 16). The accounts
of a page run with a bounded parallelism (jobs.batch_parallel, for a
scrub scrub.parallel); after each page the directory cursor
<shard>:<account> and the counters go into the job's checkpoint, so a
server that takes the job over redoes at most one page (all batch
kinds may run twice on an account). An entry purging whose registry
is gone counts as gone; with a registry it counts as active, as the
directory semantics say. Before each account the batch fences on its
lease, so a cancel request or a lost lease stops it between two
accounts. An exclusive kind takes the account's maintenance lock (the
batch's lock session job-<id> for every account), keeps it renewed
every third of the lease and waits jobs.settle_seconds before it
works; an account that an import or another job holds is put aside and
tried again after the walk, up to three times a lease period apart,
and then counted as failed. The result counts the accounts (done,
skipped, gone, failed, findings) and names the first 50 that
failed or had findings, the record stays far below the 4 MiB of an
admin record however many accounts there are.
Skip-if-idle. The nightly scrub is a batch of scrub with
skip_idle. After it scrubbed an account with every repair class, the
batch writes the account's scrub record into its directory entry: the
account counter (h:seq of the account object) and the object version
of the registry, both read after the scrub's own writes (the quota
fix, the orphan candidates, the quota count), whether the run was
complete (no import, no error, the index scan complete) and how many
findings it had. A run that found nothing but saw the state move during
the scrub (a concurrent change) records itself as incomplete. A later
batch with skip_idle leaves the account alone only when all of this
holds:
- the account counter and the registry version are the ones recorded;
- the
scrubobject is empty: no orphan candidates and no quota count wait for the second run of the two-run rule (a run that finds the quota counters right removes a count an earlier run left); - the recorded run was complete;
- no intent is open: none of
d:,bi:,bimp:,up:,ci:,dm:in the registry, no MOVE intenti:and no removal intentr:on a mailbox object; this costs one read op of the registry and one per mailbox, no listing; - the recorded run had no findings;
- it is younger than
scrub.interval_days(7): after that the account is scrubbed again in any case.
The cheap conditions (3, 5, 6) are checked from the directory entry
before the account is read at all. Accounts that are not skipped are
scrubbed with the configured orphan source (see
intents): with
listing and shadow including the listing of their namespace in the
data pool (orphan blobs) and the index pool (unknown objects), with
intents without any listing.
What skipping still misses. Since 0.42.0 every write that can leave
debris records an intent in the registry or on a mailbox object, so an
account with debris of a crash is not idle (condition 4) and is
scrubbed. What a skipped account can still hold until its next scrub, at
the latest scrub.interval_days later: the private \Seen marks
(pubseen/) of public folders that were deleted in ~public meanwhile
(their ps: key is no open intent), and debris that writes of servers
before 0.42.0 left without an intent. None of it is visible to a client;
it only takes space until then.
shardpost scrub (the run in the process itself) takes its accounts
from scrub.users, else from the directory once it carries a completed
backfill marker, else from a listing of the index pool.
Admin store: hint indexes and due queues
The admin store (adminkv) keeps
one object per record, kv.<key> in the namespace ~admin: jobs
(job/<id>), plans (plan/<id>), idempotency keys (idem/<hash>), the
JMAP push subscriptions of an account (jmap/push/acct/<account>) and
smaller state. Records stay objects in every mode. Next to them live
sorted indexes (kvindex) that spare
the job runner, the expiry of old records and the push worker the
listing of the namespace (which visits every placement group of the
pool).
| Object | Content |
|---|---|
ix.job/… (16 shards; 1 in a head created before 0.47.0) |
hint index of job/: {state, account, kind, finished} |
ix.plan/… (16 shards; 1 in a head created before 0.47.0) |
hint index of plan/: {kind, account, expires} |
ix.idem/… (16 shards) |
hint index of idem/: {job_id} |
ix.jmap/push/acct/… (64 shards) |
hint index of jmap/push/acct/: {verified, expires, sum} (any subscription verified, the latest expiry, and since 0.52.0 a checksum of the record, so the push worker's scan reads only the records that changed); its shard count is the partition count of the push worker |
kv.jmap/push/seen/<account> |
the state the push worker last pushed for the account: seq, the digest and the states of the shared accounts, since 0.52.0 an acknowledgement per subscription (ack: the position it was delivered up to) and an epoch that rises when the account counter jumped back; written with a compare-and-swap that merges by max (JMAP: Push partitions) |
ix.jmap/push/p<nn>/lock |
cls_lock of push partition nn (0.52.0), held through key rotations by its holder; the front-ends notify it with the account as payload when a subscription changes |
ix.jmap/push/members |
the membership of the push pods: one key per pod with its partitions, the accounts it watches, its bounds, its clock and whether it is leaving; a reader deletes a record older than ten minutes |
ix.jmap/push/ctl |
the control object every push pod watches; notified after an orderly release of a partition |
kv.jmap/push/lease |
the lease record of the single push worker of before 0.52.0; the partitioned pods keep it occupied as a marker (holder partitioned/<instance>) and wait while an old pod holds it |
kv.servers/<instance> |
registration of a server ({version, caps, seen}), renewed every minute, deleted 24 hours after its last heartbeat (layout 3) |
ix.servers/… (1 shard) |
hint index of servers/: {version, caps}, without seen, so a heartbeat does not write it |
kv.fts/layout3 |
the cluster mark that allows full-text layout 3 |
kv.fts_due/<account> |
the job the fts_due worker planned for the account's generation (deleted with the account) |
ix.jobs/claimable/… |
due queue of the jobs a runner may have to claim |
ix.jobs/expiry/… |
due queue of the jobs, plans and idempotency keys that expire |
ix.jobs/expiry/lock |
cls_lock of the expiry worker |
ix.kv/lock |
cls_lock of shardpost index backfill kv |
kv.jmap/sendq/<account>/<id> |
the record of a delayed JMAP submission (below) |
ix.submission/… |
due queue of the delayed JMAP submissions |
ix.submission/p<n>/lock |
cls_lock of partition n of the submission worker |
The head of ix.job carries the backfill markers backfill.kv (the
report of the last completed backfill, with its start time) and
backfill.kv.progress.
Hint index. An entry carries what its readers need of the record,
so a reader that filters (jobs by account and state) reads only the
records that match. The hint rule orders the two writes: on create the
record first, then the entry; on delete the entry first, then the
record. A crash in between leaves a record without entry, which the
backfill adds, or an entry without record, which readers skip and
remove. The entry is written after a write that created the record or
changed its index value (a heartbeat does not), and it is computed from
the record as it is then: the index update reads the record inside its
compare-and-swap on the shard object, so of two writers of one record
the later index write reflects the later record. A failed index write
does not fail the record write; it is logged and counted
(imapceph_kv_index_errors_total), and the backfill repairs it.
Due queue. A completeness index of what becomes due at a time
(duequeue): every record that
has to be visited has an entry, so a worker finds what is due by
reading the heads of at most eight shard objects instead of listing.
The omap key is <due>/<id>/<gen>: the due time as 20-digit Unix
nanoseconds (keys sort by due time), the id, and the generation of the
record it was written for. The shard is FNV-1a(id) mod n of the id
alone, so all entries of one id live in one shard object and moving an
entry is one write op (RmOmapKeys of the old key plus SetOmap of the
new one). n is admin.kv_queue_shards (default 4, at most 8) when
the first writer creates the queue; the head keeps it after that. Due
reads the first page of every shard, keeps the keys up to now and
merges them, earliest first. Writers create an entry before its record
and remove it after the record. Moves are not compare-and-swap (go-ceph
has no omap comparison); the record stays the fence, and readers
compare an entry with its record: an entry without record or with a
generation newer than the record's is skipped for a grace of ten
minutes (its writer may be between the two writes) and removed after
it; an entry that does not match its record's position is moved there.
Jobs queue (jobs/claimable). A queued job is due at its creation
time (generation 0), a running one at the end of its lease (generation
Lease.Gen). Every write that changes the lease end or the state moves
the entry after the record, from the position computed from the record
as it was read in the compare-and-swap to the one of the record as
written:
| Writer | Record | Queue |
|---|---|---|
| submit | created (Created is the entry's time, same nanosecond) |
entry at Created written before the record; notify |
| claim | Lease.Gen+1, Lease.Until = now + lease |
moved to the new lease end and generation |
| heartbeat | Lease.Until extended |
moved |
| postpone (account locked) | Lease.Until = now + lease |
moved |
| release at shutdown | Lease.Until = now |
moved to now; notify |
| cancel of a queued job | ended at once | removed |
| cancel of a running job | cancel_requested |
unchanged (claimable() does not change) |
| end | final state | removed after the record |
So the entries due now are the jobs claimable() selects. A reader
(admin.kv_index=read) that finds an entry whose record is not
claimable (a heartbeat's record write succeeded, the process died before
the move) moves it to the record's lease end; an entry of an ended job
is removed; a claim removes the entry it came from together with the
record's own. The compare-and-swap on the record stays the fence.
Wake instead of poll. A writer notifies shard 0 of
jobs/claimable when an entry becomes due earlier than before: a
submit, and a release at shutdown. In read mode every runner watches
that object and then claims from the queue at once; its poll
(jobs.poll_seconds) stays as the fallback, and a watch that reports
an error (the OSD dropped it, or the store rotated its connection) is
set up again on the next poll. In write mode runners do not watch:
their look for jobs is still a listing of the admin namespace, and a
wake-up per submit on every server would multiply those listings; they
find new jobs by the poll as before. imapceph_job_wakeups_total{cause}
counts the looks by cause. Watch errors are coalesced into one pending
error per watch, so the librados thread that reports them never waits
for the runner.
Expiry (jobs/expiry). The entries carry the retention of
cleanup():
| Record | Entry |
|---|---|
| job | placeholder at Created + retention (generation 0) at submit; at the end moved to Finished + retention (generation 1) |
| idempotency key | the same positions as its job (the job record names the key, idem_key); the placeholder is written before the key itself |
| plan | Expires + retention |
One server of the cluster runs the expiry worker: it holds the
cls_lock ix.jobs/expiry/lock instead of taking it per step. The lock
lives jobs.lease_seconds + 120 s and is renewed with MAY_RENEW every
third of that, so a renewal that waits out the OSD op timeout (60 s)
still lands before the lock runs out; releasing it waits for a renewal
in flight before the unlock, so a late renewal cannot take the lock
again. Every
jobs.expiry_seconds deletes what is due. For a job it deletes the
idempotency key first (only while the key still names this job; absent
is fine), then the job (a compare-and-swap that checks the job is still
ended and expired), then the entries. A job that has not ended when its
placeholder is due is rescheduled one retention later; an idempotency
key whose job was never stored (a crash between the two creates, which
used to block the key for good) is deleted when its placeholder is due.
A plan is deleted at Expires + retention. The lock is advisory: every
deletion is a compare-and-swap on the record. When the store rotates
its cephx key (Store.Rotate), the worker's lock is unlocked through
the old connection while its key is still valid and reported lost; the
worker takes it again on the new connection. A lock whose holder died
stays held until its TTL (jobs.lease_seconds + 120 s) ran out. A key
that Due cannot parse is skipped and counted
(imapceph_queue_stale_entries_total{action="unparsable"}).
Key rotation. The admin store is opened once and kept for the
process lifetime by the job runner, JMAP and the push worker. It does
not hold one connection for good: it is a view registered with the
store, and Store.Rotate (a new cephx key from Rook) reopens it on the
new connection and swaps it in. Requests that already run finish on the
old view, which is closed once they are done (so the old connection
shuts down); the queue watch on the old view gets an error and the
runner watches again on the new one. No restart is needed after a key
rotation. Store.Rotate releases each held worker lock only after a
renewal in flight returned, so a rotation can take up to the OSD op
timeout (60 s) per held lock.
In write and off mode cleanup() (a listing of the jobs and plans)
keeps running on every runner poll beside the expiry worker. In read
mode it does not run: after the backfill jobs/expiry knows every
record, and the expiry worker alone deletes what is due. So in read
mode no running path lists the admin namespace: the runner claims from
jobs/claimable, job lists (admin API, shardpostctl job list) and the
push worker's scans read the hint indexes, and the expiry worker reads
jobs/expiry. Only shardpost index backfill kv lists
(imapceph_rados_listings_total{site="admin_kv_backfill"}); a listing
under admin_kv in read mode is a prefix that is not an indexed
collection.
Modes (admin.kv_index):
| Mode | Writes | Reads |
|---|---|---|
off |
no index, no queue, no expiry worker | listings (the behaviour before) |
write |
hint indexes, both queues, expiry worker, notifies | listings; the runner claims from the listing; cleanup() on the poll |
read (default) |
as write |
listings of an indexed collection come from its hint index (key order, merged across shards; entries without record are removed); job lists filter on the index values; the runner claims from Due; no cleanup() |
read is only correct after shardpost index backfill kv completed
with a start after the last server without the indexes stopped: records
such a server wrote are missing from the indexes until then. A server
configured for read therefore checks the completion marker
backfill.kv: while it is missing, the server logs a warning at start
("admin.kv_index=read without a completed …") and behaves as in
write mode (it lists, runs cleanup() and does not watch the
claimable queue). It looks for the marker again every 30 seconds and
switches to read once it exists, without a restart. The server cannot
tell when the last older server stopped; that the backfill started
after it is the operator's part of the rollout (see
Operations).
Rollback. Older servers ignore the ix.* objects (they list only
kv.*) and keep reading by listing; the records are unchanged, the job
record only gains the field idem_key. A job that an older server ends
leaves its queue entries behind; the readers of this version remove
them (claimable: the record has ended; expiry: the record is gone or is
rescheduled to its real expiry).
Delayed submissions
A JMAP EmailSubmission with HOLDFOR or HOLDUNTIL (0.43.0, scaling
plan B10, JMAP) waits in the admin store
until its release time (sendqueue):
| Object | Content |
|---|---|
kv.jmap/sendq/<account>/<id> (~admin) |
the record: state, generation, release and next attempt, envelope, XCLIENT data, the GUID of the copy, attempts, the outcome per recipient |
ix.submission/head, ix.submission/<nn> (~admin) |
completeness queue, keys <due>/<account>/<id>/<gen>, shard by the id |
ix.submission/p<n>/lock (~admin) |
cls_lock of worker partition n (accounts by hash) |
sub:<guid> in the account's registry |
the intent of the message copy: submission id, size, chunks, expiry |
msg/<guid>/<n> in the account's data pool |
the message copy (Bcc removed), one reference, never linked to an entry |
The JMAP view of the submission (undoStatus, deliveryStatus, the
change log) stays the account's record jmap/submission/<account>;
the worker writes the outcome there when the submission ends.
States. The record moves by compare-and-swap, each write raising its
generation: pending → sending → final | failed, pending → canceled,
sending → pending (a temporary failure before the message was
transferred, while retries are left — one budget of retry_attempts
for relay errors and for an account directory, registry or message copy
that could not be read), and sending → final with an
unknown result once sending_timeout_seconds has passed since the
attempt began. Only the compare-and-swap pending → sending starts a
conversation with the relay, and only the worker that made it returns
the record to pending, and only for an error that happened before the
message was transferred: a submission is sent at most once, whatever
the leases, the clocks or the timeout say. A cancel is the
compare-and-swap pending → canceled; a cancel and the start of a send
cannot both succeed.
Order of the writes. Creating: the JMAP view (pending, so that a
worker that sends at once finds the entry to settle), the copy's
intent, the copy, the queue entry, the record (a completeness index: the
entry before the record); a schedule that fails takes the JMAP view
back. The record keeps the JMAP view as created with the sequence of
its creation in the account's change log; settling adds the view to the
account's records when it is missing there, the change log still
reaches back to that sequence and does not show it destroyed (a lost
view write). The records carry a random epoch set when they are first
written, and the queue record the epoch it was created in: a name that
was purged and used again has records of another epoch, which never get
an old submission's view. Past the log's reach, in another epoch, or for
a submission that failed because its account or copy is gone, a missing
view counts as destroyed, and an account without any records (purged)
gets none. Ending (worker, cancel): the
JMAP view, the copy with its intent, the record, the entry (the record
before the entry). The entry's position follows from the record: a pending
submission at its next attempt, one being sent at the end of its
sending timeout, an ended one at once (its clean-up is due). A reader
that finds an entry elsewhere moves it to the record's position; an
entry without record, or with a newer generation, is removed after ten
minutes (model.DueQueueGrace), since its writer may still be between
the two writes. A crash anywhere is finished from the entry.
Worker. Every server with a relay and
jmap.submission.delayed.worker runs it. The accounts are split into
partitions by hash, each with a renewed cls_lock; a server holds up to
max_partitions. The holder pages through the due entries of the
queue (from the key it read last, so entries of other partitions do not
hide its own) and sends up to concurrency at once. It wakes on a
notify on shard 0 of the queue (each new submission sends one), when
the earliest entry it saw becomes due, and every poll_seconds; free
partitions it tries to take only on the poll, so a notify costs no lock
operations. The leases spread the work; the compare-and-swap on the
record is the fence. Entries of partitions it does not hold that are
overdue by more than a poll are counted
(imapceph_jmap_submission_queue_entries{state="unowned"}) and warned
about: a partition nobody holds is never sent, so servers ×
max_partitions must reach partitions — (servers − 1) ×
max_partitions for one server to fail; max_partitions 0 (all, the
default) always does. Before it sends, the worker checks the account
directory: a submission of an account whose entry is purging, or that
has neither an entry nor a registry (purged), fails instead of being
sent. An entry alone is no proof: an account older than the directory
has none until the backfill ran, and its registry shows it is live. A
directory that cannot be read is a temporary failure (retried).
Shutdown. A stopping worker starts no new conversation (also none
that waited for a slot), waits for the running ones to end — up to the
relay's timeouts, ten minutes for the answer to a message — and gives
its leases back. A dial the shutdown canceled leaves the submission
pending without counting an attempt. A server killed in the middle
(SIGKILL, the pod's grace period ran out) leaves the record in
sending: at most once still holds, the submission ends as unknown
after the sending timeout.
Purge, scrub, sweep. A purge marks the directory entry purging
first — from then on no worker sends for the account — then removes the
records and entries of its submissions, named by the sub: intents of
its registry, before the namespace goes (a conversation a worker had
already begun ends as it ends; it is not repeated); the copies go with the namespace, named by
the same intents (purge). A scrub leaves a
copy alone until its intent expired (seven days after the release time)
— also a scrub that lists, which otherwise takes a blob without a
reference for an orphan — and then deletes it with its intent. The
sweep names the copies through the data model like every other blob.
Rollback. Servers before 0.43.0 do not know the queue: they refuse new delayed submissions, do not send queued ones, and leave their records, entries and copies. Going back behind 0.43.0 therefore needs an empty queue (see Operations).
Learning queue: due queue and owner index
A learning job (imapsieve) is
a record of its own: learning/<id> in the namespace ~learning of
the data pool holds the message, the job is its xattr job. The data
pool is erasure coded and has no omap, so the indexes that spare the
worker and the purge the listing of that namespace live in the namespace
~learning of the index pool
(learnindex):
Object (index pool, ~learning) |
Content |
|---|---|
ix.learning/head, ix.learning/<n> |
due queue learning: <next attempt>/<id>/<gen> → empty; imapsieve.learning.queue_shards shards (default 4) |
ix.learning/owner/head, ix.learning/owner/<nn> (16 shards) |
hint index of the jobs by owner: <account>/<id> → empty |
ix.learning/lock |
cls_lock of shardpost index backfill learning |
mbox/learning-worker |
cls_lock maildrop of the worker (the object and name every version locks) |
The head of the due queue carries the backfill markers
backfill.learning and backfill.learning.progress.
Due queue. A completeness index: every job has one entry. A live
job sits at its next attempt, a dead one at the largest due time the
key can hold (09223372036854775807, the year 2262), after every live
entry, so it is never due and readers of the due entries never reach
it. (The plan called this the dead/ prefix; a prefix on the id would
put the entry into another shard, so the move would no longer be one
write.) The generation is the job's Gen, which every write through the
queue counts up; servers without the index drop the field when they
rewrite a job.
| Writer | Record | Queue | Owner index |
|---|---|---|---|
| enqueue (event, prepared COPY/MOVE) | created exclusively; an existing job is left alone | entry at its next attempt written before the record (removed again when the record exists already at another position); notify | entry after the record |
| attempt failed (retry, backoff) | Attempts, Next, Gen+1 |
moved after the record | unchanged |
| dead (attempts exhausted, permanent error, unconfirmed after a day, oversize) | Dead, Gen+1 |
moved to the dead position | unchanged |
| retry of a dead job (admin API) | Dead false, Next now, Retries+1, Gen+1 |
moved to now; notify | unchanged |
| delivered, purge | removed | removed after the record | removed before the record |
Worker. One server of the cluster holds the cls_lock maildrop on
mbox/learning-worker in the index pool and keeps it (renewed every
third of its two minutes) instead of taking it per pass. Servers of
older versions take the same lock per pass, so during a rolling update
only one worker sends jobs to the learning programs. When the store
rotates its cephx key the lock is unlocked through the old connection
and reported lost; the worker takes it again on the new connection. A
lock whose holder died stays held until its TTL ran out. In read mode
the worker reads at most 100 due entries per pass from the heads of the
queue shards, reads each job's record and compares: an entry without
record is skipped and removed after ten minutes (the entry is written
before the record); an entry the record does not match (an older server
rewrote the job) is moved to the record's position. It watches shard 0
of the queue, which an enqueue and a retry notify, and keeps its poll of
one second as the fallback; a broken watch is set up again at the next
poll. imap_imapsieve_learning_wakeups_total counts the passes a
notify started.
Readers. In read mode the admin API lists jobs from the owner
index (all of them, or the prefix <account>/) and reads each record;
an entry whose record is gone is removed. Show and retry read the
record by id in every mode. An account purge deletes the jobs the owner
index names. In write mode all three list the namespace as before
(imapceph_rados_listings_total{site="learning_all"},
{site="learning_purge_owner"}).
Modes (imapsieve.learning.index, default read since 0.47.0, write before):
| Mode | Writes | Reads |
|---|---|---|
off |
no index, no queue | listings (the behaviour before) |
write |
due queue and owner index | worker, admin list and purge list the namespace |
read (default) |
as write |
worker from Due, woken by notify; admin list and purge from the owner index |
read needs the marker of a completed shardpost index backfill learning that started after the last server without the index stopped
(jobs such a server enqueued have no entries until then). Without the
marker the server logs a warning at start and behaves as in write mode
until the marker exists; it looks for it every 30 seconds. The backfill
lists the namespace once ({site="learning_backfill"}) under the
cls_lock ix.learning/lock, reads every job again and sets its queue
and owner entry; it can be repeated and resumes after an interruption.
It then removes the queue entries whose job is gone: those at the dead
position, which no reader of due entries reaches, and those due for
longer than ten minutes. A delete that finds the job already gone
removes the id's entries at the dead position as well (one bounded read
of its shard).
See Operations.
Rollback. Older servers ignore the index objects and the field
Gen; a job they rewrite or delete leaves its entry behind, which
readers of this version move or remove.
Notifications and search
Watch/notify is a wake-up signal. The mailbox header, the change journal and the entries remain authoritative. A full snapshot becomes necessary when the journal no longer covers the requested time range. Watches must be stopped before their store view is closed.
The IMAP sessions of a process share their watches through one watch hub
(watchhub): one RADOS watch per
object, on a store view of the hub's own, fanned out to every session
that follows the object. IDLE follows the mailbox object of the selected
mailbox. NOTIFY follows the account object (account) and reads what
changed from the account change log: the mailboxes the new entries name
get their STATUS (one header read each), the selected one its poll; when
the log no longer covers the position the session read up to, or an
entry names no mailbox, every notified mailbox is looked at. A selected
mailbox of another account or of the public namespace is followed
through the owner's account object. When an account object cannot be
watched, NOTIFY falls back to the mailbox objects. A watch that reports
an error (the OSD dropped it, or the store rotated its connection) is set
up again by the hub on a fresh view, retried with a growing delay (the
first failure is logged as a warning, the further ones at debug level),
and its followers are woken to look for what they may have missed. NOTIFY
also reads the logs it follows every 60 s without a notify, which bounds
the delay of a change recorded after its notify (an announcement whose
writer died is resolved after 60 s) or missed while a watch is set up
again. A connection holds at most server.imap_max_watches
subscriptions, one of them reserved for IDLE; NOTIFY SET is refused with
NO [LIMIT] when its fallback watches do not fit.
The full-text index works per GUID. Field terms and DE/EN stemming are stored in posting lists; a trigram dictionary supports substring searches.
| Object | omap key | Value |
|---|---|---|
fts/<nn>, server.fts_shards of them (16), shard = fnv1a(term) |
term, <field>:<token> (s:invoice) |
posting list: document IDs, sorted, delta-varint |
ftst/<nnn>, 256 of them, shard = fnv1a(trigram) |
<trigram>:<term> (voi:s:invoice), one key per trigram of the token |
empty |
ftsmap |
g:<guid>, i:<id>, n |
document ID, GUID, next ID |
Full-text write path. Indexing a message (or a batch of an import)
first allocates its document id in one compare-and-swap on ftsmap
(ensureDocIDs), then updates the posting lists of every shard its
terms fall into, all shards at the same time, each in one
compare-and-swap: a read of the terms' lists with the object version
and a write guarded by it (casPostings). That read is also where lists
of GUIDs from before document IDs are found: the round then ends
without a write, the GUIDs get document IDs, and the next round
converts the lists to the ID encoding as it adds the message. Whether
the index is fresh — the condition of the version marker v below — is
decided inside the id map's compare-and-swap as well: an id map with n
is not fresh; only when n is missing (and the view has not seen ids
before) are the posting shards looked up, one stat each until the first
one found, and the round is repeated with the answer. A delivery into
an account with document IDs thus costs one read and one write on
ftsmap and one read and one write per posting shard it touches.
The previous paragraph describes layout 2; a layout 3 index keeps the same keys in fewer objects, see layout 3.
An omap compare-and-swap of the account's objects (casOmap: posting
shards, id map, registry, account, mailbox UID shards and the other omap
objects of the account; not the header changes of a mailbox — modseq,
flags — which go through casHeader, kind="header", without backoff;
the header object's recent mark, retirement and move intents go through
casOmap) that loses
against another writer (the object version changed between its read
and its write) waits at random 1 to 10 ms, times the number of rounds
it lost so far, before it reads again, so that writers of the same
object do not collide again in the next round; it gives up after 16
rounds. Every lost round counts in
imapceph_rados_cas_retries_total{kind="omap",class}, class being the
object class (fts, ftsmap, registry, mbox, account, ...).
shardpost bench measure --contention measures it.
A term that is new for the account costs one dictionary key per trigram
of its token (at most 38, a token has at most 40 characters). The keys
are set without reading the object and without a version check, because
setting a key twice changes nothing; what a delivery or an import batch
writes to the dictionary therefore depends on its new terms and not on
the size of the vocabulary. The 256 shards are fixed for good, like
those of ix/, and keep an object below the key count at which Ceph
warns for a dictionary of tens of millions of keys.
A query token of three or more characters is looked up by scanning the
key range <trigram>:<field>: of each of its trigrams, a page of 1024
keys of every range at a time, until the first range ends: every term
that contains the token is listed under each of its trigrams, so one
complete range, filtered for the token, is the whole answer. A shorter
token is looked up as a prefix in the posting shards.
The field h (every header name and value) has posting lists but no
dictionary keys. Most of its vocabulary occurs once — message IDs,
timestamps, queue IDs — and a substring index over it would grow with
the number of messages rather than with their words. In a word search
(the JMAP header filter, the admin text search, IMAP with
fts.imap_search: word) a token searched in h matches a header word
exactly, by its stem or as a prefix (a prefix lookup reads every
matching term, 64 per round trip and shard), and as a substring where
the word is in the subject or an address (s, f, t), which have
dictionary keys. Subject, addresses and body match by substring.
Up to 0.34.6 the dictionary was ftsd/<nn> (as many shards as fts/)
with one key per trigram and the newline-separated list of its terms as
the value, capped at 2000 terms. A value was read and rewritten whole
for every term added to it, which made the cost of a new term grow with
the vocabulary (report).
Nothing writes this layout any more. It is still read in addition to
the new one, so an account finds its older terms by substring until the
job fts_dict_convert has written the dictionary again from the posting
lists and deleted the old objects; fts_rebuild does the same from the
messages. A server older than 0.35.0 does not read ftst/: after a
downgrade, substring search needs an fts_rebuild.
fts_dict_convert is a legacy job since 0.46.0: it matters only for a
layout 2 index written before 0.35.0. A layout 3 index (below) always
has its dictionary, and a conversion to layout 3 derives it from the
posting lists itself, so planning fts_dict_convert for a layout 3
account is refused (conflict). A run that finds layout 3 or a
conversion under way ends as succeeded with skipped in its result: at
its start it writes nothing; when the conversion starts during the run,
it writes no further batch and deletes no old object (what it wrote
before is harmless, the conversion derives its own dictionary). It never
changes the layout record.
What is indexed is UTF-8 text, prepared by the fts package
(body.go, HeaderValue, HeaderText)
for the index, for IMAP SEARCH keys checked against the message and
for the cached preview, so they agree: header names and values
(unfolded, RFC 2047 words in any charset of the WHATWG list, raw 8-bit
text in fts.fallback_charset), every text/* part converted from its
charset (text/html reduced to its visible text, fts.html), the file
names of all parts and the header and text of attached messages, at
most 4 MiB of text per message. Details are in the
configuration.
A word of more than 40 characters has no term of its own
(query.go states and proves the rules,
TestLongWordWindows checks them). Its first 40 characters are a term
in every field, so every word start stays findable. In the fields with
the dictionary it also gets the 40-character windows that start at
every multiple of 20 and the window that ends with the word, at most
eight: every inner part of up to 21 characters lies in one of them.
Two marker terms stand for what the windows do not cover: <field>:~~
(a word of more than 40 characters, for inner parts of 22–40
characters) and <field>:~+ (a word longer than its eight windows,
180 characters). Neither can be a query token, and neither has
dictionary keys.
The header store ftsh/<nn> keeps HeaderText per message — the
decoded header lines that IMAP SEARCH checks header keys against —
compressed with DEFLATE, at most 64 KiB of text (a longer header is not
kept). It is written before the posting lists (delivery, APPEND, JMAP,
import, fts_rebuild; see layout 3 for why) and removed with them (expunge, purge, mailbox
deletion, the scrubber's orphans, DeleteFTS); keyed by GUID, it is not
touched by COPY or MOVE. Like the rest of the index it does not
count against the quota.
| Object | omap key | Value |
|---|---|---|
ftsh/<nn>, server.fts_shards of them, shard = fnv1a(GUID) |
GUID | format byte 1, DEFLATE of the header text |
ftsmap |
v, r |
index version marker 2; r (empty) while fts_rebuild runs |
IMAP SEARCH matches substrings (RFC 9051 6.4.4). The index cannot
answer that alone — stems, words in any order and the missing
dictionary of h make it find too much or too little — so by default
(fts.imap_search: substring) it supplies candidates and every
candidate is checked. SearchCandidates
(ftssearch.go) returns a
superset of the messages that contain the value, or nothing when it
cannot narrow. Each word of the value is looked up by how it can occur:
a word between two separators of the value as that term; the last word
as the start of a term, by a scan of the posting shards for keys with
that prefix (keys only, a page of 1024 per round trip, complete because
every term has its key in exactly one posting shard); the first word,
which may be the end of a longer word, through the dictionary. The last
two add the messages with the marker the word needs. A word does not
narrow when it cannot be looked up completely: an inner part in h, of
fewer than three characters, a lookup of more than 4096 terms, an
account still on the dictionary layout before 0.35.0.
The version marker v in ftsmap says that every message of the
account was indexed by this version. A fresh index gets it with its
first document ID — only when the id map has no n yet, no rebuild is
running and no posting shard exists (an index from before document IDs
has posting lists of GUIDs and no n). fts_rebuild sets r to a token of its run right
after deleting the old index and, when every message is indexed again,
sets v and removes r in one compare-and-swap — only if r still
holds its own token, so a rebuild that another one replaced does not
mark the index that one is still writing. A rebuild that stops midway,
or a delivery into the index it is rebuilding, leaves no trusted
marker. Without v, or with r, SearchCandidates does not
narrow at all — an older index (markup, undecoded words, no file
names) cannot be trusted even for whole words — and every message is
checked: correct, but slow, until the rebuild.
The protocol layer (searchtext.go)
asks for candidates separately for the header and the body of every
text key, wherever it is in the tree, and combines them: AND
intersects, OR unites, NOT does not narrow. It checks the
candidates fts.verify_parallel at a time: header keys against the
header store (one omap read per shard for 256 messages; a message
without a stored header has the first 16 KiB to 1 MiB of its blob
read), the body by reading the message under the memory budget, only
where the body candidates include it. The cost of a search is
therefore index lookups, header-store reads for the messages a header
key cannot narrow — at worst every message of the mailbox, as for a
TEXT value that may begin inside a header word — and message reads
for the messages whose body may match. A key the index cannot narrow at
all (BODY ab) reads every message. With fts.imap_search: word the
index answers SUBJECT, BODY, TEXT and HEADER Subject alone, as
earlier versions did. JMAP and the admin text search always use the
index alone.
Full-text index layout 3
Layout 2 — the objects described in the previous section, the only layout before 0.45.0 — has a fixed set of objects per account: 16 posting shards, 256 dictionary shards, 16 header shards. A delivery touches nearly all of them: 245.6 RADOS operations in the R4 measurement, 225 of them for the index (report). Layout 3 keeps the same keys and values in an index generation whose object counts follow the size of the account (ftslayout.go). Searches give the same results in both layouts; only object names and counts differ.
| Object | omap key | Value |
|---|---|---|
ftx/<gen>/p<i>, S of them, shard = fnv1a(term) mod S |
<field>:<token> |
posting list (as in layout 2) |
!g, !s, !n, !dn |
generation, state (live or frozen:<tok>), terms, dictionary keys of the terms |
|
ftx/<gen>/d<j>, D of them, shard = fnv1a(trigram) mod D |
<trigram>:<field>:<token> |
empty |
#<tri0>:b:<token> |
empty; the start entry of a body identifier in a generation with the identifier filter (0.50.0), in the object of the token's first trigram <tri0> (below) |
|
!g |
generation | |
ftx/<gen>/h<k>, H of them, shard = fnv1a(GUID) mod H |
GUID | header text (format byte 1, DEFLATE) |
!g |
generation | |
ftsmap |
L |
the layout record (JSON) |
V, c |
version marker of layout 3 (instead of v: 2, since 0.50.0 also 3 and 4, below), number of documents |
<gen> is a random token of 16 hex digits. Keys that start with ! are
markers: every full enumeration skips them (a conversion, the
dictionary conversion), and no prefix scan can reach them. A version
before 0.45.0 reading a layout 3 account finds no v and narrows
nothing (IMAP search is slow but right).
The layout record L names the current generation (f 3, g,
s, d, h), a conversion in progress (next: target generation and
the token of its run, for the import reshard also its heartbeat at),
sources a conversion has not removed yet (old, each with the token of
its run and, for the import reshard, the heartbeat at it carries over
from next at the switch) and whether the layout
2 objects are gone (lg). Since 0.50.0 a generation (current, next,
old) may carry the identifier filter of its dictionary (dx:
{"v":1,"n":25}) and its text preparation (ah: nosig); both are
fixed for the life of the generation. Without L the account has
layout 2.
Writers read L in the id map's compare-and-swap of every indexing (no
extra read); removals read it with the document id; readers cache it
per view, with the trust markers, and check the markers of every object
they read in the same operation.
Creating an index. A fresh index (no n, no L, no layout 2
posting object) gets layout 3 when fts.layout is 3 (the default since
0.46.0) and the cluster mark fts/layout3 exists in the admin store,
read at that moment without a cache; otherwise it gets layout 2. The
server logs the layout new indexes get at startup and whenever a read
of the mark differs from the one before, and keeps it in the gauge
imapceph_fts_layout_effective. The account is entered in the presence index
ix.fts_layout (in ~accounts; account → since) first, then the S +
D + H objects are created exclusively with their markers, then the
compare-and-swap that allocates the first document ids writes L, V
and c. A writer that loses that race to another creator deletes its
objects and follows the record that won; the presence entry belongs to
the account and stays. An account converted to layout 3 while it had no
documents gets V with its first document ids (no n and no rebuild
under way), so its substring searches narrow from the start. Objects of
a generation come into being only
there, in fts_rebuild (which decides the layout the same way when it
starts) and in a conversion. A fresh index has S = D = H =
fts.shards_min (1), or, when an import or a rebuild knows how many
messages it brings, the counts the bench corpus estimates for that many
messages.
Writing. An indexing writes the header texts first (in both layouts:
a conversion that freezes the posting objects afterwards finds them in
the source it copies), then one compare-and-swap per posting object —
reading the terms of a page (1020 terms and the four markers) and
writing the lists and the counters — then the dictionary keys of the
new terms blindly, asserting that the object exists. The new terms of
every attempt in a generation count, also of one that ended at an
anomaly after some objects were written: a retry finds those terms
existing and would not name them again. A removal's retry that reads a
list an earlier attempt emptied as non-empty again (a delivery during
the frozen wait) drops that term from the keys it removes. An anomaly — a frozen object,
a missing object, a foreign !g, in layout 2 a missing posting object
while L exists — ends the attempt; the writer reads L and
- repeats the whole indexing in the generation
Lnames when it is another one (the same document ids; nothing more goes into the old generation); - waits, once per indexing, for a conversion that froze the objects
(
fts.frozen_wait_seconds) and then goes on in the new generation, or fails with a temporary error (LMTP 451) — also for a freeze of another run while a conversion is named, because lifting it would write into a source being copied; - lifts a freeze that no conversion names any more in its own versioned write;
- fails on a missing object of the named generation (the scrub reports
it,
fts_rebuildrepairs it); a layout 3 object is never created by a writer.
A layout 2 index without L is written with the operations of 0.44.0:
a missing posting object is created in the writer's compare-and-swap
(the L it read with its ids says no conversion runs). That is safe
because a conversion takes the account lock — which refuses new
logins, deliveries and imports, but does not end the IMAP, POP3 or JMAP
sessions already open — and waits jobs.settle_seconds before it
records L; only a writer that sleeps through a whole conversion could
create an object of the removed layout 2 objects afterwards, which the
scrub reports (fts_mixed_layout) and the purge and the sweep remove.
With L present a missing layout 2 posting object is an anomaly as
above. A reader of layout 2 that finds a posting object missing reads
L once per search, also when its cached view had none: an idle
session keeps that view across a whole conversion, and without the
read its next search would take the removed objects for empty. A
missing dictionary or header object of layout 2 is empty, as before.
One indexing follows at most two generation changes and waits at most once; the posting goroutines report a freeze and the indexing waits centrally. A dictionary write that finds its object missing in another generation is ignored: the terms are in the source the conversion derives its dictionary from.
Removing. The removal reads the document id and L in one read,
removes the header text, takes the document out of the posting lists
(the anomaly rule as above; in another generation the removal is
repeated there) and removes the dictionary keys of the terms that became
empty. The document id is freed in any case, also when the blob cannot
be read or an object is frozen: a list that still holds it holds a dead
id, which searches resolve to nothing and conversions and rebuilds leave
out. The undo of a delivery, the upload expiry and the scrubber's
orphans do not wait for a frozen index.
Reading. A search reads over the generation the view cached. A
missing object or a foreign !g makes it read L again: in another
generation it starts over (at most twice), in the same one it fails
(IMAP NO [UNAVAILABLE], JMAP serverFail). A frozen object of the generation L
still names is read on — its state is complete. The dictionary ranges
of the trigrams that fall into one object are read in one operation per
round, so with D = 1 a substring lookup costs one read per round.
What it costs. With S = D = H = 1 a delivery costs about 21
operations (TestHotPathOps on the test cluster: 10.3 reads and 10.4
writes per LMTP delivery, a view per message; 13.3 and 13.4 with S = D
= H = 4); the full-text part is 2 S + D + 1. An expunge costs about 7
operations of the index plus the blob read. A small account has 4
full-text objects instead of 284; the keys are the same.
Growth and reshard. Posting and header objects have the target
T_p = fts.shard_target_keys and the cap fts.shards_max (128),
dictionary objects the target T_d = fts.dict_target_keys and the cap
fts.dict_shards_max (512): the dictionary has about five times as many
keys as the postings (280 against 57 per message on the largest account
measured), so one cap for all three either starves the dictionary or
lets the postings fan out (0.49.0; before, fts.shards_max 32 applied
to all three). A posting compare-and-swap that finds more than
1.5 × T_p (100 000) terms in its object while S is below its cap, or
more than 1.5 × T_d dictionary keys per dictionary object (!dn · S /
D) while D is below its cap, and an id map compare-and-swap with more
than 1.5 T_p documents per header object while H is below its cap,
queue the account in ix.fts_due (in ~accounts; id the account,
generation the FNV-64 of the index generation, due now); a server
queues a generation at most once per ten minutes. At a cap the trigger
is silent: no queue entry, no job and no account lock; instead the
writer counts imapceph_fts_shards_capped_total{kind} and logs a
warning, once per generation, kind and server (a memory of 65 536
generations without expiry, oldest out first). The worker
fts_due runs with the job system on the server that holds its lease
(fts.auto_reshard) and owns the entries: an entry of a purged account
or of a generation that was replaced goes; a locked account and a job
that has not ended postpone it (5 minutes, doubling up to 6 hours);
otherwise it plans and applies fts_layout {"due_only": true} and
remembers the job in the admin record fts_due/<account> (deleted with
the account). A job that switched removes entry and record; one that
found nothing to do (the per-object trigger and the target from the sums
can disagree) leaves the entry six hours alone, and when it fires after
them, the entry goes and the record (exactly that job's, changed first)
turns idle, keeping the number of no-ops of the generation. A writer
whose trigger still asks for a reshard then queues the account afresh,
and the worker plans a new job; after every further no-op of the same
generation the wait doubles, up to seven days (a skewed account below
its cap, whose busiest object outgrew its target while the sums ask for
the shape it has, takes the account lock less and less often). Before 0.49.0 the entry
was moved to the same past time on every look of the worker, once a
minute and for good, and the account was never planned again — a cap
raised later (or real growth) needed a job by hand. The targets are
S′ = 2^⌈log2(Σ!n / T_p)⌉ and H′ from c, each at most
fts.shards_max, and D′ = 2^⌈log2(Σ!dn / T_d)⌉, at most
fts.dict_shards_max; all at least fts.shards_min. FTSLayoutDue
(the plan of fts_layout, plan -due-only, the import reshard) uses
the same caps and names the kinds a cap cut (capped in the plan's
target); it counts nothing. Beyond 128 × T_p, 12.8 million terms, and
512 × T_d, 51.2 million dictionary keys per account with the defaults,
the objects grow past their targets; Ceph reports large omap objects
from 200 000 keys per object
(osd_deep_scrub_large_omap_object_key_threshold). A fresh index is
sized from the import hint (or, for a rebuild, the documents) with
estimateKeys: linear between calibration points of the bench corpus
up to 5000 messages (about 85 dictionary keys per message, a corpus
without identifiers) and the largest account measured (36 246 messages,
2 069 828 terms, 10 138 532 dictionary keys), beyond it with the slope
of that last section (about 63.5 terms and 311 dictionary keys per
message); between 5000 and 36 246 messages it is a bridge, not a
measurement.
A posting compare-and-swap sees the counters of one object only, so
the trigger decides per object (!n > 1.5·T_p, !dn · S / D >
1.5·T_d), while the plan of fts_layout (plan -due-only, the worker's
job, the import reshard) decides by the sums (D′ = 2^⌈log2(Σ!dn /
T_d)⌉). For an evenly spread index the sums ask for more objects about
1.5 times earlier than the trigger fires (Σ!dn above D·T_d against
1.5·D·T_d); a skewed one can make the trigger fire while the sums still
ask for the shape it has (a no-op job, see above). A manual plan -due-only may therefore reshard an account before its writers would
have queued it. The trigger stays per object on purpose: computing it
against the target of the sums would need S reads per write.
What the caps do not change. More dictionary objects keep each of
them below Ceph's threshold, but not the sum. At about 280 dictionary
keys per message and an estimated 50 to 60 bytes of omap each (the
index pool holds about 65 bytes per key on average, postings and
header texts included), a dictionary costs 14 to 17 KB of NVMe per
message; 10¹⁰ messages, an upper estimate for a provider with a million
accounts, would be about 2.8·10¹² keys and 140 to 170 TB before
replication. The identifiers — new in every message — are most of it;
the bench corpus ids and shardpostctl fts stats -classes on test
accounts measure how much, and a later stage may give them one key each
instead of one per trigram — the identifier filter of 0.50.0 does
(below). The next limit of large
accounts is the fan-out of a delivery: with about 650 terms it touches
nearly every posting object, 2·S operations.
Conversion. The job fts_layout (role repair, exclusive, also as
a batch) converts between the layouts and reshards a layout 3 index. A
run identifies itself by a token, job-<id>/<lease generation>, and
holds the account lock (deliveries fail temporarily for the whole job):
| Phase | |
|---|---|
| P2 | fence; L.next = target generation and token. A next of exactly this token is continued; any other is foreign — also one of the same job with another lease generation, which may still be writing — its targets are deleted and it is replaced. Sources a crashed run left in old are deleted. 2→3 enters the presence index first. |
| P3 | !s = frozen:<tok> on every source posting object (a missing layout 2 one is created frozen). |
| P5 | the targets are created afresh; the sources are read page by page, markers and dead ids left out, lists of GUIDs from before document ids converted; the dictionary is derived from the terms by the target's identifier filter, the header texts of live documents copied; written blindly in pieces of at most 2048 keys asserting the objects exist, fenced before each; then the counters. Since 0.49.0 the copy hands every full piece to fts.convert_writers (8) writers and goes on reading; its buffer holds at most max(fts.convert_buffer_keys, 1024 × target objects) keys, and above it the largest pending piece is written early (imapceph_fts_convert_buffer_keys). The result counts the writes (writes) and the largest buffer (buffer_keys). |
| P6 | fence; L names the target (only while next carries exactly this token), the source goes to old; the target gets the version marker of its filter and text preparation in V when the source's (v for 2→3, V for 3→3) was one this version trusts, else none, and 2→3 removes v; 3→2 removes L, writes v = 2 (4 for the text preparation nosig) for a trusted source, removes V and takes the account out of the presence index. Before 0.50.0, 2→3 moved v to V and 3→2 moved V back to v as they were. |
| P7 | fence; the sources are deleted (only while old names this run), then the old entry; 2→3 sets lg. |
Between P6 and P7 the sources are frozen while L already names the
target: writers with an older L meet the freeze, read L and write
into the target. A run that fails or is cancelled clears its
conversion itself (ClearFTSLayoutNext: the record's next first, then
the freezes of exactly its token — never another run's — then its
targets); after a lost lease it leaves it, and the server that takes
over replaces it. The runner clears it as well after it stored the
final state of a single-account job, the cancel of a job that ended
does, anyone who takes the account lock (an exclusive job, a batch per
account, OpenImport) clears any foreign one, and the scrub job clears
one whose job is not running any more or whose import heartbeat is
older than 90 seconds (also shardpostctl fts layout clear); the same
rule applies to the old entries a run left after its switch.
During an import. The account lock of an import keeps fts_layout
jobs out until the import ends, so a large import would run on the
objects of a fresh index. shardpostctl migrate tells the target how
many messages it brings (the hint sizes a fresh index) and after every
batch calls POST /admin/v1/accounts/{account}/import/fts-reshard
under its session: the endpoint reads the counters (S + D + H + 1
reads) and, when they ask for more objects, reshards under the import
session with the token <session>/<nonce>. The run waits
jobs.settle_seconds, renews the import lock and its heartbeat at in
L.next (after the switch in its L.old entry, until P7) every 30
seconds, and counts as fenced while the last renewal
worked and its context lives. The request waits up to
fts.import_reshard_sync_seconds and then answers 202; the session's
batches answer 409 while the reshard runs. The runs of a server are
kept in a map per account (one run per session, started under one
mutex); a GET on another server falls back to L.next. The background
run has a view of its own and stops, clearing its conversion, before the
store closes at shutdown.
Purge, sweep, scrub. The data model names every generation of L
(current, next, old) before the id map, and the layout 2 objects in
any case, also with lg: a layout 2 writer that slept through the
conversion may have left one, and deleting absent objects is cheap (one
more read per account for L). A purge
removes the account from the presence index before its registry; an
unnamed generation (a creation that lost its race and crashed) is a
remnant for the sweep. The scrub reports a missing object of the current
generation and layout 2 objects in an account with lg (a stat of
fts/00, ftsh/00 and ftst/000: fts_mixed_layout), both repaired
by fts_rebuild, and keeps the
presence index in step. The full-text objects do not count against the
quota, in neither layout.
Mixed versions and the way back. Every server since 0.45.0
registers itself (servers/<instance> in the admin store, at startup
and every minute, with its version and the capability fts_layout3;
the hint index ix.servers carries version and capabilities only, so a
heartbeat does not write it; a registration not renewed for 24 hours is
deleted). While a server before 0.45.0 holds the lease of the expiry
worker, it removes the jobs/expiry entries of the registrations it
does not know; those records then expire only through status and
enable, which delete registrations older than 24 hours. Older servers
do not register and cannot read layout 3, so
the cluster mark is set by shardpostctl fts layout3 enable --pods n
only when exactly n fresh registrations report the capability
(--force skips that, audited). Without the mark no layout 3 index is
created whatever fts.layout says; this is what makes the default 3
safe for a cluster that has not switched. The way back:
fts.layout: 2on every server (explicitly: the default is3);shardpostctl plan -all -layout 2 fts_layoutand apply it;shardpostctl fts layout3 disable— it removes the mark, waits five seconds and succeeds only when the presence index is empty;- roll the image back.
In an emergency (an older server already runs): export
shardpostctl fts layout3 list, run fts_rebuild for those accounts
under the older version, and the sweep later under 0.45.0 or newer.
The separate caps (0.49.0) and older versions. The shape of a
generation is in L, so a version before 0.49.0 reads and writes a
generation of any D. But it computes the target D with
fts.shards_max and its trigger has no cap: a generation whose D is
above that version's fts.shards_max counts as due for its due_only
jobs (sameShape compares the counts only) and would be shrunk. While
such a version holds the fts_due worker (a rolling update) and on the
way back, either fts.auto_reshard: false on every pod or
fts.shards_max at least the largest D in the store (shardpostctl fts layout3 list -verify prints it per page) prevents it; see
operations.
Identifier filter and text preparation (0.50.0)
Identifiers — tracking tokens, hashes, base64 runs, the DKIM and ARC signatures of attached headers — are long runs of letters and digits that are new in every message. Each costs the substring dictionary one key per distinct trigram (a 64-character hash 114 keys over its three windows), so the dictionary of a large account grows with them linearly: about 280 keys per message on the largest account measured, 85 on the bench corpus without identifiers (plan). Two settings make it smaller. Both are off by default; with the defaults every key, marker and search result is the one of 0.49.0.
The identifier filter (fts.dict_filter: ids,
ftswrite.go dictEntriesAt). A
body term (field b) whose token has at least one letter, at least one
digit and at least N runes (fts.dict_id_min_runes, 13 to 40, default
25; letters and digits as the tokenizer sees them, fts.IsIdentifier)
gets one start entry #<tri0>:b:<token> in the object of its first
trigram <tri0> (its first three runes) instead of a key per
trigram. The window terms of long words are terms like any other: a
window with a digit is an identifier. Postings, windows and the markers
~~ and ~+ do not change, nor do the fields s, f, t (their IMAP
keys keep the substring rule) and h (no dictionary). # sorts before
every trigram (letters and digits, from 0x30 on), starts no trigram and
is no marker, so no trigram range reaches a start entry. The rule
depends on the term and N alone: writers, removals, the conversion, the
counter !dn (one key for an identifier) and its trigger all derive the
same keys, and dictEntriesAt is the only place of the write path the
filter changes.
A substring search reads, for a body word q of at least three runes,
besides the trigram ranges the start entries that begin with q — the
range #<tri0(q)>:b:<q>, an exact prefix — in the same object and the
same read as the range of q's first trigram, so it costs no operation
of its own. The answer is there when a trigram range and the start range
are both read to their end; it is their union, and maxCandidates
(4096) counts both together: more and the word does not narrow. The
reader does not need to know whether the generation has the filter:
without it the start range is empty. An identifier is thus found as a
whole word and by its start (and a long one by the start of each
window), an inner piece of it is not; see
protocols for what this means for
BODY, TEXT, JMAP and the word mode.
The text preparation (fts.attached_headers: nosig,
attached.go). The body text has the
header of every attached message (message/rfc822, message/global)
as "Name: value" lines and the text of a text/rfc822-headers part (a
bounce) as it is. With nosig the signature and trace fields of both —
DKIM-Signature, ARC-Seal, ARC-Message-Signature,
ARC-Authentication-Results, Authentication-Results, Received,
Received-SPF, X-Google-DKIM-Signature and every X-*-Signature (a
denylist, the fields the bench corpus ids,nosig leaves out) — are
left out, with their continuation lines; every other field and the
header of the message itself stay. Unlike the filter this changes the
indexed text and so the postings: the same function prepares the text
of the index and the text IMAP checks each candidate against, so the
server's BODY/TEXT semantics follow the setting at once, but an
index has the words of the preparation it was built with.
A property of the generation. Both settings take effect only
through a new generation: a fresh index, fts_rebuild, and — for the
filter — fts_layout; never by the configuration alone. The generation
records them in L (dx, ah, also in next and old), and
writers, removals and conversions take them from there. A writer
tokenizes a message in the text preparation of the generation it
writes to, again when it follows a conversion into another; a removal
tokenizes the blob the same way. New generations get the settings only
while the cluster mark fts/dictfilter is set (shardpostctl fts dictfilter enable --pods n, the servers since 0.50.0 register the
capability fts_dict_filter); it is read without a cache wherever a
generation is created, and without either setting it is not read. The
account's entry in the presence index ix.fts_layout carries props
while a generation of the account, current or under way, has either
property: set before such a generation is created (a fresh index, a
rebuild, P2 of a conversion), cleared after the account switched to a
generation without (the first ids of a fresh index, P6, the clear of
a conversion that did not happen; 3→2 removes the entry). The scrub
keeps it in step with L (a props it clears must be older than its
grace: it may belong to a generation being created).
shardpostctl fts dictfilter disable therefore reads the presence
index only.
Changing an existing index.
- Filter:
fts_layoutwithdict_filteridsornone(or, by the configuration and the mark,plan -all -due-only fts_layout:due_onlyis due when the filter differs). P5 copies the postings and derives the dictionary from them by the target's filter, so both directions are exact: after the way back a generation is the one that was never filtered, also for the messages delivered meanwhile. The dictionary keys of the target are estimated before P5 from a sample: the posting keys of max(1, S/8) objects (the plan and the import reshard's check: one object) give the factor by which the keys change, applied to Σ!dn(logged, no term); P5 writes the exact counters. The client library reads no keys without their values, so a sampled object costs its whole omap.dict_filter: idson a layout 2 index without a layout 3 target is refused. The workerfts_duedoes not plan because of the filter; a job it plans for the size takes the configured filter along. - Text preparation:
fts_rebuild, which reads every message again (fts_layoutkeeps the source's preparation: it copies the postings). The way back fromnosigis a rebuild withall, exact as well. During a rebuild IMAP reads the messages instead of the index (slow, right).
Version markers. V (layout 2: v) says which version may trust
the index:
| Marker | Generation | Trusted by |
|---|---|---|
2 |
no filter, every attached header field | every version since 0.45.0 (0.35.0 for v) |
3 |
identifier filter, every field | 0.50.0 and later |
4 |
text preparation nosig (with or without the filter) |
0.50.0 and later, and only by a server with fts.attached_headers: nosig |
A version before 0.50.0 trusts only 2: in a generation with 3 or
4 IMAP narrows nothing there (it reads every message — slow, but
right), and JMAP and the word mode find identifiers only as whole
words. A server of 0.50.0 trusts 3 without condition — the filter is
part of the index's search semantics, whatever its own
fts.dict_filter says — but an index of nosig only with nosig
itself: otherwise it lacks words the IMAP check looks for, and IMAP
reads the messages. A fresh index and a rebuild set the marker of their
generation, a conversion the one of its target (when the source's was
trusted), 3→2 writes 2 (4 for nosig), and a rebuild into layout
2 always 2: layout 2 has neither filter nor text preparation of its
own and indexes with the default. The text preparation counts as
nosig when L or the marker says so: a version before 0.50.0 that
rewrites L drops dx and ah, but leaves the marker.
Mixed versions. Without the mark nothing changes. With it every
server is 0.50.0 or later (the registrations say so when it is set). A
writer of an older version in a filtered generation writes trigram keys
(a superset); an older removal leaves a start entry behind (a lookup
that finds no postings); the next fts_layout derives the dictionary
anew. An older version that reshards a filtered generation derives the
whole dictionary but keeps V = 3 and drops dx from L; one that
converts it back to layout 2 copies 3 into v. Both are exact
dictionaries: this version trusts them, and the scrub reports the
marker (fts_version_drift) and sets it to 2. A marker 4 whose L
lost ah the same way is only reported: readers take nosig from the
marker, and the next fts_layout writes L anew. The other way round,
an older writer that gives a filtered or nosig generation its first
document ids while it is empty (a conversion of an empty account)
writes V = 2, which an older reader would trust although
identifiers lack their trigram keys; the scrub reports it as
fts_version_drift and sets the marker of the generation (3 or 4;
the older writers wrote a superset since). The way back is in
operations.
Online runs (0.51.0)
An offline fts_layout or fts_rebuild holds the account lock for the
whole job: deliveries get a temporary failure, logins are refused and
running IMAP and POP3 sessions end within five seconds. With the cluster
mark fts/online both run online, without the lock
(ftsonline.go): users notice at
most a search that starts over once at the switch.
Recording through the id map. Writers keep writing into the current
generation only. While L.next carries on, the compare-and-swap of the
id map that every indexing does anyway (it allocates the document ids
and reads L) also records the indexed GUIDs for the run, in the same
write:
Key in ftsmap |
Value |
|---|---|
w:<guid> |
<tok>:<id>: the run and the id the GUID had (a GUID indexed again with a new id is recorded again) |
w# |
<tok>:<n>: the writes of w: keys for the run (starts again for another token) |
L.next gets three fields besides the target: on, m (copy from
the postings, a reshard or a change of the identifier filter;
reindex from the messages, a rebuild or a change of the text
preparation) and hold (Unix milliseconds, see below). L itself does
not change in the id allocation.
The soft freeze. The run freezes the source posting objects as an
offline run does (!s = frozen:<tok>), but for writers of 0.51.0 the
freeze is soft: an operation that meets it reads L once; if the
generation is still current and next is an online run it is recorded
for, it writes through every freeze of that run (and the one it met)
for the rest of the operation — one anomaly round per operation, never
the frozen wait. An indexing that allocated its ids before the run
began (it read L before next existed) records itself first
(registerBuild, one compare-and-swap). The state is bound to the
run's token: a freeze of any other run is an anomaly again, so an
operation that wrote through run A's freeze never writes through the
hard freeze of an offline run B that replaced A. A removal records
nothing: it removes from the current generation, and the target keeps
at most a dead id, which every search filters. Readers read on through
a frozen object of the current generation after one look at L per
search.
The run.
| Phase | |
|---|---|
| O1 | without a lock: the mark (refused without it); the account locked by an import or another job, or another run alive (its job runs, or an import heartbeat is fresh) → busy, no change; the next and old of runs that ended are cleared (with their w: keys); the target and the mode; the window (a run the worker or a batch planned starts only when its estimate fits); an account without a layout 3 index → unsupported |
| O2 | fence; the targets are created, then L.next = {target, tok, on, m} while next is still empty. From here on writers record. The import lock is read again: an import that locked meanwhile wins (the run's next goes, busy) |
| O3 | the soft freeze of every source posting object, each after a look at L (a run that was taken over stops before it overwrites the freeze of its successor) |
| O4 | the snapshot of the id map (after O3: a writer of an older version whose postings came before the freeze is in it); for reindex also the mailboxes, listed a page at a time and kept only as GUID → size and chunks |
| O5 | copy: the copy of P5 into the targets, which no one else writes; reindex: every live message of the snapshot from its blob, with the target's text preparation and the snapshot's ids, then the messages of the mailboxes that have no id (through the writers, so they are recorded). The run writes with a fixed write: every anomaly in the target (a missing object, another generation) means that another run took over, and the run stops (errConversionLost) — it never follows L into the current generation, waits for a freeze or queues a reshard |
| O6 | catch-up rounds: read w#, then every w: key of the run, and index the pairs not done yet from their blobs into the target. Once, before the first switch, the whole i: range of the id map is compared with the snapshot: live documents that are neither in it nor recorded (a writer of an older version, a message without terms) are indexed too |
| O7 | fence; the switch, one compare-and-swap of the id map: only while next carries the token and on, and w# is what the last round read before its listing. L names the target, the source goes to old; c stays. copy sets the target's version marker when the source's was trusted (as P6); reindex sets it in any case and removes the rebuild key r — an online rebuild heals an index an offline rebuild left midway — unless it skipped an unreadable message (skip_unreadable): then it removes the marker and r stays, so that no index is trusted that misses a mail (IMAP reads the messages until a complete rebuild). Every read of a blob is tried five times with a growing pause; a message of a mailbox without an id whose blob is gone was expunged meanwhile |
| O8 | fence; the sources go, then the run's w: keys and w# (each page in a compare-and-swap with L that keeps the keys of the run next names), then the old entry |
Crash hooks follow every phase. The run of a job never continues
across lease generations: the job that takes over finds a next of
the same job with another token, clears it and starts again.
Why the target is complete. Every live document's id allocation
lies before O2, between O2 and O7, or after O7 (the id map serialises
it with both). After O7 the document goes into the target directly.
Between O2 and O7 the allocation recorded it and counted w#; O7
succeeds only when w# equals what the last catch-up round read before
its listing, so the round indexed it from its blob (the blob is written
before the ids). Before O2 the document is in the snapshot: reindex
indexes it from its blob; copy copies each posting it wrote before
the freeze of that object, and a posting it writes after the freeze
meets the soft freeze, whose writer records itself (and is caught up
from the blob) — or, when L names the target already, writes there.
A GUID recorded with id 1 and then again with id 2 (removed and
indexed again) leaves only <tok>:2: id 1 is dead then, and the count
still matches. Search results after the switch are those of an offline
run over the same live messages, plus dead ids that are filtered.
Convergence and the hold. Each round indexes the deliveries of the
previous one. When the switch still finds new records after
fts.build_catchup_rounds (5), the run sets next.hold = now +
fts.build_hold_ms (2 s, at most 5 s). An id allocation that would
write sees the hold, ends its round without a write, sleeps once until
the hold ends (outside the compare-and-swap) and tries again; a
delivery is that much slower, never refused. The run catches up once
more and switches. A switch that fails under the hold lifts it; after
three holds the run fails without a change (metric, log) and the
worker plans it again.
Costs. A delivery during a run costs no operation more (two keys
more in the compare-and-swap it does anyway); an operation that meets
the freeze one more read of L, one that began before O2 one
compare-and-swap more. The run reads the messages delivered during it
once more. Until O8 the account has two generations (the space of the
target). A search during the run reads L once more.
What it leaves when it stops. A run that fails or is cancelled
clears its next as an offline run does; ClearFTSLayoutNext also
removes the run's w: keys and w#. The runner clears it after the
final state of an online job as well — also after a panic, also for
fts_rebuild (R2.5); a batch clears it for an account whose run
failed. Anyone who takes the account lock clears a foreign online
next as any other; the online run then fails at its next look at L
or at its switch. Keys a crash leaves are harmless (a later run counts
only its own token) and the scrub removes them (fts_build_leftover,
repair class uncovered: only keys whose token is not the one of
L.next in the state it read, each page in a compare-and-swap with
L).
Mixed versions. The capability fts_online_build is in every
registration of 0.51.0, and shardpostctl fts online enable --pods n
sets the mark only when exactly n fresh servers report it. A server of
an older version does not know on: for it the freeze is hard (it waits
fts.frozen_wait_seconds, then a temporary failure, or after O7 it
follows L into the target), so it loses no posting; and its id
allocation records nothing — its postings before the freeze are in the
snapshot, a message of it without postings is caught by the one
comparison of the i: range in O6 (one after that comparison would be
missing from the target, but it has no term to be found by in the
source either). An older version that rewrites L drops on; the run
notices it at its switch and stops. An older runner that claims an
online plan ignores online and runs the job offline under the lock.
Without the mark everything is as in 0.50.0.
What stays offline. Conversions between layout 2 and 3, the import
reshard (under the import session), and the repairs that need a new id
map: fts_rebuild offline for a missing object of the current
generation, an unreadable id map or L, or a cluster without the mark.
Online quota recount and the mailbox lock (0.52.0)
Two more jobs run without the account lock since 0.52.0 (design:
temp/16; code: quotacapture.go,
quotaonline.go,
mailboxlock.go). Both rest on
casOmapPrefix (casprefix.go):
a compare-and-swap whose read covers keys and the first page of prefix
ranges in one op under one version, so that the run key, the mailbox
locks and the scrub marks are read at no extra cost by the CASes that
already run.
Registry keys and record fields
| Key or field | Value | Writer |
|---|---|---|
qr |
{tok, h, t0, exp, hold}: the online recount; h is the first 16 hex digits of sha256(tok) |
the run (start, heartbeat, hold); removed by the switch or a clean-up |
qd# |
{tok, n, ne, pb, pm}: the number of qd: keys and the entries in them, the net arrivals (bytes, messages) |
every usage CAS while qr stands |
qd:<n> |
{h, m, e: [[uid, size, tagged], …]}: the entries one release took out of mailbox m (at most quota.recount_release_chunk per key) |
EXPUNGE, MOVE out of a counted mailbox, the MOVE gate |
qm:<id> |
{h, t: [[uid, size], …]}: the tagged entries a mailbox deletion released |
the release CAS of a deletion |
qi:<id> |
{t, k, m, n, b}: the intent of a COPY, a MOVE out of an ignored mailbox or a rename across the quota rules (k) |
the reservation CAS; the rename CAS |
sr:<holder> |
{holder, exp}: a scrub that repairs entries or counters |
the scrub before its first such repair, renewed with its lease, removed at the end |
mblock:<session> |
{ids, names, exp, phase}: the mailboxes a mailbox_delete locks; phase locked, unregistering, destroying |
the job (M1, M5, M6, M7) |
m:<name> → lock |
{s, exp}: the lock of one mailbox |
the job; every rewrite of the record carries it over |
bi:<guid> → m |
the mailbox id of the delivery | the reservation CAS |
dm:<id> → q |
0 while the usage is still in the counters, 1 once released | the unregistration (0), the release CAS (1); keepTombstone and the renewal of M7 carry it over |
m:<uid> → qt |
the h of the run whose reservation the entry comes from; stays with the entry |
link after a captured reservation, the MOVE gate; cleared by relink |
r:<uid> → d, n, f |
removed by the writer of the key (the header CAS for shard 0, the commit of the pending change for higher shards), the nonce of that removal, the usage released (or nothing to release) | unlink; the flag write after the release CAS |
Every value and field is optional on the read side: a server of 0.51 reads none of them and writes values without them, which the run and the scrub take as "no capture", "not removed", "not released".
The recount
Arrivals count as a sum only: the entries that come out of a captured reservation carry the run's tag, and the listing skips them. Departures are captured with (mailbox, UID) and deduplicated. A MOVE between two counted mailboxes, which changes no counter, passes a gate after its intent (one read; a CAS while a run is live) that captures the batch as arrivals and its source entries as departures. The run
- starts with one CAS (
qr, an emptyqd#, the rests of an earlier run removed) that isbusywhile an import lock, a mailbox lock beforedestroying, a scrub mark, an import step or another live run stands, and takes a snapshot of thebi:/qi:intents; - waits until those intents are gone (or dead) and, per mailbox, until the MOVE intents found at the first read of the mailbox are gone (or dead) — later ones belong to MOVEs whose gate saw the run;
- lists each counted mailbox: the header (UIDNEXT, HIGHESTMODSEQ), the
higher shards from the top (entries at or above UIDNEXT dropped), and
shard 0 last with its entries, its
r:keys and the live pending change records frompendingScanStart. An entry not seen on its shard counts when itsr:hasdand notf; one with anr:withoutdthat a live pending record names is read again until it hasd, the record is committed or expired; - folds the captures and switches in one CAS:
q:bytes/q:msgs:= counted +pb− tagged departures (a listed mailbox that got aqm:contributes nothing),qr,qd#,qd:*,qm:*removed; captures of anotherhare foreign leftovers and go with them. The check that every listed mailbox is still counted as it was (a rename across the rules) ends the runlostotherwise.
The order of the release moved for all paths, with and without a run:
unlink → the usage release CAS → one write that sets f on the
removal's own r: keys → the blob references → the r: keys, as before.
The write order and the life of r: stay exactly what the scrub's blob
safety rests on (scanIndex reads r: after the entries, a young r:
keeps fixRefs off the blob); the extra information is in the value.
entriesToRelease still leaves out every entry with an r:, so a
deletion never releases what another session is releasing.
The mailbox lock
mailbox_delete with the mark locks its targets in their registry
records, atomically with the binding name → id: the lookup, the
reservation CAS, the rename and the create CAS read the record anyway.
Exact (in a registry CAS that carries the write) are the reservation of
a counted target, the MOVE gate, CREATE under a target, RENAME and the
unregistration; Copy/Move into an ignored target, Move out of a
target, Expunge and SetFlags read the record from the lookup cache
(at most registryTTL = 2 s behind). That is enough because the job
waits jobs.settle_seconds and then, live, until no reservation names a
target and no MOVE intent points at or sits on one, and checks the plan
afterwards: a write that slipped through is drift, and the mail stays.
The job unregisters the targets by id under its lock (a record rewritten
by a server before 0.52.0 lost the lock field: the unregistration stops,
fail safe), retires the headers, sets the phase destroying — from then
on account locks are taken again and a fileinto of the name creates a
fresh mailbox — and destroys from the tombstones (idempotent; the
tombstone's time is renewed with its q as it is). An import may not
reuse a mailbox id whose tombstone lives (createIntended). The session
that has a target selected is woken through the RADOS notify of the
mailbox object and closed with BYE; poll closes a session only on an
authoritative answer (the lock in the record, or ErrNoMailbox confirmed
by a registry read, also after another session's DELETE), never on a
transport error. A delivery whose target got locked between the
resolution and the store goes to INBOX under a new GUID that the
delivery intent names for INBOX, so a rollback finds the copy where it
lies; the rollback itself uses ExpungeOwn, which ignores the lock.
Repair and its limits
The scrubber checks references, quota, orphaned blobs, missing blobs and aborted intents, among other things. Age thresholds, object versions and multi-stage candidate handling protect operations in flight. A dry run is the diagnostic option to use before intended repairs.
Scrub is not a complete backup and no substitute for restore tests. A missing blob cannot be reconstructed from its index entry. Backup/restore across both pools as well as a coordinated data migration have to be qualified as operational procedures of their own.
For store changes, always describe the success, conflict and abort paths: what is durable? What does a reader see? Who cleans up? A successful in-memory protocol test does not answer these questions for RADOS.
Rendered from docs/architecture/storage.md in the ShardPost repository.