Skip to content

Join the crawler to its storage, and make a measured hour tell you which of three it was - #7

Merged
HarryCordewener merged 1 commit into
mainfrom
feat/crawler-ingestion
Jul 31, 2026
Merged

HarryCordewener merged 1 commit into
mainfrom
feat/crawler-ingestion

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

Every piece existed and nothing connected them: IProbe/TelnetProbe produced a ProbeResult, the three writers consumed one, MUI.Discovery knew when to dial and who a probe was, and no code path ran a probe into a database. MUI.Crawler is that path, and AddMuiCrawler(…) is the one call that wires it into the deployable. MUI.Web/Program.cs is untouched.

ProbeIngestor — spec §6.5's seam

The four rules, each pinned by a test named after it:

Rule Pin
Answered with a count → one presence row with that count, including a measured zero AProbeThatAnsweredWithACountWritesThatCount, AMeasuredZeroIsAStoredCountAndNotAnAbsence
who outranks mssp, and the choice is made before the writer WhoOutranksMsspAndTheChoiceIsMadeBeforeTheWriter, AZeroFromWhoStillBeatsANonZeroFromMssp
Answered, no count obtainable → count = NULL + a reason. Never a zero, never nothing AnAnsweredProbeWithNoCountObtainableWritesANullCountAndAReasonAndNeverAZero
Failed → an availability transition and no presence row AFailedProbeWritesAnAvailabilityTransitionAndNoPresenceRowAtAll
A refusal never reaches the ingestor AScopeRefusalCannotBeExpressedAsAProbeOutcome, ADialWeRefusedIsNotInAnyGamesRecord

presence_sample is keyed (game_id, at), so the ladder — whomsspbanner — is applied in PresenceChoice before PresenceWriter is called. A failed probe also confirms no fields: a dial that never got in confirmed nothing, and bumping last_confirmed_at would record our crawler having run as the game having answered.

The measured half of the capability matrix is written true only where a protocol was observed, and the one honest negative is MSSP — because MSSP is the one option this probe asks for (IAC DO 70 on every dial), so silence is an answer. Writing false for the rest would be a lie we can already measure: alteraeon.com:23 plainly implements MSDP, GMCP, MXP and MCCP, and one probe of it yields an offered set of exactly { MSSP }. That is our instrumentation, not their game.

CrawlerService — §12

A BackgroundService behind a Postgres advisory lock, so N web replicas run exactly one crawler. CrawlCycle bounds concurrency with a semaphore, serialises per host through HostGate, honours both CrawlRateLimiter floors, and hard-bounds every probe by timeout and CancellationToken on top of whatever the probe promises — the crawler shares a process with the web tier, so this is correctness rather than hygiene. CRAWL DELAY is read off MSSP with -1 kept as "no preference" and composed by ProbeSchedule, which already knows politeness outranks the weekly floor.

The lease releases the lock explicitly on the way out, and that is not belt-and-braces: Npgsql pools, so a disposed connection returns to the pool with its session — and therefore its session-level lock — still alive. A replica standing down would have stopped the crawl for the whole deployment until its process exited. ReleasingTheLeaseLetsTheNextReplicaTakeIt failed exactly that way before the fix.

mui-crawl

mui-probe prints what one server said; mui-crawl prints what a cycle wrote, read back out of the database rather than tallied in memory. It builds the same graph AddMuiCrawler builds.

Verified against live games, twice

Four real servers into a real PostgreSQL 17. All three rungs of the ladder fired:

aardmud-org-4000       3 fields  205 players  (banner)     reachable
alter-aeon            59 fields   46 players  (mssp)       reachable
eldertaleonline-com   29 fields    0 players  (who)        reachable
m-u-s-h               16 fields   13 players  (who)        reachable

Aardwolf supports no MSSP and answers no pre-login WHO, so no MSSP-only directory can count it at all; the connect screen can. eldertaleonline.com publishes NAME "PennMUSH" — an unedited codebase default — and was listed under its own HOSTNAME rather than as one more game called PennMUSH, which is §7.3's auto-merge failure met in the wild. A second cycle wrote four more presence rows, zero new fields, zero field changes and zero availability transitions, which is the shape §5.1 promises.

That run found a defect no fixture could have. Aardwolf's connect screen states its own live player count, so reconciling connect_screen wrote changed from ####… to ####… on every probe, for ever, at one row per game — the exact cost §5.1 exists to avoid, burying every real event under an unreadable diff. The banner is upserted now, beside the fingerprint it is the other half of; a redesign is still noticed, because that is what banner_hash is for.

New schema

0006_crawl_registry.sql adds crawl_target, referral_edge and duplicate_review — the discovery half of the schema, which nothing had written — plus the folded functional index IGameFieldIndex's case-insensitive predicate needs. crawl_target has no retirement column and no way to acquire one, held by a test that reads information_schema as well as the interface.

Worth a reviewer's eye

  • MUI.Crawler.Cli is a project the plan did not name. The brief allowed "a mui-crawl entry point or an option on MUI.Probe"; a new project keeps src/MUI.Probe untouched.
  • MsspCrawlDelay belongs beside MsspReferrals in MUI.Discovery, which is where CLAUDE.md files this class of reading. It is in MUI.Crawler to respect the scope this branch was given; move it when scopes reconverge.
  • ICrawlTargetRepository.RecordAttemptAsync has two arms and a refusal needs a third. A scope refusal is not the host failing, so it must not accrue backoff — but there is no "leave the failure count alone" arm, so the refusal path takes succeeded: true and clears a count nothing will act on, since a refused target is never dialled.
  • IGameStore has no rename. A game's game.name is set when it is listed; a later NAME change moves the game_field row and the change feed, and the game row keeps its original. §5.7's deliberate slug stability does not extend to the display name.
  • ArchiveSweeper.SweepAsync still has no caller. Un-archiving is wired (one successful probe restores a game, immediately); archiving needs a once-a-day cadence decision that did not belong in this branch.
  • tests/MUI.Crawler.Tests/Support/PostgresFixture.cs is a copy of the Catalog suite's. Referencing that project would pull its source-generated tests in and run the storage suite twice; the right fix is a small shared testing project.

Five suites, all green, warning-free: Catalog 133, Crawl 125, Crawler 76, Discovery 182, Web 71.

🤖 Generated with Claude Code

…ich of three it was

Every piece existed and nothing connected them: IProbe produced a ProbeResult, the
three writers consumed one, the scheduler and the registry knew when to dial, and no
code path ran a probe into a database. MUI.Crawler is that path.

ProbeIngestor is spec §6.5's seam and holds the four rules that matter most here. A
probe that answered with a count writes one presence row — including a measured zero,
which is a filled cell and a real fact about a game. `who` outranks `mssp` and the
choice is made in PresenceChoice *before* the writer, because presence_sample is keyed
(game_id, at) and there is nowhere to keep both. A probe that answered and could not be
counted writes count = NULL with a reason: never a zero, and never nothing, because
nothing is indistinguishable from not having probed and renders as downtime. A probe
that failed writes an availability transition, no presence row, and no field
confirmations — a dial that never got in confirmed nothing, and bumping
last_confirmed_at would record our crawler having run as the game having answered. A
scope refusal cannot reach the ingestor at all: ProbeOutcome has two members and both
mean the socket was opened, so §7.2's "a refusal writes no availability sample" is
satisfied structurally rather than by a check somebody has to remember.

The measured half of the capability matrix is written `true` only where a protocol was
observed, and the one honest negative is MSSP — because MSSP is the one option this
probe asks for. Writing `false` for the rest would be a lie we can already measure:
alteraeon.com:23 plainly implements MSDP, GMCP, MXP and MCCP, and one probe of it
yields an offered set of exactly { MSSP }. That is our instrumentation, not their game.

CrawlerService is a BackgroundService behind a Postgres advisory lock, so N web
replicas run exactly one crawler. The lease is session-level and owns its connection —
and releases the lock explicitly on the way out, because Npgsql pools and a disposed
connection returns to the pool with the session, and therefore the lock, still alive. A
replica standing down would have stopped the crawl for the whole deployment until its
process exited. CrawlCycle bounds concurrency with a semaphore, serialises per host,
honours both rate floors, and hard-bounds every probe by timeout and token on top of
whatever the probe promises: the crawler shares a process with the web tier, so a
wedged probe starving request threads is a correctness bug. CRAWL DELAY is read off
MSSP with -1 kept as "no preference" and composed by ProbeSchedule, which already knows
that politeness outranks the weekly floor.

0006_crawl_registry.sql adds the registry, the referral graph and the review queue —
the discovery half of the schema, which nothing had written. crawl_target has no
retirement column and no way to acquire one.

Verified against four live games into a real PostgreSQL, twice. All three rungs of the
count ladder fired: WHO on mush.pennmush.org (13) and eldertaleonline.com (a measured
0), MSSP on alteraeon.com (46), and the connect screen on aardmud.org (205), which
supports no MSSP and answers no pre-login WHO and which no MSSP-only directory can
count at all. eldertaleonline.com publishes NAME "PennMUSH" — an unedited codebase
default — and was listed under its own HOSTNAME rather than as one more game called
PennMUSH, which is the auto-merge failure §7.3 warns about, met in the wild.

That run also found a defect no fixture could have. Aardwolf's connect screen states
its own live player count, so reconciling connect_screen wrote "changed from ####… to
####…" on every probe, for ever, at one row per game — the exact cost §5.1 exists to
avoid. The banner is upserted now, beside the fingerprint it is the other half of. A
redesign is still noticed; that is what banner_hash is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 977dfe32-deb3-4a2e-ab79-f9a71b896e70

📥 Commits

Reviewing files that changed from the base of the PR and between 30bf135 and 23f07f5.

📒 Files selected for processing (37)
  • .github/workflows/ci.yml
  • CLAUDE.md
  • Directory.Packages.props
  • MUIndex.slnx
  • migrations/0006_crawl_registry.sql
  • src/MUI.Crawler.Cli/Arguments.cs
  • src/MUI.Crawler.Cli/CrawlSummary.cs
  • src/MUI.Crawler.Cli/MUI.Crawler.Cli.csproj
  • src/MUI.Crawler.Cli/Program.cs
  • src/MUI.Crawler/CatalogueBinder.cs
  • src/MUI.Crawler/CrawlCycle.cs
  • src/MUI.Crawler/CrawlLease.cs
  • src/MUI.Crawler/CrawlerOptions.cs
  • src/MUI.Crawler/CrawlerService.cs
  • src/MUI.Crawler/CrawlerServiceCollectionExtensions.cs
  • src/MUI.Crawler/FieldObservations.cs
  • src/MUI.Crawler/GameSlug.cs
  • src/MUI.Crawler/MUI.Crawler.csproj
  • src/MUI.Crawler/MsspCrawlDelay.cs
  • src/MUI.Crawler/Persistence/CatalogueDirectories.cs
  • src/MUI.Crawler/Persistence/NpgsqlCrawlTargetRepository.cs
  • src/MUI.Crawler/Persistence/NpgsqlDuplicateReviewRepository.cs
  • src/MUI.Crawler/Persistence/NpgsqlReferralRepository.cs
  • src/MUI.Crawler/PresenceChoice.cs
  • src/MUI.Crawler/ProbeIngestor.cs
  • tests/MUI.Crawler.Tests/CrawlCyclePostgresTests.cs
  • tests/MUI.Crawler.Tests/CrawlDelayAndSlugTests.cs
  • tests/MUI.Crawler.Tests/CrawlLeasePostgresTests.cs
  • tests/MUI.Crawler.Tests/CrawlRegistryPostgresTests.cs
  • tests/MUI.Crawler.Tests/FieldObservationTests.cs
  • tests/MUI.Crawler.Tests/MUI.Crawler.Tests.csproj
  • tests/MUI.Crawler.Tests/PresenceChoiceTests.cs
  • tests/MUI.Crawler.Tests/ProbeIngestorTests.cs
  • tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs
  • tests/MUI.Crawler.Tests/Support/PostgresFixture.cs
  • tests/MUI.Crawler.Tests/Support/Probes.cs
  • tests/MUI.Crawler.Tests/Support/ScriptedProbe.cs

Comment @coderabbitai help to get the list of available commands.

@HarryCordewener
HarryCordewener merged commit 6a6b823 into main Jul 31, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/crawler-ingestion branch July 31, 2026 03:25
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