Skip to content

Commit 270b20b

Browse files
A long connect screen cost a game its listing, for ever (#19)
Found by the first crawl large enough to find it: seeding 701 addresses from the directories, three games of four hundred died with 54000: index row size 3048 exceeds btree version 4 maximum 2704 for index "game_field_field_value_idx" PostgreSQL cannot index a btree row past about 2704 bytes, and a connect screen is routinely thousands of characters -- the longest in this catalogue is 9,376. The failure is total rather than 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. A game with a generous piece of ASCII art was permanently unlistable and nothing said so. WHY THREE AND NOT FIFTY-ONE, WHICH IS HOW MANY ROWS ARE NOW OVER THAT SIZE. Index tuples cannot be stored out of line but they can be compressed, so whether a game was listable depended on how well its ASCII art compressed. That is why it presented as three unrelated failures rather than as a rule, and it is why looking at the length of the longest value would not have predicted it. Both indexes over game_field had the flaw, and the first fix caught only one -- the very next probe failed on the other. They are bounded differently because they are read differently: - game_field_field_value_idx serves §9's faceted search and is indexed on a 256-character prefix. Nothing has ever searched by connect screen and nothing will; it is a display asset, and its fingerprint has its own column. - game_field_folded_value_idx serves §7.3's identity lookup, which asks an equality question. A prefix there would silently turn that into a starts-with -- over-matching where the raw index merely refused -- so it is partial instead, and CatalogueDirectories carries the same predicate or the planner cannot use it. Every identity signal §7.3 names is short: a name, a year, a hostname, a hash, a token. A value longer than that is not one. THE STORED VALUE IS UNTOUCHED. Truncating what a game sent in order to fit our own index would be the quiet lossiness this schema refuses everywhere else. It is the index that is bounded, never the fact. The test asserts the property over EVERY index on game_field rather than over the one that was noticed, which is the mistake the first pass made. Verified against the live catalogue: the games that had been failing re-probed clean, and the one that raised the original error now holds its 7,535-character connect screen. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ee48650 commit 270b20b

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- The (field, value) index could not hold a connect screen, and a game whose screen was long enough
2+
-- failed to ingest at all.
3+
--
4+
-- Observed on the first crawl large enough to find it: three of four hundred games died with
5+
-- "54000: index row size 3048 exceeds btree version 4 maximum 2704 for index
6+
-- game_field_field_value_idx". PostgreSQL's btree cannot index a row wider than about 2704 bytes,
7+
-- and a connect screen is routinely thousands of characters — the longest in this catalogue is 9,376.
8+
-- The failure is not partial: the INSERT is refused, so the whole probe's ingestion is lost for that
9+
-- game, and it is lost again on every future probe, for ever. A game with a generous piece of ASCII
10+
-- art was permanently unlistable.
11+
--
12+
-- The index's stated purpose (0002) is §9's faceted search: which games have CODEBASE = PennMUSH, or
13+
-- capability.gmcp.measured = true. Every value that purpose looks up is short. NOTHING HAS EVER
14+
-- SEARCHED BY CONNECT SCREEN and nothing ever will — it is a display asset and a fingerprint, and
15+
-- the fingerprint has its own column. So the index covers a bounded prefix, which serves the lookups
16+
-- it was built for and cannot overflow: 256 characters is under the limit even at four bytes each.
17+
--
18+
-- The stored value is untouched. Truncating what a game said in order to fit our own index would be
19+
-- exactly the kind of quiet lossiness this schema refuses everywhere else; it is the *index* that is
20+
-- bounded, not the fact.
21+
DROP INDEX IF EXISTS game_field_field_value_idx;
22+
23+
CREATE INDEX game_field_field_value_idx ON game_field (field, left(value, 256));
24+
25+
-- The same flaw, one index over: §7.3's identity lookup folds case and whitespace on both columns,
26+
-- and folding does not shorten a connect screen. This one is partial rather than prefixed, because
27+
-- its reader asks an equality question and a prefix would silently turn that into a
28+
-- starts-with — over-matching where the raw index merely refused. Every identity signal §7.3 names
29+
-- is short: a name, a year, a hostname, a hash, a token. A value longer than this is not one of
30+
-- them, so excluding it from the lookup changes no correct answer.
31+
--
32+
-- CatalogueDirectories carries the same predicate, or the planner cannot use a partial index.
33+
DROP INDEX IF EXISTS game_field_folded_value_idx;
34+
35+
CREATE INDEX game_field_folded_value_idx
36+
ON game_field (lower(btrim(field)), lower(btrim(value)))
37+
WHERE length(value) <= 256;

src/MUI.Crawler/Persistence/CatalogueDirectories.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ SELECT DISTINCT game_id
101101
FROM game_field
102102
WHERE lower(btrim(field)) = lower(btrim(@field))
103103
AND lower(btrim(value)) = lower(btrim(@value))
104+
-- The bound is here as well as on the index, and both are deliberate. PostgreSQL's
105+
-- btree cannot hold a row past ~2704 bytes, and a connect screen is thousands of
106+
-- characters, so an unbounded index refused the INSERT and cost the game its whole
107+
-- ingestion. The index is now partial; repeating its predicate here is what lets the
108+
-- planner use it rather than sequentially scanning game_field once per identity
109+
-- signal per probe. It changes no answer: every §7.3 signal — a name, a year, a
110+
-- hostname, a hash, a token — is short, and a value longer than this is not one.
111+
AND length(value) <= 256
104112
""",
105113
new { field, value },
106114
cancellationToken: ct));
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using Dapper;
2+
3+
using MUI.Catalog.Persistence;
4+
using MUI.Catalog.Tests.Persistence.Support;
5+
6+
namespace MUI.Catalog.Tests.Persistence;
7+
8+
/// <summary>
9+
/// A field value too large for an index must not cost a game its listing.
10+
/// </summary>
11+
/// <remarks>
12+
/// Found on the first crawl big enough to find it: three games of four hundred died with
13+
/// <c>54000: index row size … exceeds btree version 4 maximum 2704</c>, because a connect screen is
14+
/// routinely thousands of characters and the <c>(field, value)</c> index tried to hold one. The
15+
/// failure is total rather than partial — the insert is refused, the whole probe's ingestion is lost,
16+
/// and it is lost again on every future probe. A game with a generous piece of ASCII art was
17+
/// permanently unlistable, and nothing said so.
18+
/// </remarks>
19+
public class OversizedFieldValueTests
20+
{
21+
private static readonly DateTimeOffset Now = Seed.Now;
22+
23+
/// <summary>The real shape: a connect screen far past the btree limit, stored whole.</summary>
24+
[Test]
25+
public async Task AConnectScreenTooLargeToIndexIsStillStored()
26+
{
27+
await using var db = await PostgresFixture.MigratedAsync();
28+
var game = await Seed.GameAsync(db);
29+
var store = new NpgsqlGameFieldStore(db.DataSource);
30+
31+
// Longer than the longest observed in the wild (9,376 characters) and several times the
32+
// index limit, so this fails against the old index and passes against the bounded one.
33+
var screen = string.Join('\n', Enumerable.Repeat(new string('=', 78), 160));
34+
35+
await store.UpsertAsync(new GameField(
36+
game, InternalFields.ConnectScreen, FieldSource.Banner, screen, Now, Now));
37+
38+
var stored = (await store.ForGameAsync(game))
39+
.Single(f => f.Field == InternalFields.ConnectScreen);
40+
41+
// Stored whole. It is the index that is bounded, never the fact — truncating what a game
42+
// sent in order to fit our own index is the kind of quiet lossiness this schema refuses.
43+
await Assert.That(stored.Value).IsEqualTo(screen);
44+
await Assert.That(stored.Value.Length).IsGreaterThan(2704);
45+
}
46+
47+
/// <summary>
48+
/// Two long values that differ only past the indexed prefix are still two distinct rows.
49+
/// </summary>
50+
/// <remarks>
51+
/// The prefix is an index, not a key. If bounding it had collapsed rows that share their first
52+
/// 256 characters — which two connect screens from one codebase easily do — the fix would have
53+
/// traded a loud failure for a silent one.
54+
/// </remarks>
55+
[Test]
56+
public async Task TwoValuesSharingTheirFirstBytesRemainDistinct()
57+
{
58+
await using var db = await PostgresFixture.MigratedAsync();
59+
var one = await Seed.GameAsync(db, slug: "one", name: "One");
60+
var two = await Seed.GameAsync(db, slug: "two", name: "Two");
61+
var store = new NpgsqlGameFieldStore(db.DataSource);
62+
63+
var shared = new string('#', 4000);
64+
65+
await store.UpsertAsync(new GameField(
66+
one, InternalFields.ConnectScreen, FieldSource.Banner, shared + "ONE", Now, Now));
67+
await store.UpsertAsync(new GameField(
68+
two, InternalFields.ConnectScreen, FieldSource.Banner, shared + "TWO", Now, Now));
69+
70+
var first = (await store.ForGameAsync(one)).Single(f => f.Field == InternalFields.ConnectScreen);
71+
var second = (await store.ForGameAsync(two)).Single(f => f.Field == InternalFields.ConnectScreen);
72+
73+
await Assert.That(first.Value).EndsWith("ONE");
74+
await Assert.That(second.Value).EndsWith("TWO");
75+
}
76+
77+
/// <summary>
78+
/// Both indexes over this table are bounded, because both could refuse a connect screen.
79+
/// </summary>
80+
/// <remarks>
81+
/// The first fix caught only one of them and the very next probe failed on the other — so this
82+
/// asserts the property over every index on <c>game_field</c> rather than over the one that was
83+
/// noticed. An index on a raw or merely case-folded value is the shape of the bug: folding does
84+
/// not shorten anything.
85+
/// </remarks>
86+
[Test]
87+
public async Task NoIndexOnThisTableCanRefuseALongValue()
88+
{
89+
await using var db = await PostgresFixture.MigratedAsync();
90+
91+
await using var connection = await db.DataSource.OpenConnectionAsync();
92+
93+
var definitions = (await connection.QueryAsync<string>(
94+
"SELECT indexdef FROM pg_indexes WHERE tablename = 'game_field'")).ToList();
95+
96+
foreach (var definition in definitions.Where(d => d.Contains("value", StringComparison.Ordinal)))
97+
{
98+
// Either the indexed expression is bounded, or the index only covers rows short enough.
99+
var bounded = definition.Contains("256", StringComparison.Ordinal);
100+
101+
await Assert.That(bounded)
102+
.IsTrue()
103+
.Because($"an unbounded index over `value` refuses a connect screen: {definition}");
104+
}
105+
}
106+
107+
/// <summary>The index still exists and still leads on the field, which is what it is for.</summary>
108+
[Test]
109+
public async Task TheFacetLookupIsStillIndexed()
110+
{
111+
await using var db = await PostgresFixture.MigratedAsync();
112+
113+
await using var connection = await db.DataSource.OpenConnectionAsync();
114+
115+
var definition = await connection.ExecuteScalarAsync<string>(
116+
"SELECT indexdef FROM pg_indexes WHERE indexname = 'game_field_field_value_idx'");
117+
118+
await Assert.That(definition).IsNotNull();
119+
await Assert.That(definition!).Contains("field");
120+
121+
// Asserted on the bound rather than the spelling: PostgreSQL reports the expression back as
122+
// "left"(value, 256), quoted, and a test that matched the source text would break on a
123+
// formatting difference while saying nothing about whether the index can overflow.
124+
await Assert.That(definition!).Contains("256");
125+
await Assert.That(definition!.Contains("(field, value)", StringComparison.Ordinal)).IsFalse();
126+
}
127+
}

0 commit comments

Comments
 (0)