Skip to content

fix(import): an import that changes a stored message's content puts its duplicate flag right - #1978

Merged
mbeisser1 merged 5 commits into
mainfrom
fix/1805-dedupe-after-content-change
Oct 7, 2026
Merged

mbeisser1 merged 5 commits into
mainfrom
fix/1805-dedupe-after-content-change

Conversation

@mbeisser1

Copy link
Copy Markdown
Member

Bug Description

Expected: An import that changes a stored message's content (a later edit's text, an attachment added, or a stored attachment given its file) leaves the message's duplicate flag matching its new content, whatever the import's dedupe setting.

Actual: An append with dedupe off changed the content and left the flag. Message M hidden behind N, another source's copy of "see you at six", took the text "see you at seven" and stayed hidden behind N, so a search for "seven" found nothing. Copies hidden behind M stayed hidden though their text no longer matched.

Root Cause

Only dedupe::dedupe_cross_source sets or clears duplicate_of, and run_import_path in crates/server/server/src/imports_api/mod.rs runs it only when the Import Run has dedupe on. Since #1801 and #1966, promote_later_edits changes a stored message's text and clears its content key, and promote_attachments changes what the key hashes, but nothing re-evaluated the flag when dedupe was off.

Fix Description

Option 1 of the decision on #1805: the import re-runs the dedupe for the changed messages, whatever its setting.

  • promote_attachments (db/staging.rs) now returns the stored messages whose attachment rows took their file (RETURNING message_id), and stored_messages_with_new_content names the messages in _promote_edit_map and those that gained an attachment row.
  • Promote::run (imports_api/promote.rs) passes those ids to the new dedupe::dedupe_changed_messages, inside the import's transaction, as its last phase. With no changed message it runs nothing.
  • dedupe_changed_messages computes the changed messages' content keys again, runs the exact and near-time passes over the messages with a content key, and writes the flags of the messages tied to a changed one: those it hid or was hidden behind, before or now, followed to the end, so no hidden message is left pointing at one that is hidden. A message an import with dedupe off added has no content key, so it is neither hidden nor a winner. Every other flag stays as it is.
  • dedupe_cross_source shares the pieces: the exact pass is split into compute and apply, and the near-time pass reads the exact pass's losers from memory instead of duplicate_of IS NULL (the same rows, since the pass clears every flag first). NEAR_WINDOW_SECS names the 2-second window both use.
  • docs/architecture/contacts-identities-and-messages.md states the rule in place of the "still leaves" note. A CHANGELOG entry is under 0.11.0, Fixes, Importing.

Assumptions, stated rather than asked:

  • The pass runs with dedupe on too, before the import's own full pass, so the import command with --skip-dedupe (which fills content keys but runs no pass) is covered as well. With dedupe on it is redundant work, only when stored content changed.
  • A deletion mark change is not a content change here: the content key does not hash it.
  • The issue's acceptance criteria say the CHANGELOG entry goes under 0.10.0; it is under the in-development 0.11.0 heading, as the brief said.

How to Reproduce (Before Fix)

  1. Import a message "see you at six" from source sms, then the same message from source imessage, both with dedupe on: the imessage copy is hidden behind the sms one.
  2. Append, with dedupe off, a later backup in which the imessage copy was edited to "see you at seven".
  3. GET /v1/messages?q=seven finds nothing, and the imessage copy is still hidden.

How to Verify (After Fix)

  1. cargo test -p message-crate-server changed_content_dedupe runs the four new tests in crates/server/server/src/imports_api/tests/changed_content_dedupe.rs, through the HTTP import routes.
  2. an_edit_with_dedupe_off_shows_a_message_hidden_behind_a_copy_of_its_old_text is the issue's scenario: M is shown, a search for "seven" finds it, and a new message the same import brings stays shown beside the copy it duplicates.
  3. an_edit_with_dedupe_off_evaluates_the_copies_around_the_message_again: the copy of M's old text hidden behind M is shown again, and a third source's copy of the new text is hidden behind M. an_attachment_with_dedupe_off_keeps_a_copy_that_still_matches_hidden: a copy that still matches M stays hidden, and M's content key hashes its new file. an_append_with_dedupe_off_that_changes_nothing_stored_runs_no_dedupe: no change, no pass.

The first three fail without the fix. The fourth and the "new message stays shown" assertion pass without it by their nature: they guard the scope of the fix against an account-wide pass.

Impact Assessment

  • Severity: Medium. A message disappears from lists and search, and nothing says why.
  • Users affected: Subset: accounts that hide duplicates across sources and later append, with dedupe off, a backup in which a stored message was edited or gained an attachment.
  • Duration: Since fix(server): an append from a later backup takes a stored message's later edit #1801 made an append take a stored message's later edit; an added attachment has changed the content key since appends could add one.

Regression Risk

Every import that changes a stored message's content now loads the account's keyed messages once for the two passes, which costs about what the full pass costs less the key hashing. Imports that change nothing stored are unaffected. The refactor of dedupe_cross_source keeps its behaviour: the whole server suite passes, including the generated-database invariant test.

Checklist

  • Root cause identified and documented above
  • Fix addresses the root cause (not just symptoms)
  • Added test to prevent regression
  • Existing tests pass locally (cargo test -p message-crate-server: 1326 passed, 1 ignored; ./scripts/check-pr.sh passed)
  • Tested the specific reproduction steps

Related Issues

Closes #1805

Related: #1801, #1757.

🤖 Generated with Claude Code

mbeisser1 and others added 2 commits October 7, 2026 15:51
…ts duplicate flag right

An append with dedupe off could give a stored message the text of a later
edit, or add an attachment to it, and leave its duplicate flag as it was,
because only the account-wide dedupe pass sets or clears the flag and an
import runs that pass only with dedupe on. A message hidden behind another
source's copy of its old text stayed hidden, so a search for its new text
found nothing, and copies hidden behind it stayed hidden though their text
no longer matched.

Promotion now collects the stored messages whose content it changed (those
in the edit map, those that gained an attachment, and those whose stored
attachment took its file) and, whatever the import's dedupe setting, runs
dedupe_changed_messages for them inside the import's transaction. It
computes their content keys again, runs the exact and near-time passes over
the messages with a content key, and writes the flags of the messages tied
to a changed one, before or now. A message an import with dedupe off added
has no content key, so it stays as it came. An import that changed no
stored message's content runs nothing.

Closes #1805

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mbeisser1 mbeisser1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of a9e1394 (the PR with main merged in). Correctness 2, Spec 3, Standards 5. Findings with no line in the diff follow in a top-level comment. Each is answered in its thread.

Comment thread crates/server/server/src/dedupe.rs Outdated
Comment thread crates/server/server/src/imports_api/promote.rs Outdated
Comment thread crates/server/server/src/db/staging.rs Outdated
Comment thread docs/architecture/contacts-identities-and-messages.md Outdated
Comment thread crates/server/server/src/dedupe.rs
Comment thread crates/server/server/src/dedupe.rs Outdated
Comment thread crates/server/server/src/dedupe.rs
Comment thread crates/server/server/src/imports_api/promote.rs Outdated
mbeisser1 and others added 3 commits October 7, 2026 16:37
The pass an import runs for the stored messages whose content it changed
now takes up only messages that had a content key before. A message that
only imports with dedupe off brought has none, and an edit to it no
longer keys it and hides it behind another source's copy of its new
text. `_promote_edit_map` records whether each message had a key and
whether its text changed, because promote_later_edits clears the key of
a message whose text changes; a change to the earlier versions alone
keeps the key and runs no pass.

An import that fills content keys skips the pass, because the full
dedupe after its commit writes every flag again. The server's import
command fills them only when that dedupe follows.

The changed ids go to SQL as one JSON array read with json_each, so
KeyScope::Changed carries them and the `_dedupe_changed` and
`_dedupe_tied` temp tables and fill_id_table are gone. A
PromotedContent struct carries what the promotion did to stored
content and names the changed messages in one place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HAS_CONTENT_KEY_SQL in dedupe.rs is now the one place that says what a
message with a content key is; the edit map, the changed-message query,
the exact pass and the near-time pass use it. reset_id_map takes extra
columns, so the edit map is made by it too.

The --skip-dedupe help, and the server CLI page generated from it, say
that the import then writes no content keys and a later full dedupe
writes them. The fill_content_keys doc says what the flag does rather
than who passes it, and the changelog entry is split into two sentences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mbeisser1
mbeisser1 marked this pull request as ready for review October 7, 2026 20:59

@mbeisser1 mbeisser1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the fix commit 028aec2. Standards 5, Correctness 1. Findings with no line in the diff follow in a top-level comment. Each is answered in its thread.

Comment thread crates/server/server/src/import_cli.rs
Comment thread crates/server/server/src/db/staging.rs
Comment thread crates/server/server/src/dedupe.rs
Comment thread crates/server/server/src/dedupe.rs
Comment thread crates/server/server/src/imports_api/mod.rs
Comment thread CHANGELOG.md
@mbeisser1

Copy link
Copy Markdown
Member Author

Review summary. Reviewed head a9e1394 (the PR with main merged in), fixes 028aec2 and 2f4e61c, base merged in again as c4a5d9e (no conflicts), CI green on c4a5d9e.

  • Correctness: 2 findings (one bug: a keyless message was keyed and hidden), 2 fixed. Re-review: 1, fixed.
  • Spec: 3 findings, 2 fixed, 1 recorded on An append with dedupe off leaves a changed message's duplicate flag #1805 (a guard test that passes without the fix, kept on purpose).
  • Standards: 5 findings, 5 fixed. Re-review: 5, 5 fixed.
  • Deferred: none. No commits for CI failures. Both merges of the base were conflict-free.
  • No user threads.

@mbeisser1
mbeisser1 merged commit c97649d into main Oct 7, 2026
28 checks passed
@mbeisser1
mbeisser1 deleted the fix/1805-dedupe-after-content-change branch October 7, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An append with dedupe off leaves a changed message's duplicate flag

1 participant