Skip to content

fix(semantic-memory): stop unbounded LLM retry storm on persistent extraction failures - #1513

Open
connectsudhindra-gif wants to merge 1 commit into
MemMachine:mainfrom
connectsudhindra-gif:fix/semantic-ingestion-retry-storm
Open

fix(semantic-memory): stop unbounded LLM retry storm on persistent extraction failures#1513
connectsudhindra-gif wants to merge 1 commit into
MemMachine:mainfrom
connectsudhindra-gif:fix/semantic-ingestion-retry-storm

Conversation

@connectsudhindra-gif

Copy link
Copy Markdown

Summary

Fixes #1453 (reported ~$200 in unexpected LLM cost from background semantic/profile ingestion running for many hours with no user activity).

Root cause, traced in packages/server/src/memmachine_server/semantic_memory/semantic_ingestion.py:

  • The background ingestion loop (semantic_memory.py::_background_ingestion_task) polls every feature_update_interval_sec (default 2 seconds) for any set with un-ingested messages.
  • Per-message idempotency already exists via an is_ingested flag — once a message is successfully processed (or hits a non-retryable context-length error), it's marked ingested and won't be re-fetched.
  • But when llm_feature_update failed with any other exception (a transient API error, a malformed/unparseable LLM response for that specific content, etc.), the code logged it and continued without marking the message ingested.
  • That message stays is_ingested=False forever, so the very next poll cycle (2 seconds later) fetches it again and calls the LLM again — forever, with no backoff, since the exception is swallowed locally and never reaches the outer loop's error-backoff handling.
  • A single message that fails deterministically for the same input (which is plausible — malformed JSON output, a specific content pattern the LLM mishandles, etc.) therefore becomes an unbounded retry storm: thousands of LLM calls over hours from a single stuck message, matching the reporter's symptom exactly.

Fix

  • Added _try_feature_update, mirroring the existing _try_consolidate bounded-retry pattern already used for the consolidation path in the same file.
  • A generic failure is now retried up to _MAX_FEATURE_UPDATE_ATTEMPTS (2, matching _MAX_CONSOLIDATION_ATTEMPTS) times.
  • Once retries are exhausted, the message is marked ingested — same treatment already given to non-retryable context-length errors — so a persistently failing message stops being re-fetched instead of retrying every 2 seconds forever.
  • debug_fail_loudly still raises instead of silently giving up, unchanged behavior for that flag.
  • This does not address the other (larger, separately-scoped) asks in the issue — cost/concurrency budgets, a queue/status API, content-hash dedup for resubmitted history. Those are legitimate follow-ups but a bigger feature than this bug fix.

Test plan

  • Added test_process_single_set_gives_up_on_persistent_generic_errors: simulates a message whose feature update fails every time, asserts the LLM is called only _MAX_FEATURE_UPDATE_ATTEMPTS times (not once, not forever), that the message ends up marked ingested, and that a second _process_single_set call (simulating the next poll cycle) makes zero further LLM calls.
  • Added test_process_single_set_retries_and_recovers_from_transient_error: fails once then succeeds, asserts the retry recovers and the feature is correctly applied.
  • Verified both new tests fail against the pre-fix code (confirming they catch the real bug: 1 call instead of the expected bounded retries, no backoff before infinite reprocessing) and pass after the fix.
  • Ran the full semantic_memory test suite (uv run pytest packages/server/server_tests/memmachine_server/semantic_memory/): 349 passed, 2 skipped (pre-existing, unrelated), integration tests requiring Postgres/Neo4j deselected as usual.
  • ruff check and ruff format --diff on both changed files: clean.

…traction failures

Background semantic/profile ingestion polls every feature_update_interval_sec
(default 2s) for un-ingested messages. When a message's feature-update LLM
call failed with a non-context-length error, it was logged and skipped via
`continue` without ever being marked ingested. That message would then be
re-fetched and re-sent to the LLM on every subsequent poll cycle, forever,
at the fast idle-polling interval rather than the outer loop's error
backoff — a single persistently-failing ("poison") message could generate
thousands of LLM calls over hours with zero new user activity.

process_semantic_type now retries a feature-update failure up to
_MAX_FEATURE_UPDATE_ATTEMPTS times (mirroring the existing
_try_consolidate retry pattern) via a new _try_feature_update helper.
Once retries are exhausted, the message is marked ingested so it stops
being re-fetched, matching how non-retryable context-length errors are
already handled. debug_fail_loudly still raises instead of giving up.

Fixes MemMachine#1453.
@connectsudhindra-gif

Copy link
Copy Markdown
Author

This is ready for review whenever a maintainer has time. Let me know if any changes are needed.

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.

[Bug]: Excessive background semantic/profile memory LLM calls

1 participant