Skip to content

feat: persist UUID episode identifiers - #1707

Open
malatewang wants to merge 25 commits into
mainfrom
episode_uuid
Open

malatewang wants to merge 25 commits into
mainfrom
episode_uuid

Conversation

@malatewang

@malatewang malatewang commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Episodes receive server-generated UUIDs before persistence. A database-backed
increasing sequence number gives every episode a stable tie breaker when event
times match. Both SQLite and PostgreSQL reserve numbers atomically; semantic
history stores the same sequence instead of a batch position. Episode and
semantic history reads order by episode time and sequence.

The episode store, episodic memory, and semantic registration still write
concurrently to avoid serializing request latency on the episode database
commit. Semantic history keeps registration time separate from episode time.
Registration and ingestion age checks use the semantic storage clock, so clock
skew between application workers and the database does not prematurely expire
missing episodes or trigger ingestion. Empty episode metadata is normalized to
None across in-memory and stored read paths.

Delete behavior and partial writes

Deleting a valid but unknown episode UUID is idempotent and returns success.
Deletion attempts cleanup in each configured backend even when the episode
store row is absent. The episode-store existence read has been removed. All
concurrent add failures are logged before the request raises its first error.

Concurrent writes can still leave a partial record if one backend fails. The
semantic ingestion retry window remains in place, is configurable through
semantic_memory.missing_episode_grace_period_sec, and is applied in debug mode.

Compatibility

This PR does not upgrade old integer episode IDs. Existing integer-key episode
tables, semantic history rows, and citations that refer to those IDs require
an explicit data migration before use with UUID episodes. For databases
already using the UUID schema, the PostgreSQL semantic history migration and
the vector semantic storage startup migration rename the batch position column
to the episode sequence column.

Verification

Focused episode, semantic, and episodic tests pass. Ruff checks and formatting
pass on changed Python files. PostgreSQL and Neo4j integration tests could not
run because Docker is unavailable in this environment. The broad type check is
blocked by optional packages absent from the test environment.

The OpenAPI document was regenerated with UUID examples and schemas.

@malatewang
malatewang force-pushed the episode_uuid branch 3 times, most recently from 20918da to 10e0ae2 Compare September 22, 2026 23:53
@malatewang
malatewang requested review from edwinyyyu and marvinyu-memverge and removed request for edwinyyyu September 23, 2026 18:47

@marvinyu-memverge marvinyu-memverge left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 10e0ae2. The parallel write and the grace period on missing
episodes both look right to me; three things I'd want settled before this
goes in, all reproduced locally:

  1. Upgrading an existing deployment. The episode store only runs create_all,
    so an existing episodestore keeps its INTEGER id column, and inserting a
    UUID into it fails (SQLite: "IntegrityError: datatype mismatch"; Postgres
    rejects it the same way). Because the store write now runs alongside the
    episodic and semantic writes, each of those failed adds still lands in
    episodic memory and the semantic queue. So on an un-migrated database
    every add errors and leaves an orphan. Could we either ship a migration
    (semantic storage already has one for its history_id -> string change) or
    check the column type at startup and refuse to start? Either way it fails
    loudly instead of half-writing.

  2. Orphans can't be deleted by id. Same shape outside the upgrade case: if
    episode_storage.add_episodes fails (DB timeout, dropped connection), the
    episode is still in episodic memory and shows up in search, but the new
    existence check in delete_episodes raises ResourceNotFoundError for it,
    so it can only be cleared by deleting the whole session. Either
    delete_episodes should go ahead for ids that aren't in the store, or a
    failed store write should undo the episodic write.

  3. Ordering within a batch. History now sorts by (created_at, history_id),
    and every message in one add shares a created_at whenever the client sends
    the same timestamp for all of them (e.g. importing a transcript stamped
    per session). Ties then fall back to the random UUID, so ingestion can
    process the messages out of order and the older fact wins. I ran
    test_ingestion_keeps_latest_value_across_uuid_ordered_batches with every
    entry at the same created_at: it ends on "violet" instead of "red". On
    main the int id kept insertion order. Something that breaks ties by
    position in the batch would restore it.

Verified and fine: created_at reaches semantic history on all four backends,
the 30 s grace period covers ingestion reading an episode before its store
row commits, and CI is green at head.

@malatewang

Copy link
Copy Markdown
Contributor Author

Reviewed at 10e0ae2. The parallel write and the grace period on missing episodes both look right to me; three things I'd want settled before this goes in, all reproduced locally:

  1. Upgrading an existing deployment. The episode store only runs create_all,
    so an existing episodestore keeps its INTEGER id column, and inserting a
    UUID into it fails (SQLite: "IntegrityError: datatype mismatch"; Postgres
    rejects it the same way). Because the store write now runs alongside the
    episodic and semantic writes, each of those failed adds still lands in
    episodic memory and the semantic queue. So on an un-migrated database
    every add errors and leaves an orphan. Could we either ship a migration
    (semantic storage already has one for its history_id -> string change) or
    check the column type at startup and refuse to start? Either way it fails
    loudly instead of half-writing.
  2. Orphans can't be deleted by id. Same shape outside the upgrade case: if
    episode_storage.add_episodes fails (DB timeout, dropped connection), the
    episode is still in episodic memory and shows up in search, but the new
    existence check in delete_episodes raises ResourceNotFoundError for it,
    so it can only be cleared by deleting the whole session. Either
    delete_episodes should go ahead for ids that aren't in the store, or a
    failed store write should undo the episodic write.
  3. Ordering within a batch. History now sorts by (created_at, history_id),
    and every message in one add shares a created_at whenever the client sends
    the same timestamp for all of them (e.g. importing a transcript stamped
    per session). Ties then fall back to the random UUID, so ingestion can
    process the messages out of order and the older fact wins. I ran
    test_ingestion_keeps_latest_value_across_uuid_ordered_batches with every
    entry at the same created_at: it ends on "violet" instead of "red". On
    main the int id kept insertion order. Something that breaks ties by
    position in the batch would restore it.

Verified and fine: created_at reaches semantic history on all four backends, the 30 s grace period covers ingestion reading an episode before its store row commits, and CI is green at head.

We do not support upgrade or backward compatibility.

Comment thread packages/server/src/memmachine_server/common/episode_store/episode_model.py Outdated
@edwinyyyu

edwinyyyu commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Also using UUID types everywhere prevents bugs:

  • For a string representation, you don't know whether it's hex or not (though it probably is), lowercase or uppercase.
  • For a string representation, you don't know whether it has the hyphens.
  • If some code path doesn't do validation or doesn't follow the convention, then there may be a mix of arbitrarily written ids.

Assign and persist UUIDs for episode entries while preserving batch and semantic ingestion chronology.

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 3755d06 against main 8101f14. Inline comments below. The three points already raised (migration, orphans on partial failure, batch ordering) remain open; I have not repeated them except where the inline comment adds a mechanism.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Comment thread packages/server/src/memmachine_server/main/memmachine.py
Comment thread packages/server/src/memmachine_server/main/memmachine.py
Comment thread packages/server/src/memmachine_server/main/memmachine.py Outdated
Comment thread packages/server/src/memmachine_server/common/episode_store/episode_model.py Outdated

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Second pass at 07f4317. Three inline comments below, plus one item on a file outside the diff:

packages/common/src/memmachine_common/api/doc.py:634 still gives EPISODIC_ID = ["123", "345"] and EPISODIC_IDS = [["123", "345"], ["23"]] as the public examples, and docs/openapi.json is generated from them. This PR changes the id format every client sees, so those examples now describe ids that can never match. Please update them to UUID strings and regenerate docs/openapi.json in this PR.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Comment thread packages/server/src/memmachine_server/main/memmachine.py Outdated

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One more inline comment on the metadata normalization.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Comment thread packages/server/src/memmachine_server/main/memmachine.py Outdated

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Four more inline comments: the history timestamp doubling as the ingestion debounce clock, and three on the grace-period bookkeeping.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Comment thread packages/server/src/memmachine_server/semantic_memory/semantic_memory.py Outdated
Comment thread packages/server/src/memmachine_server/semantic_memory/semantic_ingestion.py Outdated
Comment thread packages/server/src/memmachine_server/semantic_memory/semantic_ingestion.py Outdated
@edwinyyyu edwinyyyu added horizontal scaling Wrong or unsafe when more than one server process serves the same backends (replicas or workers) and removed horizontal scaling Wrong or unsafe when more than one server process serves the same backends (replicas or workers) labels Sep 29, 2026

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One naming nit.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One more on the missing-episode bookkeeping.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Comment thread packages/server/src/memmachine_server/semantic_memory/semantic_ingestion.py Outdated

@leomem leomem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at a2cbde7. Batch delete from the Python client and the CLI fails after this change.

DeleteEpisodicMemorySpec.episodic_id is now UUID | None (packages/common/src/memmachine_common/api/spec.py:693), but the client still sends an empty string by default:

  • packages/client/src/memmachine_client/memory.py:598: delete_episodic(self, episodic_id: str = "", ...) passes episodic_id into the spec unconditionally.
  • packages/client/src/memmachine_client/cli.py:456: --id defaults to "".

So memory.delete_episodic(episodic_ids=[...]) and memmachine delete-episodic --ids <uuid> raise before any request is sent. Reproduced on this branch:

DeleteEpisodicMemorySpec(org_id="o", project_id="p", episodic_id="", episodic_ids=[str(uuid4())])
# ValidationError: episodic_id
#   Input should be a valid UUID, invalid length: expected length 32 for simple format, found 0 [type=uuid_parsing, input_value='']

An older client that sends "episodic_id": "" now gets a 422 from the server for the same reason. The client tests only call delete_episodic with a single id, so CI doesn't catch it.

Suggested fix: default episodic_id to None in delete_episodic and in the CLI's --id, and add a client test that deletes by episodic_ids alone. If old clients should keep working, the spec could also map "" to None before validation.

@edwinyyyu

Copy link
Copy Markdown
Contributor

@malatewang one review thread is still open and may be easy to miss since it sits on an older review: #1707 (comment)

Short version: keeping the store insert concurrent with the memory writes is a correctness tradeoff (partial writes with no scan-free recovery), so it needs a number. The saving is bounded by the store insert time itself, and that is already instrumented as @timed("add_episodes"). Please post p50 and p99 of that timer on Postgres and SQLite for batch sizes 1 and 10. If the saving is under roughly 20 ms, the ask is to restore the store-first order and drop the grace-period machinery with it. Every other thread from this side is resolved.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

@malatewang

Copy link
Copy Markdown
Contributor Author

@malatewang one review thread is still open and may be easy to miss since it sits on an older review: #1707 (comment)

Short version: keeping the store insert concurrent with the memory writes is a correctness tradeoff (partial writes with no scan-free recovery), so it needs a number. The saving is bounded by the store insert time itself, and that is already instrumented as @timed("add_episodes"). Please post p50 and p99 of that timer on Postgres and SQLite for batch sizes 1 and 10. If the saving is under roughly 20 ms, the ask is to restore the store-first order and drop the grace-period machinery with it. Every other thread from this side is resolved.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

That number will not provide any information. Any overhead is really workload and hardware dependent.

@malatewang

Copy link
Copy Markdown
Contributor Author

@malatewang one review thread is still open and may be easy to miss since it sits on an older review: #1707 (comment)
Short version: keeping the store insert concurrent with the memory writes is a correctness tradeoff (partial writes with no scan-free recovery), so it needs a number. The saving is bounded by the store insert time itself, and that is already instrumented as @timed("add_episodes"). Please post p50 and p99 of that timer on Postgres and SQLite for batch sizes 1 and 10. If the saving is under roughly 20 ms, the ask is to restore the store-first order and drop the grace-period machinery with it. Every other thread from this side is resolved.
🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

That number will not provide any information. Any overhead is really workload and hardware dependent. The original implementation also has consistency and correctness problem. To make the episode insert atomic, it requires additional work.

@edwinyyyu edwinyyyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Final-state pass at 6f6fa2e. Six inline comments: two correctness, two dead code, one stale docstring, one leftover test setup. Two items on files outside the diff:

  1. PR body, Compatibility. It declares only the episode table incompatible with existing databases. The semantic history and citation tables are too: existing history_id and citation values are integer strings, every reader now does UUID(...) on them, and history rows from before migration 62dff1150a46 have a NULL created_at that the new registration-time lookup cannot handle. Under the stated no-upgrade stance that is fine, but the body should say all three tables, not one.

  2. docs/open_source/configuration.mdx. The semantic_memory parameter table and samples do not list the new missing_episode_grace_period_sec key. The same section's existing field is ingestion_trigger_age_seconds, an integer, so the new key should also follow that spelling and type within the section.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

session_data.session_key,
episode_entries,
)
created_at = datetime.now(UTC)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Within-batch order is still lost at the episode layer. This stamps one created_at on every entry of the batch, the integer key that used to break ties is gone, and every episode-store read orders by created_at alone (episode_sqlalchemy_store.py:246, :380, :431). Three messages added in one call come back from list and get in arbitrary order. The semantic history fix (batch_position) does not reach this path.

Episode.sequence_num already exists on the model and is never assigned. Assigning it per batch here and ordering by (created_at, sequence_num) in the store fixes list and get, and would let the semantic-history batch_position column go, since the position would ride on the episode itself.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The episode-store ordering gap is real. One correction to the proposed fix: Episode.sequence_num currently exists only on the typed model; the SQL episode table neither stores nor hydrates it, and per-batch numbering repeats across batches with equal timestamps. It therefore cannot replace the semantic-history batch_position column as written. I propose a persisted insertion-order tie breaker for episode-store list and ID queries, while retaining history batch_position for semantic ordering. Also, get_episode selects one primary-key row and get_episodes currently has no ORDER BY, so the three cited queries do not all have the same ordering behavior.

@edwinyyyu edwinyyyu Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the client should pass any timestamps it needs for ordering. Otherwise, it means the ordering/timing doesn't matter to the client, so everything with the same time can be treated as concurrent and arbitrarily ordered by whatever the system wants. If a consistent order is really necessary, just ordering by the UUID should work well without having to use an extra field. If the client wants to avoid this, they may sort UUIDs before assigning them or use UUIDv7 or similar.

)
)
if self._debug_fail_loudly:
now = datetime.now(UTC)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This compares a process clock against a different process's clock. The registration time is now written from the API host (semantic_memory.py:243, semantic_session_manager.py:162: datetime.now(UTC)), and this line compares it against the ingestion host's datetime.now(UTC). On main the history row's created_at was the database's func.now(). With the hosts split, a 30 s skew in one direction deletes a live row on its first pass, which is the race the grace period exists to cover; skew the other way extends the window.

Database times should be compared with database times, and process times with process times, wherever the comparison decides an order or a deadline. Here that means keeping the database default for the registration column and doing the age comparison against the database clock: WHERE created_at <= now() - interval in SQL, and datetime() in Cypher, or fetch now() from the database once per pass and compare in Python. The batch position stays a process value because it is only ever compared with other positions from the same request.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

async def add_messages(
self, set_id: SetIdT, history_ids: Sequence[EpisodeIdT]
) -> None:
async def add_messages(self, set_id: SetIdT, history_ids: Sequence[UUID]) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add_messages has no caller in packages/server/src; only tests call it. Production registers history through SemanticSessionManager.add_message -> add_message_to_sets. This PR added the episode-store round trip and the batch ordering to the dead path, and the ordering tests (test_ingestion_keeps_latest_value_across_uuid_ordered_batches, test_id_only_registration_preserves_episode_times) exercise it rather than the production one, so a regression in add_message_to_sets would not be caught. Either route the session manager through this method or delete it and point the tests at the production path.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed that production registration goes through SemanticSessionManager.add_message to add_message_to_sets, while the two ordering tests call add_messages. I agree that those tests miss the production path. I would keep add_messages as a supported service entry point rather than call it dead solely because there is no in-repo production caller, and add ordering and missing-episode tests through SemanticSessionManager.add_message. Removing add_messages would need an API-usage decision beyond this test gap.


return DeclarativeMemoryEpisode(
uid=episode.uid or str(uuid4()),
uid=episode.uid or uuid4(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dead fallback. Episode.uid is now a required UUID, and a UUID is always truthy, so or uuid4() can never run. Leaving it implies episodes may arrive without an id and that the declarative node id may be minted here, separately from the store. The same dead checks: cluster_splitter.py:114 and :116 (if m.uid is not None) and semantic_ingestion.py:219 (if message.uid is None). Removing all four makes the invariant visible: the uid is assigned at EpisodeEntry construction and nowhere else.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

) -> AsyncIterator[EpisodeIdT]:
"""Retrieve history messages with optional ingestion status."""
) -> AsyncIterator[UUID]:
"""Retrieve history by creation time, then ID, before applying the limit."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale contract. The implemented and tested order is episode time, then registration time, then batch position, then id (test_history_equal_times_preserve_batch_order). A backend written to this docstring, creation time then id, satisfies the stated contract and fails the test. State the four-key order here.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

session = DummySessionData("session-del")

episode_storage = MagicMock()
episode_storage.get_episodes = AsyncMock(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Leftover from the removed precheck: this get_episodes mock, and the same ones at :1142, :1174, :1528, :1552, are set up and never read, since delete_episodes no longer consults the store before deleting. test_delete_episodes_cleans_missing_store_id at :1060 is the one that should keep its mock, because it asserts the call is never made. Drop the other five so readers do not infer a read that no longer exists.

🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.

@malatewang

Copy link
Copy Markdown
Contributor Author

On the final-state review summary (#1707 (review)): the documentation gap for missing_episode_grace_period_sec is valid. One naming correction: docs/open_source/configuration.mdx currently documents ingestion_trigger_age as an HH:MM:SS duration, matching the YAML configuration model. ingestion_trigger_age_seconds is the API update field, not the key used in that documentation section. I propose adding missing_episode_grace_period_sec as a numeric seconds field to both YAML samples and the parameter table, while keeping the existing age key unchanged.

@wanghy73 wanghy73 added this to the v0.4.0 milestone Oct 2, 2026
@wanghy73 wanghy73 modified the milestones: v0.4.0, v0.4.1 Oct 2, 2026

This branch has not been deployed

No deployments
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.

5 participants