-
Notifications
You must be signed in to change notification settings - Fork 0
A long connect screen cost a game its listing, for ever #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| -- The (field, value) index could not hold a connect screen, and a game whose screen was long enough | ||
| -- failed to ingest at all. | ||
| -- | ||
| -- Observed on the first crawl large enough to find it: three of four hundred games died with | ||
| -- "54000: index row size 3048 exceeds btree version 4 maximum 2704 for index | ||
| -- game_field_field_value_idx". PostgreSQL's btree cannot index a row wider than about 2704 bytes, | ||
| -- and a connect screen is routinely thousands of characters — the longest in this catalogue is 9,376. | ||
| -- The failure is not partial: the INSERT is refused, so the whole probe's ingestion is lost for that | ||
| -- game, and it is lost again on every future probe, for ever. A game with a generous piece of ASCII | ||
| -- art was permanently unlistable. | ||
| -- | ||
| -- The index's stated purpose (0002) is §9's faceted search: which games have CODEBASE = PennMUSH, or | ||
| -- capability.gmcp.measured = true. Every value that purpose looks up is short. NOTHING HAS EVER | ||
| -- SEARCHED BY CONNECT SCREEN and nothing ever will — it is a display asset and a fingerprint, and | ||
| -- the fingerprint has its own column. So the index covers a bounded prefix, which serves the lookups | ||
| -- it was built for and cannot overflow: 256 characters is under the limit even at four bytes each. | ||
| -- | ||
| -- The stored value is untouched. Truncating what a game said in order to fit our own index would be | ||
| -- exactly the kind of quiet lossiness this schema refuses everywhere else; it is the *index* that is | ||
| -- bounded, not the fact. | ||
| DROP INDEX IF EXISTS game_field_field_value_idx; | ||
|
|
||
| CREATE INDEX game_field_field_value_idx ON game_field (field, left(value, 256)); | ||
|
|
||
| -- The same flaw, one index over: §7.3's identity lookup folds case and whitespace on both columns, | ||
| -- and folding does not shorten a connect screen. This one is partial rather than prefixed, because | ||
| -- its reader asks an equality question and a prefix would silently turn that into a | ||
| -- starts-with — over-matching where the raw index merely refused. Every identity signal §7.3 names | ||
| -- is short: a name, a year, a hostname, a hash, a token. A value longer than this is not one of | ||
| -- them, so excluding it from the lookup changes no correct answer. | ||
| -- | ||
| -- CatalogueDirectories carries the same predicate, or the planner cannot use a partial index. | ||
| DROP INDEX IF EXISTS game_field_folded_value_idx; | ||
|
|
||
| CREATE INDEX game_field_folded_value_idx | ||
| ON game_field (lower(btrim(field)), lower(btrim(value))) | ||
| WHERE length(value) <= 256; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
127 changes: 127 additions & 0 deletions
127
tests/MUI.Catalog.Tests/Persistence/OversizedFieldValueTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| using Dapper; | ||
|
|
||
| using MUI.Catalog.Persistence; | ||
| using MUI.Catalog.Tests.Persistence.Support; | ||
|
|
||
| namespace MUI.Catalog.Tests.Persistence; | ||
|
|
||
| /// <summary> | ||
| /// A field value too large for an index must not cost a game its listing. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Found on the first crawl big enough to find it: three games of four hundred died with | ||
| /// <c>54000: index row size … exceeds btree version 4 maximum 2704</c>, because a connect screen is | ||
| /// routinely thousands of characters and the <c>(field, value)</c> index tried to hold one. The | ||
| /// failure is total rather than partial — the insert is refused, the whole probe's ingestion is lost, | ||
| /// and it is lost again on every future probe. A game with a generous piece of ASCII art was | ||
| /// permanently unlistable, and nothing said so. | ||
| /// </remarks> | ||
| public class OversizedFieldValueTests | ||
| { | ||
| private static readonly DateTimeOffset Now = Seed.Now; | ||
|
|
||
| /// <summary>The real shape: a connect screen far past the btree limit, stored whole.</summary> | ||
| [Test] | ||
| public async Task AConnectScreenTooLargeToIndexIsStillStored() | ||
| { | ||
| await using var db = await PostgresFixture.MigratedAsync(); | ||
| var game = await Seed.GameAsync(db); | ||
| var store = new NpgsqlGameFieldStore(db.DataSource); | ||
|
|
||
| // Longer than the longest observed in the wild (9,376 characters) and several times the | ||
| // index limit, so this fails against the old index and passes against the bounded one. | ||
| var screen = string.Join('\n', Enumerable.Repeat(new string('=', 78), 160)); | ||
|
|
||
| await store.UpsertAsync(new GameField( | ||
| game, InternalFields.ConnectScreen, FieldSource.Banner, screen, Now, Now)); | ||
|
|
||
| var stored = (await store.ForGameAsync(game)) | ||
| .Single(f => f.Field == InternalFields.ConnectScreen); | ||
|
|
||
| // Stored whole. It is the index that is bounded, never the fact — truncating what a game | ||
| // sent in order to fit our own index is the kind of quiet lossiness this schema refuses. | ||
| await Assert.That(stored.Value).IsEqualTo(screen); | ||
| await Assert.That(stored.Value.Length).IsGreaterThan(2704); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Two long values that differ only past the indexed prefix are still two distinct rows. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The prefix is an index, not a key. If bounding it had collapsed rows that share their first | ||
| /// 256 characters — which two connect screens from one codebase easily do — the fix would have | ||
| /// traded a loud failure for a silent one. | ||
| /// </remarks> | ||
| [Test] | ||
| public async Task TwoValuesSharingTheirFirstBytesRemainDistinct() | ||
| { | ||
| await using var db = await PostgresFixture.MigratedAsync(); | ||
| var one = await Seed.GameAsync(db, slug: "one", name: "One"); | ||
| var two = await Seed.GameAsync(db, slug: "two", name: "Two"); | ||
| var store = new NpgsqlGameFieldStore(db.DataSource); | ||
|
|
||
| var shared = new string('#', 4000); | ||
|
|
||
| await store.UpsertAsync(new GameField( | ||
| one, InternalFields.ConnectScreen, FieldSource.Banner, shared + "ONE", Now, Now)); | ||
| await store.UpsertAsync(new GameField( | ||
| two, InternalFields.ConnectScreen, FieldSource.Banner, shared + "TWO", Now, Now)); | ||
|
|
||
| var first = (await store.ForGameAsync(one)).Single(f => f.Field == InternalFields.ConnectScreen); | ||
| var second = (await store.ForGameAsync(two)).Single(f => f.Field == InternalFields.ConnectScreen); | ||
|
|
||
| await Assert.That(first.Value).EndsWith("ONE"); | ||
| await Assert.That(second.Value).EndsWith("TWO"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Both indexes over this table are bounded, because both could refuse a connect screen. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The first fix caught only one of them and the very next probe failed on the other — so this | ||
| /// asserts the property over every index on <c>game_field</c> rather than over the one that was | ||
| /// noticed. An index on a raw or merely case-folded value is the shape of the bug: folding does | ||
| /// not shorten anything. | ||
| /// </remarks> | ||
| [Test] | ||
| public async Task NoIndexOnThisTableCanRefuseALongValue() | ||
| { | ||
| await using var db = await PostgresFixture.MigratedAsync(); | ||
|
|
||
| await using var connection = await db.DataSource.OpenConnectionAsync(); | ||
|
|
||
| var definitions = (await connection.QueryAsync<string>( | ||
| "SELECT indexdef FROM pg_indexes WHERE tablename = 'game_field'")).ToList(); | ||
|
|
||
| foreach (var definition in definitions.Where(d => d.Contains("value", StringComparison.Ordinal))) | ||
| { | ||
| // Either the indexed expression is bounded, or the index only covers rows short enough. | ||
| var bounded = definition.Contains("256", StringComparison.Ordinal); | ||
|
|
||
| await Assert.That(bounded) | ||
| .IsTrue() | ||
| .Because($"an unbounded index over `value` refuses a connect screen: {definition}"); | ||
| } | ||
| } | ||
|
|
||
| /// <summary>The index still exists and still leads on the field, which is what it is for.</summary> | ||
| [Test] | ||
| public async Task TheFacetLookupIsStillIndexed() | ||
| { | ||
| await using var db = await PostgresFixture.MigratedAsync(); | ||
|
|
||
| await using var connection = await db.DataSource.OpenConnectionAsync(); | ||
|
|
||
| var definition = await connection.ExecuteScalarAsync<string>( | ||
| "SELECT indexdef FROM pg_indexes WHERE indexname = 'game_field_field_value_idx'"); | ||
|
|
||
| await Assert.That(definition).IsNotNull(); | ||
| await Assert.That(definition!).Contains("field"); | ||
|
|
||
| // Asserted on the bound rather than the spelling: PostgreSQL reports the expression back as | ||
| // "left"(value, 256), quoted, and a test that matched the source text would break on a | ||
| // formatting difference while saying nothing about whether the index can overflow. | ||
| await Assert.That(definition!).Contains("256"); | ||
| await Assert.That(definition!.Contains("(field, value)", StringComparison.Ordinal)).IsFalse(); | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: SharpMUSH/MUIndex
Length of output: 37649
🏁 Script executed:
Repository: SharpMUSH/MUIndex
Length of output: 39137
🏁 Script executed:
Repository: SharpMUSH/MUIndex
Length of output: 465
🏁 Script executed:
Repository: SharpMUSH/MUIndex
Length of output: 307
Assert
game_field_folded_value_idxexplicitly.The table-wide loop passes when this index is absent, and
TheFacetLookupIsStillIndexedchecks onlygame_field_field_value_idx. Add a named assertion thatgame_field_folded_value_idxexists and hasWHERE length(value) <= 256.🤖 Prompt for AI Agents