Incremental spam training does not advance last_id, causing retained samples to be repeatedly processed

Issue Description

In Stalwart 0.16.22, spam_train(false) loads the persisted trainer and selects samples starting at trainer.last_id + 1. However, last_id is updated only when it equals zero, so it stops advancing after the first successful training run.

Retained samples added afterward are consequently processed again in later runs. Deduplication is local to each run and does not prevent this.

This affects ordinary user feedback: add_account_spam_sample(), used by IMAP spam/ham corrections, passes hold_sample=true, producing delete_after_use=false.

Verified through source inspection and an isolated reproduction of the selection/counter logic. The same code is present on main.

Expected Behavior

After successful incremental training, the persisted cursor should advance past the newly processed samples.

A subsequent run with no new samples should perform no training and leave the ham/spam total_seen counters unchanged.

Actual Behavior

The cursor remains at the highest sample ID from the first successful training run. Later retained samples are repeatedly treated as new, incrementing counters and being submitted to training again.

An isolated control-flow reproduction produced:

Run 1: 100 ham + 100 spam added
Processed: 200; last_id: 200; ham: 100; spam: 100

Run 2: one new ham + one new spam added
Processed: 2; last_id: 200; ham: 101; spam: 101

Run 3: nothing added
Processed: 2; last_id: 200; ham: 102; spam: 102

IDs are simplified. The balanced classes exclude intentional reservoir replay for class balancing as the explanation.

At the default 12-hour interval, a retained sample could be processed approximately 360 times over 180 days if runs succeed and nothing removes the sample or resets/rebuilds the trainer. This is a scheduling-based estimate, not a production measurement. Classification-accuracy impact has not been measured.

Reproduction Steps

Suggested integration reproduction for an isolated instance; not executed here:

  1. Start with a fresh classifier and 100 distinct valid ham samples plus 100 distinct valid spam samples. Set delete_after_use=false and keep their blobs available.

  2. Run SpamFilterMaintenance with maintenanceType=“train” and wait for successful completion.

  3. Add two new distinct retained samples: one ham and one spam.

  4. Run maintenanceType=“train” again and wait for completion.

  5. Without adding or changing samples, run maintenanceType=“train” a third time.

  6. Inspect SpamTrainer.last_id, the reservoir ham/spam total_seen counters, and the training-completed event.

The code path predicts that the third run reprocesses the two samples from step 3 instead of reporting zero work. Use “train”, not “retrain”.

Relevant Log Output

No live-server training logs are attached. The results above come from an isolated reproduction of the source control flow, not from running the Stalwart binary.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Selection, deduplication and cursor assignment:

Trainer persistence:

Counter increment:

User-generated samples are retained:

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

Your source analysis matches what’s in crates/spam-filter/src/modules/classifier.rs on the referenced tag. The cursor update logic is:

if trainer.last_id == 0 {
    trainer.last_id = id;
}

This only sets last_id the very first time it’s zero (i.e., on the very first successful run), and is never updated again in subsequent runs, even though new samples are processed each time and merged into the reservoir/counters. So your reproduction’s behavior — last_id staying frozen at the value from run 1 while total_seen keeps incrementing on runs 2 and 3 — lines up with what the code does: since the range scan always starts at last_id + 1 and last_id never advances past the first run, previously-processed retained (non-delete_after_use) samples keep re-entering the selection window and get deduplicated only within that single run, not across runs.

This is a source-level defect report rather than a documentation question, so I can’t point you to docs that describe or contradict this behavior — there isn’t a doc page covering the training cursor internals. I don’t have a way to confirm here whether a fix is already staged or how it will be prioritized.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This has been fixed in v0.16.24 (yet to be released).