Skip to content

fix(storage-postgres): store attribute names containing U+0000 in the catalog - #833

Open
LeeroyHannigan wants to merge 6 commits into
mainfrom
fix/367-pg-catalog-nul-names
Open

LeeroyHannigan wants to merge 6 commits into
mainfrom
fix/367-pg-catalog-nul-names

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

This PR lets a PostgreSQL deployment store attribute names that hold U+0000 in its catalog. DynamoDB accepts U+0000 in a key attribute name, an index key attribute name and a projected attribute name. DescribeTable returns each name byte for byte (measured on 2026-09-18 and 2026-10-24, and again on 2026-11-04). PostgreSQL jsonb refuses U+0000. So on main at 25ee109, CreateTable with such a name answers 500 on PostgreSQL.

The stored form

crates/storage-postgres/src/catalog_json.rs defines the stored form of the jsonb documents that carry attribute names. The server stores U+0000 as U+0001 U+0001, and U+0001 as U+0001 U+0002. It stores every other character as itself, so a name without these two characters keeps its old stored form. Every write of these documents goes through the escape, and every read goes through the inverse. The documents are:

  • in the catalog database: tables.key_schema, tables.attribute_definitions, indexes.key_schema, indexes.projection, backups.key_schema, backups.attribute_definitions, backups.vector_indexes, backup_definitions.definition, backup_index.source_table_details_json, vector_indexes.vector_attribute, vector_indexes.search_schema and vector_indexes.projection
  • in the data database: gsi_pending.index_context, which holds the index context of the queued index updates

The item documents do not go through the escape. #368 covers U+0000 in item data.

Migration 10, attribute_name_escape

The migration uses the framework of ADR-0023. On PostgreSQL it applies the escape once to each stored document that holds U+0001. Releases before migration 10 stored the names raw. Without the migration, a stored name with U+0001 followed by U+0001 or U+0002 would read back as another name.

  • The migration has a catalog body and a data body. Each body runs in one transaction with its ledger row.
  • It writes one note for each column that it changed, for example tables.key_schema: 1 document rewritten.
  • It is Kind::Breaking. A binary without the decoder reads an escaped name as another name.
  • It is not convergent, because an escaped U+0001 and a raw U+0001 U+0002 are the same bytes. So migrate --yes --reconverge skips it.
  • It does not apply to SQLite, Cassandra and MongoDB. SQLite and Cassandra store these documents as JSON text, where U+0000 is the six characters \u0000. MongoDB stores them as BSON, whose strings hold U+0000.

The backup and restore jobs

#700's backup job writes the source table, attribute names included, to backup_index.source_table_details_json through claim_backup and its status write. row_from_tuple reads that column for every backup_index read. Without the escape, the insert for a source table with a U+0000 attribute name fails on PostgreSQL. #740's restore job reads tables.key_schema and tables.attribute_definitions in restore_target_shape, and indexes.key_schema and indexes.projection in restore_indexes. With restore_indexes raw, a restored GSI keyed on a U+0001 name has no items. With restore_target_shape raw, the restore of a table with a U+0001 key name removes its target.

7739167 escapes both writes and decodes the reads in row_from_tuple and restore_target_shape. restore_indexes gets its decode in the merge commit b3bd377. Main moved the index read into that function. This PR decodes that read, so the merge moved the decode with it. Migration 10 rewrites backup_index.source_table_details_json together with the other catalog documents.

Migration 3, sort_key_definitions, is convergent, so migrate --yes --reconverge runs it again on a catalog at migration 10. There it reads the stored form. Its repair_tables step compares and writes stored names, so it stays consistent. Its repair_backups step looks up a missing sort key name in the item data, which holds raw names. That lookup misses a sort key name that holds U+0000 or U+0001. The case needs a backup that lost its sort key definition to the UpdateTable fault of #259, written to a migrated catalog by an older release. This PR does not change migration 3.

Documentation and CI

  • docs/manuals/07-upgrade-manual.md describes migration 10 in step 4 of the general upgrade procedure, with backup_index.source_table_details_json in its list of documents.
  • docs/differences-from-dynamodb.md has a row for these names.
  • docs/adr/0023-catalog-migrations.md states that each migration is breaking on every backend it applies to. Before this change, it named five migrations. The upgrade manual keeps main's sentence, which now says that migrations 1 to 7, 9 and 10 are breaking.
  • CONTRIBUTING.md renumbers its example migration from 10 to 11.
  • The sample outputs show catalog migration 10.
  • The no-pgvector job in .github/workflows/integration.yml also runs the new test binary migrations_name_escape.

This PR replaces the catalog part of #531. #531 added catalog version 0.0.5 with its own migration code. Main migrates through the ADR-0023 framework, so this PR builds its migration on that framework.

Main now runs migrations 1 to 9: #671 took 6, stream_pending is 7, #740 took 8 and #474 took 9. So this PR's migration is now 10. #368, #819 and #823 add migrations too. Each pull request that merges after another one of them must take the next free id.

The merges with main

The merge of main at 38992eb (5a0de15) moved this PR's migration from 6 to 7. It also ported catalog_nul_names.rs and migrations_name_escape.rs to LegacyBackupReader, which replaced BackupEngine in #671. The assertions did not change.

The merge of main at 35edb77 (b3bd377) moves the migration from 7 to 10. It keeps main's migrations 7 to 9 in the registry, in each backend's migration list, and in the MongoDB and SQLite migration tests. It also sets main's new GsiInput.warm_throughput field in this PR's tests. The merge of main at 887b6e2 (cdd89ca) has no conflicts.

Why

Closes #367. The issue reports two faults on PostgreSQL. #830, now merged, fixes the table name fault. This PR fixes the attribute name fault.

Testing done

On 2026-11-10. The tip cdd89ca differs from 7739167 only by main's #842 and #840, which change docs and crates/server. The runs marked "escape commit" ran on 7739167 before its last change, which adds deadlines to two waits in catalog_nul_backup_jobs. After that change the binary passed again, 3 of 3, in a postgres:16 container.

  • cargo fmt --all -- --check: clean on each of the three new commits.
  • cargo +1.97.0 clippy --locked --all-targets -- -D warnings: clean on b3bd377 for extenddb-storage, extenddb-storage-sqlite, extenddb-storage-mongodb, extenddb-storage-cassandra and extenddb-storage-postgres. Clean on 7739167 for extenddb-storage-postgres, the one crate that commit changes.
  • cargo check --locked --workspace on the tip: clean.
  • Lib tests on the escape commit: extenddb-storage 240, extenddb-storage-postgres 84, extenddb-storage-sqlite 283, extenddb-storage-mongodb 172 (66 ignored), extenddb-storage-cassandra 129 and extenddb-engine 636 passed. Two SQLite backup_catalog::write_lock_tests failed on the test host. There the filesystem backup store refuses its root, because a parent directory belongs to another user. Both pass in a container.
  • cargo test --locked -p extenddb-storage-postgres --tests on the escape commit, against pgvector/pgvector:pg16: 409 passed and 2 ignored, in the library and 30 test binaries. 5 more tests failed on the test host for the same store root reason: 3 store_identity tests in backup_restore from main, and 2 tests of the new catalog_nul_backup_jobs. Both binaries pass in a postgres:16 container, 3 of 3 each.
  • The migration tests of the no-pgvector job on the escape commit, against postgres:16 without pgvector: 50 passed, 0 failed. By binary: migrations_crash 9, migrations_fresh 6, migrations_golden 7, migrations_guards 7, migrations_name_escape 2, migrations_released 12 and migrations_runs 7.
  • MongoDB (mongo:7 replica set) on the escape commit: migrations_fresh 8, migrations_locks 11 and migrations_released 5 passed.
  • Cassandra: not run here. CI runs its test binaries.

The new binary catalog_nul_backup_jobs has three tests:

  • A store-backed backup and restore keeps key schema, GSI key and projected names that hold U+0000 and U+0001, byte for byte.
  • Items restored by the restore job reach a GSI keyed on a U+0001 name. A Query on the restored index returns its three items.
  • A backup_index row keeps such names through claim_backup and the status write.

One at a time, a raw restore_target_shape, restore_indexes, row_from_tuple or status write fails at least one of these tests. The code of the merge commit b3bd377 fails all three. The golden test of migration 10 covers a backup_index row that holds U+0001.

Earlier, on the branch at base 25ee109, before the merges of main:

  • Cassandra (cassandra:4.1): --test migrations 21 passed, and --test migrations_stopped 7 passed.
  • Wire tests through boto3, against a local server per backend, from a release build of the branch. The set is tests/python/test_nul_attribute_names.py (5 rows), test_tables and test_gsi.
Backend Result
SQLite 33 passed
PostgreSQL 33 passed
Cassandra (cassandra:4.1) 33 passed
MongoDB (mongo:7 replica set) 33 passed

The 5 new rows against a main 25ee109 PostgreSQL binary: 4 failed with 500 InternalServerError on CreateTable, and the U+0001 row passed.

Against Amazon DynamoDB in us-east-1 on 2026-11-04, from 22:45 to 22:56 UTC: the 5 new rows passed.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: ADR-0023, catalog migrations. This PR adds migration 10 under it.

Breaking changes

Migration 10 is breaking on PostgreSQL. A binary without migration 10 refuses a PostgreSQL catalog that has it, with exit status 4. To roll back, restore the backup taken before extenddb migrate --yes. SQLite, Cassandra and MongoDB record migration 10 as not applicable, which is additive.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

… catalog

DynamoDB accepts U+0000 in a key attribute name, an index key attribute
name and a projected attribute name. DescribeTable returns each name
byte for byte (measured on 2026-09-18 and 2026-10-24, and again on
2026-11-04). PostgreSQL jsonb refuses U+0000, so CreateTable with such a
name answered 500 on PostgreSQL.

catalog_json stores U+0000 in the jsonb documents that carry attribute
names as U+0001 U+0001, and U+0001 as U+0001 U+0002. Each read applies
the inverse. A name without either character keeps its stored form. The
documents are tables.key_schema, tables.attribute_definitions,
indexes.key_schema, indexes.projection, backups.key_schema,
backups.attribute_definitions, backups.vector_indexes,
backup_definitions.definition, vector_indexes.vector_attribute,
vector_indexes.search_schema and vector_indexes.projection in the
catalog database, and gsi_pending.index_context in the data database.
Item data does not go through the escape.

Migration 6, attribute_name_escape, applies the escape once to each
stored PostgreSQL document that holds U+0001. It has a catalog body and
a data body, and each body runs in one transaction with its ledger row.
It writes one note for each column that it changed. It is breaking,
because a binary without the decoder reads an escaped name as another
name. It is not convergent, because an escaped U+0001 and a raw U+0001
U+0002 are the same bytes. It does not apply to SQLite and Cassandra,
which store these documents as JSON text, or to MongoDB, whose BSON
strings hold U+0000.

The upgrade manual, ADR-0023, CONTRIBUTING.md and the sample outputs
name migration 6. The CONTRIBUTING.md example becomes migration 7. The
no-pgvector CI job also runs the new migrations_name_escape tests.

Closes #367
Main added migration 6, backup_catalog_index, which this branch had
used for attribute_name_escape. The merge keeps main's migration 6 as
it is and moves attribute_name_escape to id 7 in the registry, in the
PostgreSQL module (the file, module, SOURCE and FINGERPRINT are now
m0007_attribute_name_escape), in the golden fixture, which is now
0007_attribute_name_escape.txt, and in the SQLite, MongoDB and
Cassandra NotApplicable arms. The RELEASED_THROUGH comment names 7.

crates/storage/src/migrations/registry.rs: both sides added an entry at
id 6. The result keeps main's BackupCatalogIndex at 6 and adds
AttributeNameEscape at 7.

crates/storage-postgres/src/migrations/mod.rs: both sides added an arm
in migration() and an id in the transactional arm of runs_in(). The
result has both arms and both ids.

crates/storage-sqlite, storage-mongodb and storage-cassandra
src/migrations/mod.rs: both sides added an arm. The result has main's
BackupCatalogIndex arm followed by this branch's NotApplicable arm.

crates/storage-mongodb/tests: main replaced the literal catalog
version with MigrationId::LATEST.number(), which this branch had moved
to 6. The result keeps main's form. migrations_fresh.rs lists id 7 as
a not-applicable step and ledger row and names it in the reconverge
comment, the later-binary rows use id 8, and the step counts in
migrations_locks.rs and migrations_released.rs are 8.

CONTRIBUTING.md: both sides renumbered the table_note example to 7. The
result renumbers it to 8, the next free id after attribute_name_escape,
and lists both new ids in the runs_in() example.

docs/manuals/07-upgrade-manual.md: the result keeps main's sentence on
migration 6 and its section on the backup catalog index, this branch's
sentence that each migration is breaking on every backend it applies
to, and this branch's description of the escape as migration 7. The
sample outputs in the getting started guide, the quickstart, the admin
guide and the install guides, and the row in the differences table,
name migration 7.

crates/storage-postgres/tests/catalog_nul_names.rs and
migrations_name_escape.rs: main replaced BackupEngine with
LegacyBackupReader, whose create and restore take a JobPermit. The two
tests call create, describe, restore_source and restore with
JobPermit::detached().
test_a_migration_6_that_fails_in_its_backfill_stays_pending_and_resumes
expected migration 6 to be the only pending migration of a catalog at
migration 5. This branch adds migration 7, so such a catalog has 6 and
7 pending. The check now asks for migration 6 among the pending
migrations and for no pending migration below it.
@LeeroyHannigan LeeroyHannigan added release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release labels Nov 6, 2026
@jcshepherd jcshepherd self-assigned this Nov 9, 2026
@jcshepherd

Copy link
Copy Markdown
Collaborator

General: you'll want to merge and resolve conflicts, and it may expand the scope of this PR. After merging from main, a restore will read the escaped names as the literal names, so a restored GSI keyed on a U+0001 name will have no items. crates/storage-postgres/src/restore_engine.rs:64-91 and crates/storage-postgres/src/backup_engine.rs:76-97 (on main)

#740 added the new restore job, and #700 added the new backup job. They merged after this PR's base. There are three places where escaped columns are read or written "raw":

restore_target_shape (restore_engine.rs:64-91 on main) reads tables.key_schema and tables.attribute_definitions with serde_json::from_value.
restore_indexes (backup_engine.rs:76-97 on main) reads indexes.key_schema and indexes.projection with serde_json::from_value.
claim_backup and set_status (backup_catalog.rs:492-523 and :246-273 on main) bind backup_index.source_table_details_json raw, and row_from_tuple (:128) reads it raw. job.rs:317 fills it with the source table, names included.

@jcshepherd

Copy link
Copy Markdown
Collaborator

The migration number is also going to need to be bumped again.

Ping me after you merge with main, and I can approve.

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

Needs to be synced from main and conflicts fixed; additionally main will bring down a couple new areas where escaping will be needed. Once that is taken care of and the migration number refreshed, I can approve.

Main added migrations 7 (stream_pending), 8 (restore_progress) and 9
(warm_throughput). This branch used id 7 for attribute_name_escape. The
merge keeps main's ids and moves attribute_name_escape to id 10. The
registry, the PostgreSQL module and the SQLite, MongoDB and Cassandra
NotApplicable arms use the new id. The module file is now
m0010_attribute_name_escape.rs, with SOURCE and FINGERPRINT to match.
The golden fixture is now 0010_attribute_name_escape.txt. The
RELEASED_THROUGH comment names 10.

crates/storage/src/migrations/registry.rs: both sides added an entry
at id 7. The result keeps main's entries at 7, 8 and 9 and adds
AttributeNameEscape at 10.

crates/storage-postgres/src/migrations/mod.rs: both sides added arms
in migration() and ids in the transactional arm of runs_in(). The
result has main's three arms, then this branch's arm, and all four ids.

crates/storage-sqlite, storage-mongodb and storage-cassandra
src/migrations/mod.rs: both sides added arms. The result has main's
arms, then this branch's NotApplicable arm.

crates/storage-sqlite/src/migrations/tests.rs and released_tests.rs:
both sides changed two tests to count only the migrations that have a
body on SQLite. The result is main's form of each test. In tests.rs,
main checks the applied and not_applicable counts, where this branch
checked the applied count and the total. In released_tests.rs, both
forms count the convergent migrations that have a body on SQLite. The
Migration import that this branch added is no longer used, so the
result drops it.

crates/storage-mongodb/tests/migrations_fresh.rs: the result lists id
10 as a not-applicable step and ledger row, and the reconverge comment
names it. The later-binary ledger rows use id 11, and the LATEST
assertion is 10. migrations_locks.rs and migrations_released.rs count
11 steps.

crates/storage-postgres/src/create_table.rs: main added the warm
throughput record of each global secondary index. This branch writes
the key schema and projection of that index through to_catalog_json.
The result keeps both.

crates/storage-postgres/src/backup_engine.rs: main moved the index
read of the legacy restore copy into restore_indexes(). This branch
read the key schema and projection in that copy through
from_catalog_json. The result calls restore_indexes() from the copy,
and restore_indexes() reads both documents through from_catalog_json.
The import line keeps main's RestoreSource and this branch's
catalog_json functions.

CONTRIBUTING.md: main renumbered the table_note example to 10. The
result renumbers it to 11, the next free id after
attribute_name_escape. The runs_in() example lists main's three ids and
AttributeNameEscape.

docs/manuals/07-upgrade-manual.md: the result keeps main's text on
migrations 6 to 9. This branch's sentences on the escape follow the
paragraph on migration 9 and name migration 10. The sentence on kinds
is main's, with migration 10 added to the breaking migrations. Step 4
of the general upgrade procedure names migration 10.

docs/getting-started.md, docs/manuals/04-quickstart-setup-guide.md,
08-install-linux.md and 09-install-macos.md: the sample outputs show
migration 10. The admin guide example and the row in the differences
table also name migration 10.

crates/storage-postgres/tests: main added warm_throughput to GsiInput,
so the literals in catalog_nul_names.rs and catalog_nul_index_writes.rs
set it to None. migrations_name_escape.rs names migration 10 in its
test names, skip messages and assertions.
The backup job (#700) and the restore job (#740) merged after this
branch's base. They read and write catalog documents that carry
attribute names without the escape of catalog_json.

- backup_catalog.rs: claim_backup and set_status bind
  backup_index.source_table_details_json through escape_json, and
  row_from_tuple reads it through unescape_json. Without the escape in
  claim_backup, PostgreSQL refuses a source table with U+0000 in a
  name, and CreateBackup fails. Without the escape in set_status, the
  job's last write fails, and the backup stays CREATING. Without the
  unescape, DescribeBackup returns the stored form of each name.
- restore_engine.rs: restore_target_shape reads tables.key_schema and
  tables.attribute_definitions through from_catalog_json. Without it,
  a store-backed restore looks for the stored form of each key name on
  the items, and the restore fails.

The merge commit before this one reads indexes.key_schema and
indexes.projection through from_catalog_json in restore_indexes(),
which main split out of the legacy restore copy.

Migration 10 also rewrites backup_index.source_table_details_json,
which migration 6 adds. The module docs of catalog_json.rs and of the
migration, and step 4 of the upgrade manual, list the column.

Tests:

- catalog_nul_backup_jobs.rs is new. The first table has a U+0000
  name and a U+0001 name in its key schema, and a GSI keyed on a
  U+0001 name. The backup job writes it to a filesystem store, and the
  restore job restores it. DescribeBackup and DescribeTable of the
  restored table return each name byte for byte.
- The second table has items. Its key names and the key of one GSI
  hold U+0001, and the key of a second GSI holds U+0000. After the
  backup and the restore, a Query on the restored GSI returns the
  items. The PostgreSQL item columns cannot hold a name with U+0000
  yet. So only the first table has U+0000 in its key schema, and it
  has no items.
- The third test writes a backup_index row through claim_backup and
  set_status, and reads it back unchanged. It checks that the stored
  text holds the stored form of each name.
- migrations_name_escape.rs: the golden test seeds two backup_index
  rows as a release before migration 10 wrote them. The fixture shows
  the raw names rewritten, the other row unchanged, and the note
  "backup_index.source_table_details_json: 1 document rewritten".
Main at 887b6e2 adds two commits since the previous merge. #842 lists
what the audit log records. #840 refuses access key import unless
allow_credential_import is true. Neither commit adds a migration, so
attribute_name_escape stays at id 10.

The merge has no conflicts. Both sides edited
docs/manuals/07-upgrade-manual.md, docs/differences-from-dynamodb.md,
docs/getting-started.md and docs/manuals/05-admin-guide.md. Main
changed the text on credential import and audit logging, and added a
v0.1.15 behavior change that needs no catalog migration. This branch
changed the migration list, the sample outputs and the escape. The
changes are in separate paragraphs and table rows. The Current Status
section lists migrations 1 to 10. Each sample output and example still
names migration 10, so the merge changes no line beyond main's.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

@jcshepherd I merged main twice, 35edb77 in b3bd377 and 887b6e2 in cdd89ca. The migration is now 10.

The three places you named now go through catalog_json:

  • restore_target_shape decodes tables.key_schema and tables.attribute_definitions (7739167).
  • restore_indexes decodes indexes.key_schema and indexes.projection. Main moved the index read into that function, and the merge b3bd377 moved this PR's decode with it.
  • claim_backup and set_status escape backup_index.source_table_details_json, and row_from_tuple decodes it (7739167).

Migration 10 also rewrites backup_index.source_table_details_json.

I searched crates/storage-postgres for other raw reads and writes of the escaped documents. Outside migration 3 I found none. Migration 3 reads the stored form when --reconverge runs it again after migration 10. The PR body describes that case, which this PR leaves as it is. Main's other new JSON columns hold no attribute names (warm_throughput) or hold item images (stream_pending.record_data, which #368 covers).

The new test binary catalog_nul_backup_jobs backs up and restores a table with U+0000 and U+0001 names through the jobs. A Query on the restored GSI keyed on a U+0001 name returns its items. A raw restore_target_shape, restore_indexes, row_from_tuple or status write, one at a time, fails at least one of its three tests.

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

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] PostgreSQL: U+0000 in key schema attribute names returns 500; U+0000 in a table name fails in authorization instead of validation

2 participants