Fix: let the database enforce the events -> sessions cascade in DatabaseSessionService - #740
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: let the database enforce the events -> sessions cascade in DatabaseSessionService#740AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 6, 2026 15:15
An event row could outlive its session, because nothing but application code linked the two tables. The events table now declares a composite foreign key onto sessions with ON DELETE CASCADE. StorageSession's primary key moves to (app_name, user_id, id) to match adk-python. MikroORM binds a composite foreign key in the target's declaration order, so that order is what keeps the events column order and primary key unchanged. The MySQL index-limit test now derives the key column lengths from the properties that emit them, because the session relation owns three of the four columns.
Adding the events -> sessions foreign key fails on a PostgreSQL or MySQL database that still holds event rows whose session is gone, so the service would not start. ensureDatabaseCreated now deletes those rows and retries the schema update once. The purge does not run when the schema update succeeds, which is the common case.
The bare catch discarded every updateSchema failure before running a destructive DELETE, so a lock timeout or a permissions error triggered the orphan purge with no trace of the cause. The retry now logs the error it caught, and deleteOrphanedEvents logs the affected row count at warn, because that step deletes user rows during startup. The events key-column length test moves to schema_test.ts, which is where it belongs; operations_test.ts no longer carries a second suite named 'storage schema'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A
Problem: The
eventstable has no foreign key ontosessions, so an event row can outlive its session.deleteSessionissues two separateDELETEstatements, and a failure between them leaves orphaned event rows. Those rows are unreachable through the service, so they accumulate silently and skew any direct query overevents.adk-python's v1 schema prevents this with a composite foreign key.Solution: The
eventstable now declares the composite foreign key(app_name, user_id, session_id)ontosessions (app_name, user_id, id)withON DELETE CASCADE.StorageSession's primary key moves to(app_name, user_id, id), because MikroORM binds a composite foreign key in the target's declaration order; that order is what keeps theeventscolumn order and primary key unchanged.ensureDatabaseCreateddeletes orphaned rows and retries once, because the constraint cannot be added while they exist.deleteSessionkeeps its explicit event delete, because existing SQLite databases never acquire the constraint.Generated DDL (SQLite, fresh database):
Parity note: the primary-key order is adopted from
adk-python'sschemas/v1.py. Parity wins here because the order is observable in the physical schema. Local convention wins for the mirrors:appName,userIdandsessionIdstay declared aspersist: falseproperties, so every existing query filter keeps working.Operational impact:
init()after upgrade on PostgreSQL or MySQL runs DDL against live tables:sessions' primary key is dropped and recreated, and the foreign key is added toevents.sessionsholds one row per session, so this is bounded, but it takes an exclusive lock. Operators of large deployments may prefer to apply the DDL out of band.ADD CONSTRAINTfor SQLite.schemaGenerator: {createForeignKeyConstraints: false}to opt out of foreign-key DDL.validateDatabaseSchemaVersionaccepts one value and has no upgrade path, so a bump would break every existing deployment. Row contents do not change, so no bump is warranted.Existing test changed, deliberately: "keeps events composite key columns within the MySQL index limit" read
lengthoff theStorageEventpropertiesappName,userIdandsessionId. Those three are nowpersist: falsemirrors. They carry nolengthand no longer drive the DDL, so the old assertion cannot hold and could not be kept alongside a new one. The test now reads the four key-column lengths fromStorageEvent.idand thesessionrelation, which are the properties that emit those columns. It keeps its name and both assertions, 191 per column and the 3072-byte budget, and it moves fromoperations_test.tstoschema_test.ts, the file that mirrors the source it tests.Collision check:
gh pr list --repo AmaadMartin/adk-js --state all --limit 1000returns no open PR that touchescore/src/sessions/db/schema.ts. #641 and #492 changedeleteSessionandappendEvent; this PR does not touchdeleteSessionand makes a one-expression change inappendEvent. #291 adds a doc comment toparseDbUriinoperations.ts, a different region fromensureDatabaseCreated.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
New file
core/test/sessions/db/schema_test.ts(7 tests) drives a real in-memory SQLite database through MikroORM with no mocks: the generated DDL declares the cascade, theeventsprimary key keeps its column order, a rawDELETE FROM sessionsremoves the events, an event naming a missing session is rejected,pragma foreign_keysreturns1, and the read-only mirrors still servefindandnativeDelete. It also holds the key-column length test moved out ofoperations_test.ts.core/test/sessions/db/operations_test.tsadds five cases: the orphan repair runs and the schema update is retried, the caught failure is logged with its cause, a second failure propagates unchanged,deleteOrphanedEventsremoves only the row whose session is gone, and it reports how many rows it deleted.No CI check ran on this pull request, so I validated locally on the pushed commit:
tests/integration/build_setup/build_setup_test.tsdoes not run in my sandbox. Its fixtures runnpm install, which fails withE403against the registry mirror I am behind. The failure is in the fixture install step and is independent of this change.Coverage on the touched sources, measured with
--coverageover these test files:core/src/sessions/db/operations.tsis 100% of statements, branches, functions and lines.core/src/sessions/db/schema.tsis 99.23% of lines; the one uncovered line is the pre-existing non-string arm ofCamelCaseToSnakeCaseJsonType.convertToJSValue, which this change does not touch.Proof that the tests can fail. Each mutation was applied to the source, the tests were run, and the source was restored.
deleteRule: 'cascade'from thesessionrelation. "declares an events -> sessions foreign key that cascades on delete" failed withexpected ... to contain 'foreign key(...)... on delete cascade'. "deletes the events when the session row alone is deleted" failed withDELETE FROM sessions ... - SQLITE_CONSTRAINT: FOREIGN KEY constraint failed.StorageSession's primary key to(id, app_name, user_id)and setjoinColumnsto match. "keeps the events primary key columns in their original order" failed withexpected ... to contain 'primary key (\id`, `app_name`, `user_id`, `session_id`)'; the generated table wasprimary key (`id`, `session_id`, `app_name`, `user_id`)`.sessionrelation with the three scalar@PrimaryKeycolumns. Four of the six new tests failed, including "rejects an event that names a session which does not exist".ensureDatabaseCreated. "deletes orphaned events and retries when the schema update fails" failed withpromise rejected ... instead of resolving, and "propagates the error when the schema update fails again" failed withexpected ... 'second failure' but got 'first failure'.DELETE_ORPHANED_EVENTS_SQLtoDELETE FROM events WHERE 1 = 1. "removes only the events whose session is gone" failed withexpected [] to deeply equal [ 'live-event' ].'run'method argument from the purgeexecute()call, which is what makesaffectedRowsreal. "logs how many rows it deleted" failed: the log readDeleted undefined event rows ....errorfrom the retry log. "logs the schema update failure that triggered the retry" failed withReceived: "Schema update failed; retrying after deleting orphaned events."against the expected message ending inError: lock timeout.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
The SQLite path is covered end to end by
schema_test.tsagainst a real database. To check the PostgreSQL upgrade path against a live instance:main, create a session with several events, then runDELETE FROM sessions WHERE ...so the event rows are orphaned. ConfirmSELECT count(*) FROM eventsis non-zero.DatabaseSessionServiceagainst the same database. Confirminit()resolves and the orphaned rows are gone.sessions' primary key is(app_name, user_id, id)andeventscarries the foreign key withON DELETE CASCADE.init()emits no further DDL.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.