Skip to content

Access can see and use the tables the engine creates; freed space across sessions - #3

Merged
markmario merged 7 commits into
mainfrom
fix/engine-follow-ups
Oct 5, 2026
Merged

markmario merged 7 commits into
mainfrom
fix/engine-follow-ups

Conversation

@markmario

Copy link
Copy Markdown

Follow-ups to #2: qcatco/exportmdb.interop#22 (Access could not see a table the engine creates) and #23 (space freed in an earlier session was not used again). Fixing #22 with Access as the referee turned up the rest, and every item is checked against ACE 16.

Commits

  1. Text index keys are the ones Access writes (8e41563).
    • The port read the codes files' dropped leading zero ("SC" is 0x0C) as no bytes at all, so #, $, the space, control characters and many accents left their keys.
    • A character with no extra codes still shifted every later accent.
    • Ligatures (Æ, ß, ffi, …) take one place per letter.
    • Surrogate pairs stay out of the key.
    • Measured over every BMP character between a and é, plus supplementary characters: 63,499 values, every key now equal to ACE's.
    • A golden file of ACE's keys (1,863 values, ascending and descending) is the regression test; on the old encoder 2,370 of its 3,726 keys differ.
  2. Tables the engine creates are ones Access can see and use (442bbf8).
    • The catalog row: ParentId = the Tables container, the Admin user as Owner.
    • Entries in MSysObjects' Id and ParentIdName indexes, and MSysACEs permissions as Access gives its own new tables.
    • Names written uncompressed where the column does not allow compression.
    • Usage maps on a data page, not a reference map's page type.
    • The index writer rewritten to Access's rules: tail pointers, 0 for no page, TDEF and level in each page's header, used-pages maps, shared-prefix pages read, splits at any depth.
    • Keys written per column, GUIDs as Access writes them.
    • Pages the engine appends leave the database's global free-page map. This one reaches ExportMdb's exports too: Access handed those pages out again and wrote over them.
    • A duplicate table name, and a key on a column whose keys are not written, are refused.
  3. Space a delete freed is used again after the file is reopened (61f3b99), through the column's free-space usage map, as Access keeps it.
  4. Deletes and updates keep the primary key (8eaea37). A deleted row's entry stayed behind, so ACE counted 90 rows in a table of 70.

Evidence

ACE 16 matrix, run against the engine's tables. Before this PR nothing passed: ACE could not find the table.

  • Names: #Hash, $Dollar, Café, naïve résumé, ÆØÅ, Ελληνικά, spaces, O'Brien.
  • Keys: a primary key of 3,000 rows, which ACE then added 2,000 to (splitting the engine's index pages), a text key, a two-column key and a memo.
  • Scale: 300 tables, so the catalog's indexes split.
  • Both directions: the engine reads ACE's rows, ACE reads the engine's later tables.
  • DAO compacts the file with every table and row intact. Before the global-map fix it dropped three tables.

Typed keys: Long, Int, Byte and GUID keys equal ACE's in both directions.

Tests: the new engine tests need no Access. The corpus's Access-made tables are the reference: a new table's catalog row and permissions must equal those of an Access-made table in the same file, for files from Access 2000 to 2010. Each new test was seen failing on the code before it:

  • 5 text-key tests;
  • 32 catalog and storage tests;
  • 3 cross-session tests;
  • 4 primary-key upkeep tests.

dotnet test: 631 of 631.

Owner and permissions

How these were derived:

  • The Admin user's SID is the system objects' owner XOR 0x0102. This holds for every Access-made table of the corpus, from Access 97 to 2019; the only exception is a table Jackcess made.
  • A new table gets an ACE for each inheritable Tables-container ACE holding all of 0x000FFEFF. This matched Access's own tables in nearly every corpus file. The exceptions are compaction's MSysCompactError tables, tables created under other permissions, and Jackcess-made tables.

The ExportMdb.Interop submodule bump, with ACE tests in its classic build, follows in a PR there. That PR closes #22 and #23.

https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY

A text value's index key (General Legacy sort order) differed from Access's for
many characters, so Access could not find such a value through an index - and a
table whose name has one, through MSysObjects' ParentIdName index:

- The codes files drop a code's leading zero ('#' is "SC", 0x0C; the space is
  "S7"), and 1,065 lines of the two files have such a code. The port read them
  as no bytes, so '#', '$', the space, control characters and many accents left
  their keys. Jackcess pads the zero back.
- A "Z" character with no extra codes counted as one with an empty run, moving
  the accent of every later character one place.
- Access sorts 28 characters as two or three letters (AE for the ligature, ss,
  ffi) and gives each letter its own place in the accents that follow. The
  codes files do not say which; the list is what ACE 16 wrote.
- Surrogate pairs stay out of the key: ACE 16 writes none for them (Jackcess's
  computed codes are not what Access writes).

Measured with ACE 16 indexing every BMP character between 'a' and an accented
letter, plus supplementary characters: 63,499 values, every key now equal. The
golden file holds ACE's keys, ascending and descending, for 1,863 values; on the
old encoder 2,370 of its 3,726 keys differ.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
A table Database.CreateTable made was invisible to ACE (qcatco/exportmdb.interop#22).
Every reason, each found by ACE 16 refusing something, and fixed as Access does it:

- The catalog row: ParentId was 0 and Owner empty. Access gives a table the "Tables"
  container's Id and the Admin user as owner; the Admin user's SID is the system
  objects' owner with 0x0102 mixed in, in every Access-made table of the corpus
  (Access 97 to 2019).
- MSysObjects' indexes (Id, and ParentIdName, through which Access finds a table
  by name) had no entry for the row; MSysACEs had no permissions for the table.
  The table now gets an entry in each, and an ACE for each inheritable ACE of the
  Tables container holding the full table permissions - what Access writes, and
  what the corpus's Access-made tables have.
- MSysObjects' Name does not allow Unicode compression, and the engine compressed
  every text value: ACE read the name as UTF-16 and its table list came back empty.
  Text is compressed only in columns that allow it; the engine's own Text and Memo
  columns allow it, as Access's new fields do.
- A table's usage maps were on a page of type 0x05, a reference map's page type;
  Access keeps the rows on a data page owned by no table, and refused the table
  ("Not a valid bookmark").
- The index writer is rewritten to Access's rules, so Access reads and adds to its
  trees: entries sorted by key then row; a node holds the last entry under each
  child but its last, which its tail leads to; each level linked through
  prev/next with 0 for no page; the page's TDEF and level in its header; every
  page in the index's used-pages usage map (a primary key now has one, on its
  table's usage-map page as Access keeps it); pages Access wrote with a shared
  prefix read and rewritten; splits at any depth, the new root patched into the
  right index block. Pages earlier versions wrote are read either way.
- Keys are written per column as Access writes them (IndexKeys): a flag for each
  column of a composite key, and a GUID big-endian in 8-byte segments with their
  length bytes. Checked against ACE for Long, Int, Byte and GUID, both directions.
- Pages the engine appended stayed free in the database's global usage map (page
  1), so Access handed them out again and wrote over them: tables went missing
  when ACE added rows, and DAO's compact dropped them. A page the engine appends
  now leaves the map; past the inline map's window the map becomes a reference
  map, as Jackcess keeps it. This reaches any file the engine adds pages to,
  ExportMdb's exports included.
- A second table of the same name, and a primary key on a column whose keys are
  not written, are refused.

Checked with ACE 16 against ten table names (#, $, accents, Greek, spaces), a
primary key of 3,000 rows ACE then added 2,000 to, text and two-column keys, a
memo, 300 tables, and DAO compacting the file: every check passes, and none did
before. The new tests here need no Access: the corpus's Access-made tables are
the reference. All 32 fail on the previous code; 624 of 624 pass now.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
Space a delete or an update freed on a long-value column was kept only in memory,
so in a later session (another Database.Open) it was used again only on the
column's last page (qcatco/exportmdb.interop#23).

The column's free-space usage map now lists those pages, as Access keeps it: a
delete that leaves a page with room adds it; a page a value fills, or one that is
not the column's long-value page, leaves it (UsageMap.RemovePage); and the first
write to a column in a session notes the pages the map lists, read for their room
when a chunk could fit. ExportMdb, which never deletes, writes no more than before.

Tests, each seen failing first: the map stayed empty after the deletes; the next
session took two new pages instead of the five freed ones; a filled page stayed
listed. ACE 16 reads every value written into the freed space, adds to the table,
and DAO compacts the file.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
A deleted row's primary-key entry stayed in the index, and an update added an
entry for the moved row beside the old one. Access counts and finds rows through
the key: once ACE could see the engine's tables, it counted 90 rows in a table of
70, the extra twenty being rows the engine had deleted.

A delete now takes its entry out (IndexWriter.Remove), and an update moves its
entry to the rewritten row. Removal keeps Access's rules, as Jackcess does: a page
left empty leaves the tree, its level's chain and the index's usage map; a tail
leaving its node makes the node's last entry's child the tail; the entry copying
a page's last entry follows it when that changes; a root left with one child gives
way to it, and one left with none is an empty leaf again.

Tests, each seen failing first: deleting a middle range of keys (whole leaves
empty), deleting the top range (the tail empties) then adding after it, deleting
every row then adding again, and an update - in each the key holds exactly the
table's rows, its usage map exactly its pages, and the tree Access's rules. ACE 16
counts the rows correctly after the engine's deletes.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
The free-space map change used it, which every target but netstandard2.0 has; the
CI build of that target failed.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
… kept

What the fresh review found, each seen failing first (EngineReviewTests):

- Text keys are written in General - Legacy only. A column's sort order is now
  read from its TDEF (SortOrder), and a table is not registered in a catalog
  that sorts names in Access 2010's General order (or Access 97's): refused
  before a page is added, the file left byte for byte as it was. A text index
  in another order is not searched with keys it does not hold; the table is
  scanned instead (indexCodesV2010).
- The engine's Text and Memo columns say General - Legacy (09 04 00 00), as
  Access 2000-2007 write them. With no sort order, ACE keyed the rows it added
  in another order, and the engine could not find them by key.
- An index root keeps its page, as Jackcess's does: a split moves its entries
  to two new pages beneath it, and a root left with one child takes that
  child's entries back. A second Table object held the old root and wrote
  into it; 150 tables registered in an Access 2003 file broke the catalog's
  ACE index the same way.
- A node an earlier version wrote (an entry for every child, each with row
  pointer 0, no tail) gets the row of the last entry under each child before
  the tree is changed, so a delete of a leaf's last key no longer leaves a
  stale entry behind.
- Insert, DeleteRow and UpdateByPrimaryKey refuse a table whose primary key
  the engine cannot keep before changing anything (a text key in another
  order, Access 97's included).
- A table cannot take a name a query or linked table has; the name check, like
  every other refusal, comes before a page is allocated.
- The header and the global usage map are read once and kept
  (PageFile.ReadKeptPage): appending 200 pages read 400 pages before.

ACE 16 matrix (tables the engine makes, ACE adding to them, DAO compacting):
0 failed. The build before this commit fails "engine finds ACE's text keys".

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
… gave, scans by the index's columns

What the second fresh review found, each seen failing first (SecondReviewTests):

- A delete or update in a primary key whose pages Access prefix-compressed
  (indexCodes Table14) changed the row and then failed rewriting the key page
  uncompressed, leaving the key behind. Pages are now written the way Access
  writes them, the bytes their keys share stored once, and a page that does
  not fit after any change - a removal's parent update included - splits.
  A key sharing nothing with a packed page's other keys can leave a half that
  still does not fit, so a split halves until every piece does.
- A root left with one child that itself had one child kept it: the collapse
  now repeats, and a root that collapses into a node takes its level.
- A table registered by an earlier version (ParentId 0) no longer held its
  name; any table's name, in any container, is refused again.
- FindRowByEntry's scan matched the table's first columns, not the index's.
- An earlier version's node is brought to the rules children and all: TDEF
  named, 0 for no page, its level set.
- The definition is laid out before a page is added: a key of eleven columns
  or names too long for one TDEF page refuse with no pages left behind
  (a definition over one page is NotSupportedException).
- A table made from an Access 97 table's columns sorts in General - Legacy.
- IndexKeys.Encode refuses a column whose keys it does not write, so the
  low-level IndexWriter methods refuse as Table does.
- IndexCursor's primary-key path no longer needs a lone uncompressed leaf.

Each fix and each sort-order rule the review found untested is held by a test:
19 mutations, one per fix, each caught. Engine tests 661/661.
ACE 16: Access-made compressed keys after the engine's deletes, updates and
inserts (V2003, V2007) 10/10 each - counts, seeks, ACE's own inserts, DAO
compaction, the key's order unchanged by the rebuild; the matrix 52/52 on a
new file and on Access 2003 and 2010 templates.

Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
@markmario

Copy link
Copy Markdown
Author

Review summary

Two fresh read-only reviews ran on this PR: one on the whole branch (head 270d475), one on the fix commit 8032223. Each finding was seen failing first, then fixed (EngineReviewTests, SecondReviewTests); a mutation of each fix is caught by a test.

First review (270d475) - fixed in 8032223

Finding Fix Test (red before, green after)
Text keys are written in General - Legacy only, but a catalog in Access 2010's General order took a table anyway: its ParentIdName key was one Access cannot find it by Each Text/Memo column's sort order is read from its TDEF (SortOrder); registration in a General-order (or Access 97) catalog is refused before a page is added A_file_whose_catalog_sorts_in_the_newer_General_order_is_not_given_a_table (file byte for byte unchanged); with it a guard that passed before as it does now, The_engine_keys_every_catalog_row_as_Access_keyed_it (General - Legacy files, Access's own keys as the oracle)
A text index in another order was searched with General - Legacy keys and missed rows IndexReader.CanResolve/IndexCursor search only an index whose keys they make; otherwise the table is scanned A_text_index_in_the_newer_General_order_is_not_trusted_to_find_rows (indexCodesV2010)
The engine's Text and Memo columns had no sort order (zeros); ACE keyed the rows it added in another order They say General - Legacy, 09 04 00 00, as Access 2000-2007 write it The_engines_text_and_memo_columns_sort_in_General_Legacy_as_Access_writes_it; ACE matrix "engine finds ACE's text keys" fails on 270d475 and passes now
A root split moved the root and patched the TDEF; another Table object (or the catalog writer holding an older root) kept writing into the old one The root never moves, as in Jackcess: a split moves its entries to two new pages beneath it; a root left with one child takes that child's entries back A_split_made_through_one_table_object_does_not_mislead_another, Many_tables_registered_in_an_Access_made_file_keep_its_catalog_indexes_whole
A node an earlier version wrote (an entry per child, row pointer 0, no tail) compared wrongly once converted: a delete left a stale entry Before a change, each entry takes the row of the last entry under its child and the node is written back A_primary_key_an_earlier_version_wrote_keeps_one_entry_per_row_through_deletes_and_updates
Insert/DeleteRow/UpdateByPrimaryKey changed the rows before finding the key could not be kept Table.EnsurePrimaryKeyCanBeKept refuses first A_table_whose_key_the_engine_cannot_write_is_left_as_it_was
A table could take a query's name; the duplicate check came after pages were allocated SystemCatalog.EnsureCanRegister: any object in the Tables container, any case, before allocation A_table_cannot_take_a_name_a_query_has_and_a_refusal_leaves_no_pages
The header and the global usage map were read for every page appended PageFile.ReadKeptPage, written through Appending_pages_reads_the_header_and_the_free_page_map_once (400 reads for 200 pages before, at most 2 now)

Deferred, filed where the engine's work is tracked (issues are off here): qcatco/exportmdb.interop#26 (port Access 2010's General order so those files can be written), qcatco/exportmdb.interop#27 (secondary indexes of an Access-made table are not kept). Neither touches ExportMdb: its template sorts in General - Legacy and the shipped one has no indexes.

Evidence

  • Engine tests: 661/661 (net8.0) at 22ff8f4.
  • ACE 16 matrix (tables the engine makes, ACE reading, seeking and adding to them, the engine reading ACE's rows, DAO compacting): 52/52 on a new Jet4 file, on an Access 2003 file and on an Access 2010 General - Legacy file. The build before the review fails "engine finds ACE's text keys".
  • ACE 16 on an Access-made compressed key after the engine's deletes, updates and inserts (indexCodes V2003 and V2007, Table14): 10/10 each - ACE counts, seeks, refuses a duplicate, adds rows the engine then finds, and DAO compacts with the key's order unchanged by the rebuild.
  • Engine CI green on 22ff8f4.
  • ExportMdb.Interop with this engine (qcatco/exportmdb.interop#24 at caf5de4): classic 72/72, Jackcess edition 53 + 2 skipped by design, on net472 and net481.

Second review (8032223)

Finding Fix Test (red before, green after)
Critical. A delete or update in a primary key whose pages Access prefix-compressed (indexCodes Table14: a leaf of 299 entries is 4,454 bytes written out in full, room 3,616) deleted the row, then failed rewriting the key page uncompressed: the key kept the deleted row's entry Pages are written as Access writes them, the bytes their keys share stored once (offset 24); a page that does not fit after any change, a removal's parent update included, splits; a key sharing nothing with a packed page's other keys can leave a half that still does not fit, so a split halves until every piece does A_key_Access_compressed_keeps_one_entry_per_row_through_a_delete_and_an_update (V2000, V2003, V2007), A_key_sharing_no_prefix_with_a_packed_page_splits_it_until_every_page_fits, The_engine_s_key_pages_store_a_shared_prefix_once
Important. A table the released 2.1.0 engine registered (ParentId 0) no longer held its name Any table's name, in any container, is refused, as well as any object's in the Tables container A_table_an_earlier_version_registered_keeps_its_name
Important. FindRowByEntry's scan matched the table's first columns, not the index's (pre-existing, but more lookups now fall back to it) The scan matches the chosen index's columns FindRowByEntry_finds_every_row_by_an_index_on_a_later_column (V2003 through the index, V2010 by the scan)
A root could be left holding one child that itself held one The collapse repeats; a root collapsing into a node takes its level A_deep_key_emptied_down_to_one_leaf_keeps_its_tree_whole, A_root_that_collapses_into_a_node_takes_its_level
CreateTable refused an eleven-column key or a definition too long for its page only after adding pages The definition is laid out before any page is added; one over a page is NotSupportedException A_table_its_definition_cannot_hold_is_refused_before_a_page_is_added
A table made from an Access 97 table's columns wrote sort order 09 04 00 FF Access 97's orders become General - Legacy A_table_made_from_an_Access_97_table_s_columns_sorts_in_General_Legacy
IndexWriter.InsertPrimaryKey/FindRowByPrimaryKey searched a key in an order they do not write IndexKeys.Encode refuses such a column The_low_level_key_methods_refuse_a_text_key_in_another_order
Fixes no test held: an earlier version's node brought to the rules (level, TDEF, links), sort-order code 0 as General - Legacy, Access 97 as its own order Earlier-version nodes are rewritten with their children; the rules kept A_primary_key_an_earlier_version_wrote_... now checks every rule; A_text_key_with_no_sort_order_is_kept_in_General_Legacy; An_Access_97_text_key_is_not_written_and_the_file_is_left_as_it_was

Not changed: two Database objects writing one file each keep their own copy of the free-page map, so pages one appends can be handed out again by the other (the reviewer's minor 4). A file has one writer; PageFile.ReadKeptPage says so.

Mutations: 19, one per fix, each caught by a test.

https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY

@markmario
markmario merged commit 531a679 into main Oct 5, 2026
4 checks passed
@markmario
markmario deleted the fix/engine-follow-ups branch October 5, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant