Repository navigation
Write files of any size: usage maps grow, inserts do not slow - #2
Conversation
- UsageMap: a page outside an inline map's window moves the window when
every page the map holds still fits in one, and otherwise turns the map
into a reference map whose map pages are allocated as they are needed -
as Access and Java Jackcess do. Adding a page outside the window threw,
so no file could pass 2 MB (Access's 512-page windows) or 6.4 MB (this
engine's 1,600).
- DataPageWriter: rows go to the table's last data page, as Access adds
them. Looking through every owned page for room read the whole table on
every insert - 500 inserts read 192,571 pages, the next 500 570,220 - and
put rows ahead of rows inserted before them. A page holds at most 255
rows: Jet points at a row with one byte.
- LvalWriter: the column's last page and the pages a deleted value freed
in the session, not every owned page. Freed space is still reused.
- RowEncoder: a Text value longer than its column is refused, as Access
refuses it. Written whole, ACE read it cut short ("14-Sept-202" for a
12-character date in an 11-character column) and typed readers refused
the table.
New tests, each seen failing first: UsageMapTests (8 of 9 fail on the
inline-only map), LargeFileTests (3), TextColumnLengthTests (1 of 2).
Suite 566 passed, 0 failed.
qcatco/exportmdb.interop#20
Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
From the review of #2, each fix with a test seen failing first: - A row's address is Jet's own layout, page << 8 | row (RowPointer), in every place that packs or unpacks one. Shifted 16 bits, a page past 32,767 did not fit: now that files may pass 128 MB, a primary-key index pointed a row on page 40,001 at page 16,751,681 and one on page 70,001 at page 4,465 (ReviewFindingsTests, both red before). - An update checks the merged row before it frees or deletes the row it replaces. Refused for text longer than its column, it had already deleted the row (red: the row was gone). - A long value goes only onto a long-value page ("LVAL" at bytes 4-7): a map listing another table's data page last had chunks written onto that table's page (red: the other table read back two rows). - A chunk that takes a different row from the one its chain names is an error, not a link to the wrong row. - A value longer than its column throws ColumnValueTooLongException, with the column, its width and the value's length, so callers can tell it from other argument errors. Table.Validate checks a row without writing it, so a caller can check a whole batch before writing any of it. - Guard tests for behaviour already right, each seen failing against a mutant: long values share the column's last page; one column's freed pages are not used by another; a row never goes onto another table's page listed in its map; a Jet3 Text column counts characters. Suite 575 passed, 0 failed. Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
markmario
left a comment
There was a problem hiding this comment.
Reviewed commit 5ecc2145b5eb9240dbfcc9d2de96328a77392fb4 against base 3721d0418350c79cc8fd7fe458c1398852e79edb.
Recommendation: fix the three findings below before merging. Two affect data integrity; one introduces a cross-table performance regression.
| Priority | Finding | Verified effect |
|---|---|---|
| P1 | Text-width rejection happens after the original row is deleted during an update | Rejected update permanently removes the record, including after reopening |
| P1 | Newly enabled large-file inserts exceed the existing packed row-address representation | Incorrect primary-key index addresses at page 32,768; wrapped addresses at page 65,536 |
| P2 | Freed LVAL pages are scanned globally for every long-value write | Deleting a 1 MB value in one table raises unrelated-table inserts from 8–9 page reads on base to 254 on this PR |
Validation:
- Built and ran the complete net8.0 suite for the exact PR head: 566 passed, 0 failed, 0 skipped.
- Built the base revision separately and ran the targeted reproductions against both DLLs.
- No tracked source changes were made during the review.
- The large-file boundary probe checks the library's indexed lookup and the raw index entry; it did not use ACE. The new growth support turns an existing pointer-width limitation into successful writes with invalid indexes, whereas the base rejects these allocations.
Detailed reproduction steps, observed results and suggested corrections are attached inline. The existing green suite does not cover these scenarios.
| if (row is null) | ||
| throw new ArgumentNullException(nameof(row)); | ||
|
|
||
| RefuseTextLongerThanItsColumn(row); |
There was a problem hiding this comment.
[P1] Validate the merged row before deleting the existing record
This new rejection runs inside InsertRow, but DataPageWriter.UpdateRowByPrimaryKey first frees the original row's LVAL chains and marks its slot deleted (lines 235–244), then calls InsertRow for the replacement. An ordinary overlength-text validation error therefore permanently deletes the record the caller was trying to update.
Minimal reproduction against the PR head:
using (var db = Database.Create(path, JetVersion.Jet4))
{
var table = db.CreateTable("T", new[]
{
new ColumnBuilder("Id", DataType.Long).Build(),
new ColumnBuilder("Name", DataType.Text).MaxLength(3).Build()
}, primaryKey: "Id");
table.Insert(new Row { ["Id"] = 1, ["Name"] = "old" });
try
{
table.UpdateByPrimaryKey(1, new Row { ["Name"] = "toolong" });
}
catch (ArgumentException) { }
// PR head: Count == 0; primary-key lookup also finds nothing.
Console.WriteLine(table.ReadAllRows().Count);
}
using (var db = Database.Open(path))
Console.WriteLine(db.GetTable("T").ReadAllRows().Count); // Still 0.The head reports Column 'Name' holds at most 3 characters; the value has 7. and leaves zero rows. The exact base accepts this update and retains one row before and after reopening. Rejecting an oversized value is the intended new behaviour; losing the original record is the regression.
Validate the merged replacement before freeing old long values or marking the old slot deleted. Add an update-rejection regression test asserting the original row and its values survive both immediately and after reopening.
There was a problem hiding this comment.
Fixed in c4bda54: UpdateRowByPrimaryKey now checks the merged row (RowEncoder.Validate) before it frees the old row's long values or marks its slot deleted, so a refused update leaves the row alone.
ff08da3 widens the test to what you asked for. ReviewFindingsTests.An_update_refused_for_text_longer_than_its_column_leaves_the_row_as_it_was checks the row, its memo and the primary-key lookup both in the session and after the file is reopened.
Red/green: with the check moved back after the delete, as it was in 5ecc214, the test fails (Expected: 2, Actual: 1 rows). On ff08da3 it passes.
| // Jackcess's own test: the window must hold the span with a page to spare. | ||
| if (last - newStart + 1 < capacity) | ||
| { | ||
| ByteUtil.PutInt(page, rowStart + 1, newStart); |
There was a problem hiding this comment.
[P1] Preserve full row addresses before enabling these larger allocations
Moving/converting the usage map now permits data pages beyond 32,767, but DataPageWriter.InsertRow still packs (dataPage << 16) | rowNum into a signed int. IndexWriter.BuildLeafEntry extracts the page with (rowPointer >> 16) & 0xFFFFFF: at page 32,768 this sign-extends to 0xFF8000 instead of 0x008000; at page 65,536 the page bits wrap to zero. The existing representation predates this PR, but the newly accepted allocations make corrupting writes reachable.
Bounded reproduction, without inserting hundreds of megabytes of records:
- Create a Jet4 database and a table
Twith a single Long primary-key columnId. - Before the first insert, extend that temporary file to
32768L * 4096bytes using aFileStreamopened withFileShare.ReadWrite. This places the next allocated data page at the boundary; the empty intermediate pages are not owned by the table. - Insert
new Row { ["Id"] = 42 }, dispose the database, reopen it, then callNewIndexCursor().FindRowByPrimaryKey(42). - Repeat with
65536L * 4096bytes.
Observed results:
| Boundary | PR head | Exact base |
|---|---|---|
| Page 32,768 / 128 MiB | Insert succeeds; raw leaf entry is 7F8000002A FF8000 00; indexed lookup after reopening throws EndOfStreamException for page 16,744,448 |
Insert rejected with NotSupportedException because it exceeds the inline map capacity |
| Page 65,536 / 256 MiB | Insert succeeds; stored index page address is 000000; indexed lookup returns null |
Same allocation rejection |
Plain table scans still find the inserted rows on the head, so row-count/readback checks alone miss the invalid index entries.
Correct the packed row-address representation and all its producers/consumers to preserve the full Jet page address, or reject unsupported addresses before mutating the file. Add indexed readback tests at both boundaries alongside the new usage-map growth tests.
There was a problem hiding this comment.
Fixed in c4bda54. A row pointer now uses Jet's own layout, with the page in the high 24 bits and the row in the low 8 (Indexes/RowPointer.cs). The row insert, the index writer, the index reader and the index cursor all use it. RowPointer.Pack throws for a page it cannot hold, past 16,777,215, which is a 64 GiB file and far beyond Jet's 2 GB.
A_row_on_a_page_past_32767_is_indexed_and_found_by_its_primary_key checks two things after reopening: the page in the raw leaf entry (IndexReader.FindRowPointers) and the primary-key lookup. ff08da3 adds your two boundaries: rows on page 32,768 and on page 65,536, alongside the existing 40,001 and 70,001.
Red/green: I put the 16-bit shift back. The leaf entry then names page 16,744,448 for 32,768 and page 0 for 65,536, your figures, and all four cases fail. On ff08da3 all four pass.
| _writer = writer; | ||
| var file = writer._file; | ||
| var umap = writer._umap; | ||
| foreach (int page in file.FreedLvalPages.OrderBy(p => p)) |
There was a problem hiding this comment.
[P2] Restrict freed-page candidates to the owning LVAL column
PageFile.FreedLvalPages covers the entire database. This loop sorts every freed page and calls UsageMap.Contains for it on every Memo/OLE write, even when the page belongs to another table or column. Each membership check rereads the target column's usage map. Unrelated pages never enter this picker's _pages, so Done() cannot remove them and the overhead persists for the rest of the session unless their owner reuses them.
Reproduction with two otherwise small tables A and B, each having Long Id and OLE Blob columns:
- Insert a 1,000,000-byte OLE value in A.
- Measure reads while inserting a 3,000-byte OLE value in B.
- Delete A's value with
a.DeleteRow("Id", 1). - Measure reads for two further 3,000-byte inserts in B.
The identical probe against base and head counted page reads using a delegating ICodecHandler on PageFile.Codec:
| B insert | Base | PR head |
|---|---|---|
| Before deleting A's value | 7 | 7 |
| First after deletion | 8 | 254 |
| Second after deletion | 9 | 254 |
Deleting one unrelated 1 MB value adds roughly 247 map reads to every subsequent long-value insert in B. Larger deleted values make the penalty scale with storage elsewhere in the database, counteracting the bounded insert cost this PR is intended to provide.
Index or cache freed-page candidates by their owning column/usage-map identity instead of scanning the file-wide set for each value. Add a performance regression test covering deletes in one table followed by inserts in another.
There was a problem hiding this comment.
Fixed in ff08da3. Freed pages are now kept per column (Pages/FreedLvalPages.cs), keyed by the owned-pages map of the column whose value was freed, with the room each page had when last seen. A long-value write:
- looks only at its own column's freed pages,
- reads one only when it was last seen with room for the chunk, and at most once per value,
- checks it against the column's map and the LVAL signature before writing to it,
- notes the room left once the value is written.
Pages freed in other tables or columns are never read.
Your probe's shape: reads for each 3,000-byte insert after a 1,000,000-byte value is deleted.
| Insert | c4bda54 | ff08da3 | Nothing deleted |
|---|---|---|---|
| Into another table | 254 | 8 | 8 |
| Into the same column | 499 | 7 | 8 |
The delete reads 249 pages in both builds. Reusing freed space is now cheaper than taking a new page.
Two new tests, both red on c4bda54 and green on ff08da3:
Deleting_a_long_value_in_one_table_does_not_slow_long_value_inserts_in_anotherrequires the same reads as the same inserts with nothing deleted. On c4bda54:[254, 254, 254]against[8, 8, 8].Space_a_deleted_long_value_freed_is_used_again_without_reading_every_page_it_freedchecks that five values after the delete add no pages and read no more than inserts onto new pages. On c4bda54: 498 reads against 8.
Full suite on ff08da3: 579 passed, 0 failed.
Review 5412715460, P2: the pages a delete freed were one set for the whole file, and every long-value write - in any table - read each of them and its own map to see whether it was one of its own. After a 1 MB value was deleted from one table, each 3,000-byte write into another read 254 pages instead of 8, and each into the same column 499. Now each column's freed pages are kept apart, with the room each had when last seen, and a write reads one only when it was seen with room for the chunk: 8 reads into the other table, 7 into the same column, and the deleted space is still used again. The review's two P1 findings were fixed in c4bda54. Their tests now take the review's own cases as well: rows on pages 32,768 and 65,536, and the refused update's row read back after the file is reopened. Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
…board From a fresh review of ff08da3, each test seen failing first: - A value's last chunk can go onto a freed page that was too small for its full chunks. The freed pages were walked once, for the full chunks, and not again: five 5,000-byte values after a delete took 7 new pages instead of 5. A smaller last chunk now walks them again, reading no page twice. - After a value is written, the room left on every page it read or wrote is noted again if the column has a record for that page, so a freed page reached as the column's last page no longer keeps an overstated record. A page holding 255 rows is noted as having none, so it is not read again for room it cannot use. New tests in FreedSpaceTests: two long-value columns in one table; the freed path refusing a page that is not on the column's map, is not a long-value page, or has less room than noted; and space freed in a file Access wrote (blobV2007.accdb) being used again with every other row unchanged. The read-count tests in ReviewFindingsTests now reopen the file and read every value back. Each guard was also shown failing with its check removed. Claude-Session: https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY
|
A fresh review of ff08da3, and what 0f6ee30 does about it. The reviewer worked read-only, with its own probes, including on copies of corpus files Access wrote. It found nothing that writes a chunk where it doesn't fit, breaks a chain, writes onto another column's or table's page, or damages a file Access wrote.
Not changed: finding room costs in-memory work in proportion to a column's freed pages, with no page reads. This only matters after deleting hundreds of MB and refilling with small values; ExportMdb never deletes. Suite 587/0 (net8.0); CI green on 0f6ee30. |
The engine could not write a file past 2 MB, and every insert was slower than the last. Both broke ODES's Egress on any Ringtail export of real size (qcatco/exportmdb.interop#20, qcatco/odes#155). Issues are disabled on this fork, so they are tracked on exportmdb.interop.
What changed
UsageMap.AddPagemoves the window when every page the map holds still fits in one. Otherwise it turns the map into a reference map, whose map pages are allocated as needed. This is what Access and Java Jackcess do (UsageMap.InlineHandler/ReferenceHandler).DataPageWriteradds a row to the table's last data page, as Access does, instead of reading every owned page to find room. Rows also stay in insertion order now. Only a data page of this table takes the row: a map that lists another table's page last is passed over.LvalWritertries the column's last page, and the space values of the same column freed in the session (FreedLvalPages, kept per column with the room each page has). It reads a freed page only when it is known to have room, and a value's last chunk can take a freed page that was too small for its full chunks. Only a long-value page of this column takes a chunk.RowPointer, used by the insert, the index writer, reader and cursor). Held as page << 16, a row on a page past 32,767 was indexed at the wrong page once files could grow that far.ColumnValueTooLongException(anArgumentException) naming the column, its width and the value's length. Jet3 widths count characters, Jet4's bytes. Written whole, ACE read such a value cut short and typed readers refused the table.Table.Validate(row)checks a row without writing it, and an update checks the merged row before it touches the old one.Red, then green
Each test was seen failing for the reason it names, and passing after. For a guard added after its fix, the red comes from undoing the fix in place (a mutation):
LargeFileTests.Rows_past_the_first_usage_map_window_are_written_and_read_back_in_orderNotSupportedException: Page 1600 is beyond the inline bitmap capacityLargeFileTests.Each_insert_reads_about_as_many_pages_however_large_the_tableLargeFileTests.A_data_page_holds_at_most_255_rowsUsageMapTests(9 tests)TextColumnLengthTests.Text_longer_than_its_column_is_refused_rather_than_writtenColumnValueTooLongExceptionnaming the column and its widthReviewFindingsTests.A_row_on_a_page_past_32767_is_indexed_and_found_by_its_primary_key, rows on pages 32,768, 40,001, 65,536 and 70,001ReviewFindingsTests.An_update_refused_for_text_longer_than_its_column_leaves_the_row_as_it_wasReviewFindingsTests.Deleting_a_long_value_in_one_table_does_not_slow_long_value_inserts_in_anotherReviewFindingsTests.Space_a_deleted_long_value_freed_is_used_again_without_reading_every_page_it_freedReviewFindingsTests.A_Jet3_text_column_counts_its_length_in_charactersReviewFindingsTests.Long_values_share_the_column_s_last_page_while_it_has_roomReviewFindingsTests.Space_freed_in_one_long_value_column_is_not_used_by_anotherReviewFindingsTests.A_row_never_goes_onto_another_table_s_page_listed_in_its_mapReviewFindingsTests.A_long_value_never_goes_onto_a_page_that_is_not_a_long_value_pageFreedSpaceTests.The_last_chunk_of_a_long_value_reuses_a_page_a_delete_left_partly_freeFreedSpaceTests.Room_left_on_a_freed_page_is_noted_however_the_value_reached_itFreedSpaceTests.A_freed_page_holding_as_many_rows_as_a_page_can_is_forgotten_whatever_its_roomFreedSpaceTests.A_column_s_freed_space_is_its_own_in_a_table_with_two_long_value_columnsFreedSpaceTests.A_freed_page_that_is_not_on_the_column_s_map_is_never_written_toFreedSpaceTests.A_freed_page_that_is_not_a_long_value_page_is_never_written_toFreedSpaceTests.A_freed_page_with_less_room_than_recorded_is_read_before_anything_goes_onto_itFreedSpaceTests.Space_a_delete_frees_in_a_file_Access_wrote_is_used_again_and_nothing_else_changes(blobV2007.accdb)The read-count tests in
ReviewFindingsTestsalso reopen the file and read every value back.The map tests cover a reference map's second, third and fourth map pages with page numbers alone, without a 128 MB file.
Reviews
Verified
Known, tracked
https://claude.ai/code/session_01AYm7ruNjAV5SnM2ZRs1MxY