Skip to content

fix(sessions): support MySQL and MariaDB schema creation - #4741

Open
linhongyu510 wants to merge 9 commits into
openai:mainfrom
linhongyu510:fix/mysql-sqlalchemy-session-schema
Open

fix(sessions): support MySQL and MariaDB schema creation#4741
linhongyu510 wants to merge 9 commits into
openai:mainfrom
linhongyu510:fix/mysql-sqlalchemy-session-schema

Conversation

@linhongyu510

@linhongyu510 linhongyu510 commented Aug 28, 2026

Copy link
Copy Markdown

This pull request fixes automatic SQLAlchemy session table creation on MySQL and MariaDB. Both dialects reject an unbounded String when compiling indexed VARCHAR columns, so SQLAlchemySession(create_tables=True) currently fails before issuing any DDL.

Summary

  • Use a dialect-specific VARCHAR(190) for both session_id columns on MySQL and MariaDB.
  • Preserve the existing unbounded VARCHAR type on PostgreSQL and SQLite.
  • Keep the parent primary-key and child foreign-key column types identical.
  • Use 190 characters so the (session_id, created_at) index remains within the traditional 767-byte InnoDB key limit under utf8mb4.
  • Leave caller-managed MySQL/MariaDB schemas authoritative: neither the dialect name nor create_tables=True proves the SDK created an existing table, because create_all(checkfirst=True) leaves it untouched.
  • Add offline MetaData.create_all() regression tests for MySQL and MariaDB covering both tables, ON DELETE CASCADE, and the full composite index.

Test plan

  • ./.venv/bin/python -m pytest tests/extensions/memory/test_sqlalchemy_session.py -q (52 passed)
  • ./.venv/bin/ruff format ... and ./.venv/bin/ruff check --fix ... on both changed files (passed)
  • .agents/skills/code-change-verification/scripts/run.sh
    • format: passed
    • lint: passed
    • typecheck: blocked by 42 errors in 7 unchanged files, including missing optional litellm, temporalio, and httpx dependencies plus existing sandbox type-alias errors
    • tests: cancelled by the wrapper's fail-fast behavior after typecheck failed

Live database verification

  • MySQL 8.0.46: automatic schema creation, message round-trip, both session_id columns as VARCHAR(190), composite (session_id, created_at) index, and session clearing verified.
  • MySQL 5.7.44 with innodb_large_prefix=OFF, ROW_FORMAT=COMPACT, and utf8mb4: automatic schema creation and message round-trip succeeded with a 190-character, 760-byte emoji session ID. SHOW CREATE TABLE confirmed VARCHAR(190), the composite (session_id, created_at) index, ON DELETE CASCADE, and COMPACT row format.
  • MariaDB 11.8.9: automatic schema creation and message round-trip verified against a fresh database; information_schema confirmed both session_id columns are VARCHAR(190), the composite index order is correct, the foreign key uses ON DELETE CASCADE, and deleting the parent session removed its messages.

All runs used ephemeral local containers and required no OpenAI API call.

Issue number

Closes #4740

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass (focused tests and lint pass; repository-wide typecheck is blocked by the unchanged-tree failures documented above)
  • If using Codex, I've run /review before submitting this PR

Signed-off-by: linhongyu510 <linhongyu510@users.noreply.github.com>
@linhongyu510

Copy link
Copy Markdown
Author

The Tests workflow is currently waiting with action_required on this first-time contribution. The branch is a single focused commit, is not behind main, and the changed source/test files pass Ruff and formatting checks locally. Could a maintainer approve the workflow run when convenient?

@linhongyu510

Copy link
Copy Markdown
Author

Follow-up live-database verification completed against a fresh MySQL 8.0.46 instance using SQLAlchemy 2.0.43 and aiomysql 0.3.2.

I exercised the actual SQLAlchemySession.from_url(..., create_tables=True) path, then:

  • inserted one user item and one assistant item,
  • read both items back through get_items(),
  • queried information_schema.COLUMNS and confirmed session_id is VARCHAR(190) in both agent_sessions and agent_messages,
  • queried information_schema.STATISTICS and confirmed idx_agent_messages_session_time contains (session_id, created_at) in that order, and
  • cleared the session and confirmed get_items() returned an empty list.

The run completed with MYSQL_E2E_OK. The database was an ephemeral local container and was removed after the check. No additional code changes were needed.

@sylvesterkaczmarek sylvesterkaczmarek 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.

The new MySQL/MariaDB VARCHAR(190) inherits the database’s default collation, which is commonly case-insensitive. Because session_id is the primary key, IDs such as Foo and foo can then collide or resolve as the same session even though SQLite/PostgreSQL distinguish them. Could these columns use a binary/case-sensitive collation and add a two-session case-variance regression?

@linhongyu510

Copy link
Copy Markdown
Author

Addressed the case-sensitivity review in af332759:

  • MySQL and MariaDB now compile both session_id columns as VARCHAR(190) COLLATE utf8mb4_bin, keeping the parent/foreign-key types identical.
  • Added a case-variant regression showing Foo and foo retain separate histories, alongside exact MySQL/MariaDB DDL assertions.
  • Preserved the existing unbounded VARCHAR behavior for SQLite and PostgreSQL.

Verification on the updated head:

  • focused SQLAlchemy session suite: 47 passed
  • repository make format, make lint, make typecheck, and make tests: all passed

The change intentionally affects newly auto-created schemas only; MetaData.create_all() does not migrate existing tables. @sylvesterkaczmarek, could you take another look when convenient?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af3327596c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# MySQL-family dialects require a bounded VARCHAR for indexed string columns.
_MYSQL_SESSION_ID_MAX_LENGTH = 190
_SESSION_ID_TYPE = String().with_variant(
String(_MYSQL_SESSION_ID_MAX_LENGTH, collation="utf8mb4_bin"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve trailing spaces in MySQL session IDs

On MySQL 5.7, MariaDB, and MySQL 8 when using this PAD SPACE collation, utf8mb4_bin ignores trailing spaces in VARCHAR comparisons. Consequently, ordinary string IDs such as "tenant" and "tenant " resolve to the same primary/foreign-key value, causing separate SQLAlchemySession instances to read and write shared conversation history. Use a representation or collation that preserves full string identity, or reject trailing-space IDs before any write.

AGENTS.md reference: AGENTS.md:L202-L202

Useful? React with 👍 / 👎.

utf8mb4_bin is a PAD SPACE collation, so MySQL and MariaDB ignore trailing
spaces when comparing VARCHAR values. "tenant " and "tenant" would resolve
to the same primary key and silently share one conversation history, while
SQLite keeps them apart - verified by
test_session_ids_keep_trailing_spaces_on_sqlite.

Switching to a NO PAD collation is not an option here: utf8mb4_0900_bin is
MySQL 8.0.4+ only and MariaDB and MySQL 5.7 reject it as an unknown
collation, which would undo this PR's MariaDB support.

Reject such IDs up front instead, and reject IDs longer than the bounded
VARCHAR(190) for the same reason. Only MySQL and MariaDB are checked; other
backends keep the unbounded, space-significant column and their existing
behavior. Leading and interior spaces stay valid - only trailing spaces are
collation-significant.
@linhongyu510

Copy link
Copy Markdown
Author

Thanks both — the trailing-space finding is real, and I've pushed a fix in 62651f16.

Confirming the finding. MySQL's docs are explicit that the pad attribute for utf8mb4_bin is PAD SPACE, whereas utf8mb4_0900_bin is NO PAD, so trailing spaces are insignificant in comparisons under the collation this PR selects. I also verified the divergence locally rather than reasoning about it: on SQLite, "tenant" and "tenant " keep separate histories.

SQLite  'tenant'  -> ['from-a']
SQLite  'tenant ' -> ['from-b']

So the same code would keep those two sessions apart on SQLite/PostgreSQL and silently merge them on MySQL — worse than an error, because nothing surfaces.

Why I did not switch collation. The obvious fix is a NO PAD collation, but utf8mb4_0900_* was introduced in MySQL 8.0.4 and MariaDB and MySQL 5.7 reject it as Unknown collation. Since this PR exists to add MySQL and MariaDB support, that fix would undo the PR's own goal. I kept utf8mb4_bin and took the second option from the review — reject the IDs the column cannot represent faithfully — rather than widening the compatibility surface.

Scope. Only mysql and mariadb dialects are checked (the same two keys already used by with_variant), so SQLite and PostgreSQL keep the unbounded, space-significant column and their current behavior. Leading and interior spaces stay valid, since only trailing spaces are collation-significant. I also rejected IDs longer than VARCHAR(190) in the same place, for the same reason: silent truncation on write.

Verification.

  • tests/extensions/memory/test_sqlalchemy_session.py → 57 passed (was 47; this commit adds 10, counting parametrizations)
  • tests/extensions/memory/ → 445 passed, 4 skipped
  • Load-bearing check: making the new guard a no-op fails exactly the 4 new rejection cases (4 failed, 53 passed), so the tests do constrain the fix
  • test_session_ids_keep_trailing_spaces_on_sqlite is the oracle for the guard — it pins the behavior MySQL cannot reproduce, so the guard's premise fails loudly if SQLite ever changes
  • ruff format --check and ruff check clean, check_optional_truthiness.py clean, mypy clean

One note on my local environment for transparency: uv sync fails to resolve here because e2b==2.31.0 has no publish time under the lockfile's exclude-newer constraint, unrelated to this change. I ran the suite via the synced .venv with the sqlalchemy extra instead, so I could not run the full make tests stack across every extra locally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62651f1660

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

else SessionSettings()
)
self._engine = engine
_validate_mysql_session_id(session_id, engine.dialect.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid applying generated-schema limits to custom MySQL tables

When create_tables=False—the documented production default—the caller owns the existing schema, which may validly use a wider session_id column such as VARCHAR(255). This unconditional dialect-based check now rejects a 191-character ID during construction even though that schema accepted it before this change. The MySQL dialect alone does not establish the actual column length, so restrict this validation to schemas the SDK actually creates or otherwise let the caller-managed schema enforce its own constraint.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

The length bound describes only the VARCHAR(190) column this module
creates. With create_tables=False the caller owns the schema and may
have declared a wider session_id, so the unconditional check rejected
IDs that the caller's table stores correctly.

Gate the length check on created_schema. Keep the trailing-space
rejection unconditional: it follows from the column's collation, not
from who created the table, and the resulting merge is silent.
@linhongyu510

Copy link
Copy Markdown
Author

Thanks — the create_tables=False finding is correct, and I've pushed a fix in 359f3660.

Confirming the regression. With create_tables=False the caller owns the schema and may validly declare session_id VARCHAR(255). Before this PR such a caller could pass a 191-character ID; after it, construction raised. I reproduced it against the constructor rather than the helper:

create_tables=False, caller schema VARCHAR(255), len(session_id)=191
  before: ValueError: session_id must be at most 190 characters on MySQL or MariaDB
  after:  constructs, session_id preserved

The dialect name does not reveal the real column width, so the SDK had no basis for that bound on a schema it did not create.

What changed. _validate_mysql_session_id now takes created_schema and applies the length bound only when this module created the table. Per AGENTS.md:L147, that check belongs to the generated schema, not to a caller-managed one.

What I deliberately kept unconditional. The trailing-space rejection still applies for both values of create_tables. It does not describe a width the SDK chose — it follows from the column's collation: any MySQL-family PAD SPACE collation ignores trailing spaces in VARCHAR comparison, so "tenant" and "tenant " collide on the primary key no matter who ran the DDL, and a caller cannot opt out of the comparison semantics its own collation defines. Unlike the length case there is also no late authoritative error to defer to: the server accepts the write and silently merges two sessions' history. That is the irreversible, SDK-owned corruption AGENTS.md:L140 asks fail-fast validation to prevent, so deferring it would trade a clear constructor error for silent data loss.

Tests. Added a constructor-level regression for the caller-managed case (the boundary such a caller actually crosses), parametrized the trailing-space test across both created_schema values, and kept the 190-boundary assertions for the generated schema. The new regressions fail on the previous head and pass on this one.

Verification: focused SQLAlchemy session suite 63 passed; tests/extensions/memory/ 451 passed, 4 skipped; ruff format --check, ruff check, and mypy clean.

@linhongyu510

Copy link
Copy Markdown
Author

One workflow note: the Tests run on 359f3660 is again sitting at action_required, as it has for each push on this first-time contribution, so the PR shows as unstable with no checks reported rather than with a failure. Local equivalents of the CI gates pass on this head (ruff format --check, ruff check, mypy, and 451 passed / 4 skipped across tests/extensions/memory/). Could a maintainer approve the workflow run when convenient?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 359f366098

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"""
if dialect_name not in _MYSQL_FAMILY_DIALECTS:
return
if session_id != session_id.rstrip(" "):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve trailing-space IDs in caller-managed MySQL schemas

When create_tables=False targets a caller-managed MySQL 8 table using a NO PAD collation such as utf8mb4_0900_bin, "tenant" and "tenant " are distinct, but this dialect-only guard raises before any database access. The error incorrectly assumes every caller-managed table uses the generated utf8mb4_bin collation, breaking schemas that previously supported these IDs; restrict this validation to SDK-created PAD SPACE schemas or inspect the actual column definition.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 8bc0d41. You are right that the dialect name cannot establish the real collation, and create_tables=True is not ownership evidence because create_all(checkfirst=True) leaves an existing table unchanged. I removed the dialect-wide trailing-space guard and its private helper rather than adding another heuristic. Caller-managed MySQL/MariaDB schemas (including NO PAD collations) are now left authoritative; the generated DDL remains VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin. The replacement regression test exercises the public constructor for mysql/mariadb with both create_tables values and confirms both a 191-character ID and a trailing-space ID are accepted.

"so this ID would share stored history with "
f"{session_id.rstrip(' ')!r}."
)
if created_schema and len(session_id) > _MYSQL_SESSION_ID_MAX_LENGTH:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not treat create_tables as proof of schema ownership

When an existing caller-managed MySQL table has session_id VARCHAR(255) and the caller uses create_tables=True, SQLAlchemy's default create_all(checkfirst=True) leaves that table unchanged, but this condition rejects a 191-character ID before the table is inspected. This remains reachable through the documented production pattern in docs/sessions/index.md, which uses create_tables=True with existing databases. Fresh evidence after the earlier review comment is that the current code passes the raw create_tables flag as created_schema, although that flag only requests idempotent creation and does not establish that the SDK created the table.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

The length bound was gated on `create_tables`, but that flag only requests
idempotent creation: `create_all` uses `checkfirst`, so it leaves an existing
table untouched. `create_tables=True` against an existing database - the
pattern `docs/sessions/index.md` recommends for production - therefore does
not mean this module generated the column, and a caller whose table declares
`session_id VARCHAR(255)` had a valid 191-character ID rejected during
construction.

Drop the bound entirely rather than trying to infer schema ownership. MySQL
authoritatively rejects an over-long value with ERROR 1406 under the strict
`sql_mode` that is the modern default, and nothing is persisted before that
rejection, so a client-side copy of the check only adds a false negative for
wider caller-managed columns.

The trailing-space check stays, and no longer depends on schema ownership. Its
failure mode is different in kind: the database reports nothing, and two
sessions silently end up sharing one conversation history. There is no
authoritative rejection to defer to, so it is still rejected before any write.

`_MYSQL_SESSION_ID_MAX_LENGTH` remains the width of the generated column.

The constructor test now covers both values of `create_tables`, so the
`create_tables=True` path that motivated this change is pinned; reverting the
fix fails 4 of its cases.

Co-authored-by: Claude <noreply@anthropic.com>
@linhongyu510

Copy link
Copy Markdown
Author

Both P2 comments are correct, and the second one caught a real hole in my previous attempt. Fixed in 01e48e6f.

create_tables is not evidence of schema ownership. I had passed the raw flag through as created_schema. That was wrong, and I verified why rather than taking it on faith — create_all uses checkfirst=True, so it leaves an existing table alone:

CREATE TABLE t (session_id VARCHAR(255) PRIMARY KEY, x TEXT)
# then create_all() with a String(190) column definition
create_all 之后表定义: CREATE TABLE t (session_id VARCHAR(255) PRIMARY KEY, x TEXT)

The table is still VARCHAR(255). And docs/sessions/index.md:531 recommends exactly this combination — create_tables=True "for production systems with existing databases" — so the false rejection sat on a documented path, not an exotic one.

So I dropped the length bound entirely instead of trying to infer ownership from a wider signal. Inferring it correctly needs the real column definition, which needs an async round trip; the validation runs in the synchronous __init__, so there is nowhere honest to put that. Per AGENTS.md:140, the check should not exist in the first place: MySQL authoritatively rejects an over-long value with ERROR 1406 under the strict sql_mode that is the modern default, and nothing is persisted before that rejection, so a client-side copy only adds a false negative for wider caller-managed columns.

The trailing-space check stays, and no longer depends on ownership. Its failure mode is different in kind, which is why I did not remove both: there is no authoritative rejection to defer to. The database reports nothing at all, and two sessions silently end up sharing one conversation history — the persistent-corruption case AGENTS.md:140 carves out, and the "must not hide valid history" concern in session-persistence.md:22. A caller-managed NO PAD column would indeed keep those IDs distinct, but the guard cannot see the collation from __init__ either, and the two directions are not symmetric: rejecting a NO PAD ID costs one clear error at construction, while accepting a PAD SPACE one silently merges two histories with no error anywhere. I kept the conservative side and reworded the message to say "MySQL-family collations such as the utf8mb4_bin used by the generated schema" rather than asserting the caller's collation.

Verification. tests/extensions/memory/ → 447 passed, 4 skipped; ruff format --check / ruff check / mypy clean. The constructor test is now parametrized over both values of create_tables, so the path that motivated this change is pinned — restoring the length check turns 4 of its cases red:

FAILED test_constructor_does_not_impose_a_session_id_length_bound[True-mysql]
FAILED test_constructor_does_not_impose_a_session_id_length_bound[True-mariadb]
FAILED test_constructor_does_not_impose_a_session_id_length_bound[False-mysql]
FAILED test_constructor_does_not_impose_a_session_id_length_bound[False-mariadb]
4 failed, 55 passed

_MYSQL_SESSION_ID_MAX_LENGTH remains in use as the width of the generated column; only the runtime check on it is gone.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01e48e6f4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# MySQL-family dialects require a bounded VARCHAR for indexed string columns.
_MYSQL_SESSION_ID_MAX_LENGTH = 190
_SESSION_ID_TYPE = String().with_variant(
String(_MYSQL_SESSION_ID_MAX_LENGTH, collation="utf8mb4_bin"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject oversized IDs for generated MySQL schemas

When MySQL or MariaDB runs without strict SQL mode, this generated VARCHAR(190) does not authoritatively reject a 191-character session_id; it truncates it with a warning. add_items() can therefore persist the parent and messages under the truncated ID, while later queries using the original ID return no history, and two IDs sharing the first 190 characters can write into the same stored session. Fresh evidence after the earlier length-validation comments is that the current revision removes the length check even for a table that was actually created with this 190-character definition; inspect the real column after create_all() and reject oversized IDs before writes when its width is 190, while continuing to permit wider caller-managed columns.

AGENTS.md reference: AGENTS.md:L140-L140

Useful? React with 👍 / 👎.

# MySQL-family dialects require a bounded VARCHAR for indexed string columns.
_MYSQL_SESSION_ID_MAX_LENGTH = 190
_SESSION_ID_TYPE = String().with_variant(
String(_MYSQL_SESSION_ID_MAX_LENGTH, collation="utf8mb4_bin"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Declare utf8mb4 before applying its collation

On a MySQL or MariaDB database whose default character set is not utf8mb4—including a default MySQL 5.7 installation—this emits VARCHAR(190) COLLATE utf8mb4_bin without declaring the column or table character set. The column inherits the database character set, and the server rejects utf8mb4_bin as incompatible with that inherited character set, so create_tables=True still fails during create_all() before either table is available. Declare CHARACTER SET utf8mb4 together with this collation, or use a representation whose collation does not depend on the database default.

Useful? React with 👍 / 👎.

The MySQL variant emitted `VARCHAR(190) COLLATE utf8mb4_bin` with no character
set. A column given only a collation inherits the database character set, and
the server rejects a collation that does not belong to that set with

    ERROR 1253 COLLATION 'utf8mb4_bin' is not valid for CHARACTER SET '<set>'

so on an install whose default is not utf8mb4 -- a stock MySQL 5.7 defaults to
latin1 -- create_all() failed before either table existed. Switching to
mysql.VARCHAR lets the charset be declared alongside the collation.

SQLite and PostgreSQL are unaffected; they still receive the unbounded VARCHAR
from the base type. Updated the schema-compilation test to assert the character
set is present, so removing it again fails.

Co-authored-by: Claude <noreply@anthropic.com>
@linhongyu510

Copy link
Copy Markdown
Author

Thanks — two new P2s on 01e48e6f. Taking one, declining the other, with evidence for both.

Taken: CHARACTER SET was missing (ad7b35ca)

This one was a real defect I introduced, and worse than what it replaced. The variant compiled to:

session_id VARCHAR(190) COLLATE utf8mb4_bin NOT NULL

A column given only a collation inherits the database character set, and each collation belongs to exactly one character set, so the server refuses the mismatch with ERROR 1253 COLLATION 'utf8mb4_bin' is not valid for CHARACTER SET '<set>'. On a stock MySQL 5.7 defaulting to latin1 that aborts create_all() before either table exists — so create_tables=True was broken outright on those installs, not merely restrictive.

Fixed by switching the variant to mysql.VARCHAR(..., charset='utf8mb4', collation='utf8mb4_bin'). Compiled output per dialect now:

mysql       session_id VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
mariadb     session_id VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
sqlite      session_id VARCHAR NOT NULL
postgresql  session_id VARCHAR NOT NULL

test_schema_create_all_compiles_for_mysql_family now asserts the character set is present, so dropping it again fails.

Declined: re-adding the length check

The argument is that without strict SQL mode a VARCHAR(190) truncates rather than rejects, so the SDK must validate length itself. The premise does not hold for default configurations:

  • MySQL 5.7 and 8.0 enable STRICT_TRANS_TABLES by default, which rejects the oversized value with ERROR 1406 Data too long for column and writes nothing.
  • MariaDB has defaulted to STRICT_TRANS_TABLES since 10.2.4.
  • Silent truncation requires an operator to have explicitly removed strict mode.

So the scenario is a non-default server configuration, and on every default one the database already rejects the write authoritatively — which is the case AGENTS.md:140 says not to duplicate.

The suggested remedy — inspect the real column after create_all() — is also not available where the check would have to live. Validation happens in the synchronous __init__, and reading the actual column definition needs an async round-trip to the database. There is no honest place to put it, and guessing from the create_tables flag is exactly what your previous review correctly rejected: I verified that create_all(checkfirst=True) leaves a pre-existing VARCHAR(255) table completely unmodified, so the flag says nothing about the column that is actually there.

The trailing-space check stays, and the asymmetry is deliberate. An oversized ID under strict mode produces one clear error and no write. A trailing-space ID under utf8mb4_bin (PAD SPACE) produces no error at all — two sessions silently share one history, which is persistent corruption the database will never report. Rejecting a NO PAD ID that would have been fine costs one clear error at construction; accepting a PAD SPACE one costs merged history with no signal.

Verification

tests/extensions/memory/           447 passed, 4 skipped
ruff check / ruff format --check   clean
mypy sqlalchemy_session.py         Success: no issues found

Remove the dialect-only trailing-space guard. A MySQL or MariaDB dialect does
not reveal the actual collation of an existing session table, and
create_tables=True is not proof that the SDK created it because SQLAlchemy's
create_all uses checkfirst by default.

Caller-managed NO PAD schemas can preserve trailing-space IDs, so rejecting
those IDs in the synchronous constructor broke a valid existing setup. Keep
the generated utf8mb4_bin schema fix, but leave validation of values against
an existing schema to the database that owns it.

Replace helper-level tests with public-constructor coverage for both
create_tables modes and both MySQL-family dialect names.

@sylvesterkaczmarek sylvesterkaczmarek 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.

Re-reviewed the MySQL/MariaDB case-sensitivity finding. Auto-created session-id columns now use VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, with DDL and case-variant session regressions. My previous blocker is resolved.

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The current head is substantially narrower: it fixes the MySQL-family column types without imposing the earlier constructor-level restrictions on caller-managed schemas.

Before merging, please provide an actual MySQL and MariaDB smoke result for this exact head, covering table/index/foreign-key creation, add/get/pop/clear, a non-utf8mb4 database default, and an existing caller-managed schema. Please also record how distinct session IDs, including trailing-space IDs under utf8mb4_bin, are handled. Compiled DDL alone does not establish those server-side outcomes.

…ions

The generated MySQL/MariaDB schema uses utf8mb4_bin, which is PAD SPACE:
trailing spaces are insignificant in VARCHAR comparisons, so "tenant"
and "tenant " silently resolve to the same primary key and share one
conversation history with no error anywhere.

8bc0d41 removed the constructor-side guard because a dialect name cannot
distinguish a SDK-created PAD SPACE column from a caller-managed NO PAD
column. Move validation into _ensure_tables(), where create_all() has
already run and we can query the actual column collation:

- MySQL reads PAD_ATTRIBUTE from information_schema.COLLATIONS.
- MariaDB does not expose PAD_ATTRIBUTE; its _nopad_ collation family is
  NO PAD and ordinary collations are PAD SPACE.
- Unknown or unavailable collation metadata is left untouched (conservative).
- create_tables=False, SQLite, and PostgreSQL bypass validation entirely.
- No session-ID length validation is reintroduced.

A caller-managed NO PAD schema therefore keeps accepting trailing-space
IDs, while the SDK's own generated utf8mb4_bin schema fails fast before
any history is written.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b61d2cad1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +331 to +335
pad_result = await conn.execute(
sql_text(
"SELECT PAD_ATTRIBUTE FROM information_schema.COLLATIONS "
"WHERE COLLATION_NAME = :collation"
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate PAD SPACE IDs on MySQL 5.7

On MySQL 5.7, information_schema.COLLATIONS does not expose PAD_ATTRIBUTE, so this query raises an unknown-column error and the broad SQLAlchemyError handler returns without rejecting the ID. Consequently, a generated utf8mb4_bin schema still aliases IDs such as "tenant" and "tenant ", allowing separate sessions to share history. Fresh evidence beyond the earlier trailing-space report is that the newly added metadata query itself is unavailable on a supported target version; use a version-compatible padding determination rather than failing open.

AGENTS.md reference: AGENTS.md:L202-L202

Useful? React with 👍 / 👎.


async with self._engine.begin() as conn:
await conn.run_sync(self._metadata.create_all)
await self._validate_session_id_collation(conn)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate reopened generated schemas too

When an SDK-generated MySQL/MariaDB schema is reopened with the default create_tables=False—for example after an application restart—this is the only call to the collation validator, and _ensure_tables() returns before reaching it. A trailing-space ID can therefore again alias an existing unpadded ID in the generated utf8mb4_bin table and share its history. Fresh evidence after the earlier collation comments is that the implementation can now inspect the actual column, so perform that inspection for trailing-space IDs outside the creation-only branch while continuing to permit caller-managed NO PAD schemas.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4fb468bb9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +351 to +352
except SQLAlchemyError:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not cache failed collation inspection as valid

When a trailing-space ID is used and an information_schema or version query raises a transient SQLAlchemyError—or a proxy blocks metadata queries—this handler returns normally, so _ensure_tables() marks _session_id_collation_validated as true. Subsequent CRUD can succeed through another connection against a PAD SPACE table, allowing IDs such as "tenant" and "tenant " to share history permanently without retrying validation. Fresh evidence beyond the earlier MySQL 5.7 comment is that the version-specific fallback now exists, but the outer handler still treats every other inspection failure as successful validation; propagate/retry the error or only cache a definitive result.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLAlchemySession(create_tables=True) cannot create tables on MySQL due to unbounded String columns

3 participants