feat: persist UUID episode identifiers - #1707
malatewang wants to merge 25 commits into
Conversation
20918da to
10e0ae2
Compare
marvinyu-memverge
left a comment
There was a problem hiding this comment.
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:
-
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. -
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. -
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. |
|
Also using UUID types everywhere prevents bugs:
|
Assign and persist UUIDs for episode entries while preserving batch and semantic ingestion chronology.
edwinyyyu
left a comment
There was a problem hiding this comment.
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.
3755d06 to
07f4317
Compare
edwinyyyu
left a comment
There was a problem hiding this comment.
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.
edwinyyyu
left a comment
There was a problem hiding this comment.
One more inline comment on the metadata normalization.
🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.
edwinyyyu
left a comment
There was a problem hiding this comment.
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.
edwinyyyu
left a comment
There was a problem hiding this comment.
One naming nit.
🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.
edwinyyyu
left a comment
There was a problem hiding this comment.
One more on the missing-episode bookkeeping.
🤖 Written by Claude Fable 5.1 via Claude Code; posted from @edwinyyyu's account.
leomem
left a comment
There was a problem hiding this comment.
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 = "", ...)passesepisodic_idinto the spec unconditionally.packages/client/src/memmachine_client/cli.py:456:--iddefaults 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.
|
@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 🤖 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. |
|
6f6fa2e to
c4216f2
Compare
edwinyyyu
left a comment
There was a problem hiding this comment.
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:
-
PR body, Compatibility. It declares only the episode table incompatible with existing databases. The semantic history and citation tables are too: existing
history_idand citation values are integer strings, every reader now doesUUID(...)on them, and history rows from before migration62dff1150a46have a NULLcreated_atthat 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. -
docs/open_source/configuration.mdx. Thesemantic_memoryparameter table and samples do not list the newmissing_episode_grace_period_seckey. The same section's existing field isingestion_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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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.""" |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
|
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. |
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
Noneacross 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.