reindex_account — the function backing AccountMaintenance(reindex) and StoreMaintenance(reindexAccounts) — only reindexes email. Calendar and Contacts IndexDocument tasks are scheduled into an in-memory BatchBuilder that is shadowed by a let binding inside a loop body and is never written to the store unless the account happens to have ≥ 2000 items of that type. For any real-world account (< 2000 calendar events / contacts), the entire calendar and contacts reindex is silently lost.
Expected Behavior
reindex_account — the function backing AccountMaintenance(reindex) and StoreMaintenance(reindexAccounts) — reindexes email calendar and contacts
Actual Behavior
the entire calendar and contacts reindex is silently lost.
Reproduction Steps
Switch the FTS backend to Elasticsearch.
Have an account with at least one calendar event and/or contact, and fewer than 2000 of each.
Trigger a full reindex via JMAP:
[“x:Task/set”, {
“accountId”: “”,
“create”: { “k1”: {
“@type”: “StoreMaintenance”,
“maintenanceType”: “reindexAccounts”
}}}, “c1”]
After the task drains, check the ES indices:
GET st_email/_count // grows — OK
GET st_calendar/_count // stays 0 — BUG
GET st_contact/_count // stays 0 — BUG
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
PostgreSQL
Blob Storage
PostgreSQL
Search Engine
Elasticsearch
Directory Backend
OIDC
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
This confirms the bug exactly as described: the inner let mut batch = BatchBuilder::new(); inside the for document_type in [Calendar, Contacts] loop shadows the outer batch, and it’s only flushed inside the loop when batch.len() >= 2000. The final if !batch.is_empty() after the loop refers to the outer shadowed-back batch variable (the email one, already flushed), not the inner calendar/contacts one, so any inner batch with fewer than 2000 items is dropped and never written to the store. This is a confirmed code defect.
I’ve confirmed this in the source. In reindex_account (task_manager/index.rs), the loop over [Calendar, Contacts] creates a new let mut batch = BatchBuilder::new(); scoped inside the loop, and only flushes it when it reaches 2000 items. Since that inner batch variable goes out of scope at the end of each loop iteration’s block, any remaining calendar/contacts IndexDocument tasks under 2000 are silently discarded, exactly as you diagnosed, since the final flush at the bottom of the function only sees the outer (email) batch, which was already emptied earlier.
This is a genuine bug in the current code, not something covered by docs. A human maintainer will need to confirm the fix (moving the final flush inside the per-type loop, or hoisting the batch variable so it isn’t shadowed) and ship a patch; I can’t merge code changes myself.
This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.
File: crates/services/src/task_manager/index.rs, function reindex_account (starts ~line 330).
let mut batch = BatchBuilder::new(); // outer batch — used for EMAIL
// — email: uses outer batch, flushed on is_large_batch() + final flush —
for document_id in server.get_cached_messages(account_id)…emails.items… {
batch.schedule_task(Task::IndexDocument(TaskIndexDocument {
document_type: IndexDocumentType::Email, …
}));
if batch.is_large_batch() {
server.core.storage.data.write(batch.build_all()).await?;
batch = BatchBuilder::new();
}
}
// — calendar / contacts: BUG —
for document_type in [IndexDocumentType::Calendar, IndexDocumentType::Contacts] {
let cache = server.fetch_dav_resources(…).await?;
let mut batch = BatchBuilder::new(); // ← SHADOWS the outer batch, scoped to this loop body
for document_id in cache.document_ids(false) {
batch.schedule_task(Task::IndexDocument(TaskIndexDocument { document_type, ... }));
if batch.len() >= 2000 { // <-- only flushed mid-loop at >=2000
server.core.storage.data.write(batch.build_all()).await?;
batch = BatchBuilder::new();
}
}
// <-- end of loop body: this iteration's batch (with <2000 tasks) is DROPPED, never written
}
if !batch.is_empty() { // ← this batch is the OUTER (email) one;
server.core.storage.data.write(batch.build_all()).await?; // it flushes email’s leftover, NOT calendar/contacts
}
server.notify_task_queue();
BatchBuilder::schedule_task only buffers the task in memory (self.set(…) in crates/store/src/write/batch.rs); tasks are persisted exclusively via write(batch.build_all()). Because the calendar/contacts batch is re-declared inside the loop body, it shadows the outer batch, is never flushed at the end of each iteration, and the trailing if !batch.is_empty() refers to the outer (email) batch — so calendar and contacts tasks are dropped on the floor.
Net effect: reindexAccounts / AccountMaintenance(reindex) reindexes email only.