Skip to content

Scope graph rebuilds per store, batched and resumable, with run records - #1233

Closed
JeroenDeDauw wants to merge 18 commits into
masterfrom
graph-rebuild-scoped
Closed

Scope graph rebuilds per store, batched and resumable, with run records#1233
JeroenDeDauw wants to merge 18 commits into
masterfrom
graph-rebuild-scoped

Conversation

@JeroenDeDauw

@JeroenDeDauw JeroenDeDauw commented Aug 1, 2026

Copy link
Copy Markdown
Member

Groundwork for background graph rebuilds: graph stores get stable identities, and the all-or-nothing rebuild script becomes a per-store, batched, resumable service with durable run records.

Store identity. Nothing could previously address one store: SPARQL entries were known only by list position, a projection can be served by several stores, and Neo4j has no projection at all — hence names. neo4j names the bundled backend (names are case-sensitive; this one reservation is not), $wgNeoWikiSparqlStores entries take an optional name defaulting to their projection, and extension plugins register under a name. A conflicting or invalid name (reserved, duplicate — later in config order loses — or over 255 bytes) drops that entry entirely: it is neither written on save nor rebuildable until renamed, warned on the NeoWiki log channel. A single-store config behaves identically to master; only configs with two entries sharing a projection must add explicit names. update.php creates the new table; nothing else migrates.

Per-store rebuild runs. RebuildGraphDatabases executes one run per store (--store <name> to scope; default all stores, one at a time — the per-store run model is what later allows concurrency). A run walks subject pages by keyset cursor in batches (--batch-size, default 200), then reconciles deletions. A page-level failure is counted, logged, and stepped over; a store-level failure (initialize, timeout, DB error) ends that store's run as failed with the cursor preserved, and the loop moves on to the next store. --resume continues a failed run past both completed and failed pages — re-run the store to retry its failures. The exit code is non-zero whenever any store ends out of sync: a failed run, page failures, or (under --resume) a store that was never built.

Run records. neowiki_rebuild_runs holds one row per run; status and cursor are the resume contract. One active run per store is enforced check-then-act (a real lock is deferred — the race window is a human double-start). A hard-killed run is released by setting its row to cancelled, which keeps the cursor so --resume works. Store-level error messages are credential-sanitized before persistence and logging. No retention policy yet; rows accrue one per run.

Deferred to the follow-up PR: job-queue execution, REST endpoints, an admin Special page, staleness display, and supersede/cancel — plus three known gaps a reviewer would otherwise flag: a store dying mid-walk is counted as page failures rather than ending the run, the deletion phase is not checkpointed, and the run-start check is not a lock.

Verification: ~150 new tests including resume-after-failure and cursor-boundary cases; mutation-checked (including two survivors an independent review pass found, now closed); the generated schema verified byte-identical across mysql/sqlite/postgres; enumeration equivalence with master proven empirically at seven batch sizes.

Decisions worth a look:

  1. First own database table (vs stashing state in job params) — durable, farm-queryable history.
  2. Sequential writes per store — QLever requires a single writer; intra-store parallelism stays open for Neo4j later.
  3. Skip-and-warn on bad store names rather than hard config failure — mirrors the config's existing degrade behavior.

Open #1226 rewrites the same enumeration area with an incompatible design (internally-paging generator vs resumable keyset cursor); whichever merges second needs rework, not conflict resolution. Proposal: merge this first, then rebase #1226 onto the cursor lookup. Worth deciding order before undrafting.

For #1230.

AI-authored — Claude Code, Fable 5 (max); two-PR plan agreed with @JeroenDeDauw, spec settled in-session; diff not yet human-reviewed; independent review pass applied (blocking finding — backend credentials persisted unsanitized — fixed), mutation-checked, CI green.

Production notes

Design and supervision by Fable 5 (max); implementation, review fixes, and text pass by Opus 5 (max) subagents. The independent review's verified-fine list includes byte-identical regenerated schema on all three DBMS, empirical enumeration equivalence with master at seven batch sizes, and maintenance-runner exit-code compatibility across MW 1.43–master.

@JeroenDeDauw

Copy link
Copy Markdown
Member Author
image

@alistair3149 alistair3149 self-assigned this Aug 4, 2026
JeroenDeDauw and others added 18 commits August 4, 2026 15:00
For #1230

Groundwork for running graph rebuilds in the background. A rebuild rebuilt every
configured backend in one pass, in one unbatched loop, with no way to scope,
resume, or find out afterwards what happened: one unreachable store aborted the
whole thing at `initialize()`, the page ids came back in a single array, and the
script always exited 0.

A rebuild is now always of one store, and rebuilding everything is one run per
store, in sequence. That is what makes each store independently recoverable: an
unreachable store costs only its own run, and only its own run has to be redone.

## Store identity

Every backend has a name, since a scoped rebuild has to be addressed by
something. Neo4j is `neo4j`. A `$wgNeoWikiSparqlStores` entry takes an optional
`name`, defaulting to its `projection`; an entry repeating a name already taken
is skipped with a warning, the way a missing `updateUrl` already is. Extensions
register under a name too, via `NeoWikiRegistrar::addGraphDatabasePlugin()`.

The names are a composition-level map, so `GraphDatabasePlugin` stays name-free
and the composites over it stay unchanged. The save path composes from the same
map and keeps its order and its per-plugin isolation.

## Runs

`GraphRebuildCoordinator` starts and resumes runs; `GraphRebuildExecutor` walks
the wiki for one of them. The maintenance script is a shell over the coordinator,
which is what the background jobs will call.

Each run walks the subject pages in batches, projects each into its one store,
then removes the pages MediaWiki no longer has, recording its cursor and counters
after every batch. `--resume` picks a store's last unfinished run back up from
that cursor, reopening the row so one interrupted rebuild stays one record.

Failures are separated by what they say about the rest of the run. A page that
will not project is logged on the `NeoWiki` channel and counted, and the run
carries on. A store that cannot be reached — including one whose `initialize()`
throws — ends that run, with the cursor to resume from.

Only one run of a store may be going at a time; a second is refused. There is no
supersede or cancel yet: `cancelled` is in the status enum because the jobs will
write it, and resuming already accepts it.

## The table

`neowiki_rebuild_runs`, NeoWiki's first own table, declared as an abstract schema
with the generated per-DBMS SQL committed alongside (`composer dbschema`
regenerates it; a test fails if the two drift) and registered on
`LoadExtensionSchemaUpdates`.

## CLI

`--store <name>` scopes to one store, `--batch-size <n>` sets the batch (200 by
default), `--resume` continues. With no arguments it still rebuilds every store,
so `make rebuild-graph-databases` and the existing docs keep working. Output is
per batch rather than per page, and the script exits non-zero whenever anything
was left unreconciled.

## Deliberately not here

Background jobs and queueing, REST endpoints, an admin Special page, staleness
computation, supersede and cancel, and parallelism within a store. They are the
follow-up PR; this one is the service and the records they need.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Since 1.46, Maintenance::fatalError() only throws instead of exiting when the
script is flagged as being under test, so the tests covering the rebuild's
non-zero exits exited the PHPUnit process instead. Flag the script where the
property exists; the older supported versions have no such flag and go by the
MW_PHPUNIT_TEST constant alone.

Also states in RebuildRun that only failed() leaves an error behind.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…ndings

Reporting failure with fatalError() ended the whole PHPUnit process on
MediaWiki 1.46, which since that version only converts it into an exception for
scripts flagged as under test. Returning false from execute() is what
MaintenanceRunner turns into the exit status anyway, and it is testable on every
supported version without a shim.

Behaviour, from the review:

* Resuming every store no longer fails over the stores whose last rebuild
  finished; asking for one store by name and finding nothing to resume still does.
* An Error thrown mid-run is recorded as the run's end rather than leaving it
  recorded as still going, which would block every later rebuild of that store.
* A SPARQL store may no longer take the name 'neo4j', which silently replaced the
  Neo4j backend in both the rebuild map and the save path.
* An extension plugin whose name is already taken is warned about rather than
  dropped in silence, matching what a configured store repeating a name does.
* A store name that is numeric no longer breaks rebuilding every store.
* Skipped pages are logged, so a page a lagging replica hid leaves a trace.
* An over-long store error no longer fails the write that records the failure.

Documentation now says what the code does: the walk over the wiki is what is
checkpointed and resumable, a store that dies mid-run is not recognised as such,
only one *started* run per store is refused, and a rebuild killed outright leaves
a row to clear.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Three of them recorded what the spy already recorded and differed only in
what they refused, so the spy now takes the pages it will not save, whether
it refuses deletions, and what it throws — the one thing a rebuild reads,
since a wiki-database error ends the run where a rejected page does not.

Names the deletion observer's running total for what it is, and moves the
null observer to the test doubles it has always been the only user of.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A client reports an unreachable server by quoting the connection URI it
tried, credentials and all, and a SPARQL store quotes the endpoint URL it
posted to. Those messages were stored verbatim in the run records and logged
verbatim per failed page, where update.php's report already redacted them.

Lifts that redaction out of the update hook into the graph-database domain,
where both paths reach it. Ending a run now also logs on the NeoWiki channel,
which the script and the docs already promised: the run record cannot hold the
exception class or its backtrace, and until now nothing did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Building it can fail on its own — a backend whose configuration will not
resolve — and it was built as an argument to the executor, so after the run
row already said the store was rebuilding. The throw escaped the executor's
Throwable net, leaving that row Running for good, and both starting and
resuming a rebuild refuse while one is on: the store was wedged.

A factory failure now stops a run being recorded at all, which is what it is:
nothing ran, so there is nothing to resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resuming every store passes over the ones with nothing to continue, which
conflated three states: a store already reconciled, one added since the last
rebuild and never built, and one whose last run finished with pages it could
not reconcile. The last two exited zero, so a scheduled --resume left a store
holding none of the wiki and still reported success.

The exception now carries the run it looked at, and only a run that succeeded
with nothing failed counts as in sync — the same test the ordinary path
already applied to a run it started itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An extension plugin naming itself after a configured store was dropped where
the two sets were combined, without a word — while a plugin colliding with
another plugin, and a configured store colliding with another store, both
warned. The registry is now told the bundled names up front, so every
collision comes out of the one path that reports it.

A store called "neo4j" got the collision message meant for two entries
colliding with each other, which told the operator to give one of them an
explicit name — no help when the other one is a bundled backend. It now says
what it is, and rejects the name in any casing, since a store called "Neo4j"
reads as that backend wherever a name is written. A name too long for the run
records to file the store under is refused the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintenance turns anything unparseable into 0, and the script rounded that up
to 1, so a typo silently walked the wiki a page at a time. It now says what is
wrong and rebuilds nothing, and the coordinator refuses an empty batch itself
rather than trusting a parameter type only its analysed callers are bound by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The closing line claimed not every page was reconciled whenever any store was
left behind, including for failures that never reached a page — an unknown
store name, a rebuild refused because one was already going. It now names the
stores instead, which is what the operator acts on one at a time.

A store's failure goes to stderr, so a scheduled rebuild reaches whoever reads
that rather than only the log its output went to. And a page count is not
something anyone can act on, so the failing page ids are named too, the first
few of them, with the log channel holding the rest and the reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each of these survived the suite unchanged: the cursor not advancing past a
page that failed (which loops the executor over the same batch for good), a
page skipped for carrying no Subject, an oversized error cut mid-character,
the store being prepared only after the pages were projected, the two guards
on resume(), and the wiki-database rethrow in the removal phase.

Replaces an assertion that a resumed run carries no error — true after any
transition, so it could not fail — with a RebuildRun test that says which
transitions clear it and which keeps it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Uses core's batch-cursor idiom in the subject-page walk, which drops the
  repo-wide PHPStan ignore this PR added for every use of expr(), including
  the four already covered by the baseline.
- Reads an unrecognised status or trigger back as no run, so a row written by
  a newer version cannot fatal in the middle of a rebuild.
- Keys the SPARQL plugins by the store name they already carry, so the name
  comes from one place.
- Drops exception properties nobody reads, hides the two lookups nothing
  outside the extension builds, and shares the one run repository the
  coordinator and its executor both need.
- Adds a `make dbschema` target for regenerating the per-DBMS SQL, which the
  schema test now names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The extension guide never mentioned the name a graph database backend must
  now register under, or what happens to one that takes a name already held.
  Its failure paragraph still described a rebuild of everything at once.
- The recovery from a killed rebuild was "clear the run's row", which named
  neither the table nor what to write, and throwing the row away would take
  the cursor --resume continues from with it.
- --resume continues a run that failed; a run that finished having left pages
  behind is re-run, not resumed. The docs said only "if a run ends".
- The single-store configuration example set a name, which the paragraph
  under it advises against.

Also settles the ops docs on "graph store" where they mixed it with "backend",
and gives both the same command form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cuts restatement and derivation from the prose the per-store rebuild added, without changing what any
of it claims:

- The store-naming sentence now reads in one direction instead of three.
- The failure-reporting paragraph named the NeoWiki channel twice for the same fact.
- The extension-facing failure contract dropped the clause explaining why the two failure kinds differ.
- Backfilling a new SPARQL entry links to the command's own section rather than repeating it inline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebuild rework renamed newSubjectPageRebuilder() away, leaving the recipe
in docs/extending/extending.md calling a method that no longer exists. The
import variant already had the semantics an extension-triggered refresh needs
(every store, hook-path failure isolation), so it takes the general name back
and both callers share it. The refresh's failure contract is now documented as
isolating, matching what it does.

Also closes the text-review flags on the rebuild docs: the kill -9 recovery
statement is now executable as written (store-scoped, no run-id hunt), the
mirrored-projection note asks for one explicit name rather than two, the
255-byte name cap is stated, and the duration bullet names --resume and
--store as the remedies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs (#1236)

* Make a rebuild batch the unit of work, and close three gaps

A rebuild ran as one uninterruptible walk driven by the maintenance
script. Splitting a batch out as its own step is what lets the same code
be driven a batch at a time from elsewhere, and it closes the three
things the scoped rebuild left open:

- The removals are checkpointed like the projections. Both phases now
  walk by keyset cursor, and the run records which phase that cursor
  belongs to, so a rebuild interrupted between them continues with the
  removals instead of reprojecting the whole wiki first.
- Starting a run takes the store's advisory database lock across the
  check for an active run and the row that check guards, so two callers
  starting at once produce one run and one refusal.
- A batch of two or more pages that fails in its entirety is read as the
  store having gone rather than as a wiki of unprojectable pages: the run
  ends with its cursor left at that batch, so resuming retries it. A
  batch of one is exempt, or one broken page would wedge resume in a
  retry loop.

The status and trigger enums gain the values the surfaces in the commits
after this one file runs under, and the run carries the times the record
already held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Run graph rebuilds on the job queue

A rebuild could only be run by someone sitting at a shell for as long as
it took. This files one as work instead: a job per batch, each queueing
the next until the run is done, so a rebuild can be asked for from a web
request and outlive it.

The run record is the state, not the queue. A job carries only which run
to advance, and reads that record before doing anything, so a job for a
run that has since been cancelled or finished does nothing — which is
what lets cancelling be a single write, with no reach into the queue, and
what makes a retried job safe.

Rebuilds started here and run in a maintenance script are the same runs,
sharing one record per store, so cancelling reaches a rebuild wherever it
is running and only one may be under way at a time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report how far each graph store is from the wiki

Nothing said whether a store's contents still matched what the wiki
described. Editing a Mapping page is the case that matters: it changes
what every mapped page's graph should contain, and nothing reprojects
those pages, so the store keeps serving the old vocabulary with no sign
that it is doing so.

Each store is now reported as never built, stale, or in sync, derived
from the run records and the wiki rather than stored: anything stored
would be one more thing to keep true, and would be wrong exactly when it
mattered. Staleness is measured against when the last finished rebuild
started, not when it ended, because a Mapping edited mid-rebuild leaves
the pages that rebuild had already passed projected under the old rules.

Only a store holding an ontology projection can go stale this way. The
native projection and a backend holding no RDF have no editable
definition, so once rebuilt they stay as current as the per-edit
projection keeps them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report and rebuild graph stores over the REST API

A rebuild could only be asked for from a shell, and how far a store was
from the wiki could only be worked out by reading run rows. Three
endpoints now answer both, so a rebuild is something the wiki can be
asked for rather than something only its host can do.

They are gated on a new neowiki-admin right, granted to administrators.
What they report and change is the installation's own machinery, so no
page's permissions could stand in for it, and no OAuth grant maps to it —
this is not something a delegated application should acquire by asking
for a category of access.

A store is described by what it holds and how far that is from the wiki,
never by how it is reached: the endpoint URL and access token it is
configured with stay out of every response, because being allowed to
rebuild a store is not being allowed to read the credentials for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Special:GraphStores for reporting and rebuilding stores

Rebuilding a graph store, or finding out whether one needed it, meant
shell access to the server. An administrator can now see and do both from
the wiki.

Rendered on the server and read from the run records, so what it shows is
what a rebuild has actually recorded rather than a copy of it kept in the
browser. A rebuild started here runs on the job queue, so the page comes
back at once and progress appears on reload; there is no auto-refresh, so
watching one costs a wiki nothing.

Every action posts and redirects back, which is what makes reloading to
watch a rebuild advance safe: it repeats a read rather than the
submission that started the rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Optionally rebuild a store when its Mapping changes

Editing a Mapping page changes what every mapped page's graph should
contain, and reprojects nothing: the store keeps serving the old
vocabulary. That is the gap this whole thread exists to close, and
$wgNeoWikiAutoRebuildOnMappingChange closes it without anyone having to
notice.

Off by default. Such a rebuild reprojects every page carrying a Subject
into that store, which on a large wiki is substantial work an
administrator should choose to spend; while it is off, the store shows up
as stale on Special:GraphStores with the rebuild a click away.

A store already rebuilding is replaced rather than left to finish: a run
begun under the old Mapping has projected part of the wiki under rules
that no longer apply, so finishing it would leave the store half in each
vocabulary with nothing recording that it had. Ending the old run and
filing the new one happen under the store's start lock, so nothing can
slip a third rebuild in between.

The work is deferred past the change's own transaction. A rebuild cannot
be started inside one — taking the start lock flushes the connection's
snapshot, which a transaction with writes pending may not do — and an
edit must not wait on a lock or a queue to be saved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document background rebuilds and the right they need

Covers what an administrator now has: the page and the endpoints, the
job-runner caveat that decides whether a large rebuild finishes this week
or next, and that rebuilds started anywhere are the same runs. Also
replaces the manual SQL for releasing a killed rebuild, which cancelling
on the page now does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop a batch writing over the run it no longer owns

A batch read the run, spent minutes projecting, then wrote back what it
got through — carrying the status it had read. A cancellation landing in
that window was written straight back over: the admin was told the
rebuild had stopped and it carried on. Worse, an automatic rebuild
restarting a store mid-batch left the old run resurrected alongside the
new one, both projecting into the store the design exists to give one
rebuild at a time.

A batch's write now only lands while the records still have the run
going; one ended in the meantime keeps the status that ended it, and the
batch's work is dropped rather than written over it.

Alongside it, from reviewing the same code:

- Timestamps are read back as MediaWiki timestamps rather than in
  whatever format the database stores them in, so staleness is not
  decided by comparing two different formats as strings.
- A batch is read as store death only when every page the store was
  actually offered failed. One page the wiki dropped between the walk and
  the batch used to spare a dead store its verdict.
- The batch totals reach the observer as something to call rather than as
  numbers, so a background rebuild no longer counts the whole wiki twice
  per batch to tell a reporter that reports nothing.
- A batch is queued after the run row it names commits, not before.
- A continuation that cannot be queued ends its run, like the first batch
  already did, rather than leaving it recorded as going with nothing
  going.
- Special:GraphStores checks read-only mode, catches every failure its
  message claims to cover, and logs the reason that message points at.
- Losing the start lock is a 409 rather than a 500, and a run's error is
  no longer serialized into REST responses — it can quote the endpoint a
  backend could not reach, which is for whoever reads the records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert the start lock is let go of without lockIsFree

MediaWiki removed that method after 1.46, and it was the only thing these
two tests used it for. What they are really about is that a store whose
last rebuild was filed, or refused, can start another — which is a thing
the lock's callers can observe.

Also records why the restriction is passed to the SpecialPage
constructor, deprecated on 1.46, rather than declared the way that
release suggests: on 1.43 the permission check reads the property the
constructor sets and not getRestriction(), so overriding that would leave
the page open to everyone there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop a stale read resurrecting a cancelled rebuild

A batch's conditional write reported success by reading the row back, but that
read is served from the batch's own transaction snapshot — which a job runner
opens before the batch, and which therefore predates the cancellation the check
exists to notice. The write was correctly refused and reported as landed anyway.
Whether it landed now comes from the row count the write matched, and what ended
the run is read under a lock so another connection's commit is visible.

Taking a queued run up was an unconditional write on top of that, so a
cancellation committed before the first batch was written straight back over,
resurrecting a run the admin was told had stopped. It goes through the same
conditional path, and a run something else ended stops there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop Special:GraphStores running what its query string carries

The store name it reports back was substituted into the outcome message as
wikitext, which the message transform then ran: a link handed to an
administrator executed whatever it carried, on a GET. It is now substituted as
plain text, after the transform.

The outcome itself decided a message key, so the query string chose which
message the page showed. Only the outcomes this page redirects with are
reported now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Recognise a dead store in both phases of a rebuild, and only there

A store refusing every removal ended its run Succeeded: the removal phase had no
notion of a store that had gone, so the store was reported in sync while every
page the wiki deleted stayed queryable in it, out of reach of --resume. Removals
now report failure the way projections do, and a whole batch of them failing
ends the run with its cursor rewound to that batch.

The test the heuristic never had also showed it reading two things as a dead
store that are not one. A short last batch is where the walk's permanently
unprojectable pages collect, and a batch almost all of whose pages the wiki has
since dropped tells the store's answer from one page. Both left --resume
retrying pages that will never work, so a batch now has to be full and to have
reached the store with at least two pages.

The pages of a batch the run ends on are no longer logged or reported as pages
that failed. That batch is retried from where it began, so nothing should be
left recording them as settled failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop counting the whole wiki once per rebuild batch

Every batch handed its observer a way to count how many pages there are in
total, and the one observer that wanted a denominator called it — turning a
rebuild into a full-wiki count per batch. The script counts each total once, for
the store it is about to rebuild, and the observer is left reporting what a batch
did rather than how much there is of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cancel a rebuild without reading it first

Cancelling read the store's active run and then wrote it back cancelled. A run
that reached the end of the wiki in between had that recorded over: the rebuild
had reconciled the wiki, and the store was then reported as never built. The
records now end the run in one conditional write, and say whether there was one
to end.

The cancelled run also keeps whatever progress the records hold rather than
whatever the caller last read, so a batch that advanced it meanwhile is not
rolled back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Leave a rebuild somebody started out of the automatic one's reach

An edit to a Mapping page cancelled whatever rebuild its store had going and
queued its own. With automatic rebuilds enabled, that let anyone who may edit a
Mapping take away a rebuild an administrator had started and was waiting on, and
start their wait over from the beginning of the wiki.

An automatic rebuild now replaces only another automatic one. A run started from
the script, the page or the API is left to finish, and said so on the log. The
store is reported stale once it ends, so the changed Mapping is not lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report a store whose Mapping was deleted as stale

Deleting the Mapping page that defines a projection left every page projected
under it carrying that vocabulary, with nothing reprojecting them — but the
store read as in sync, because the last change was looked for on a page that no
longer exists. The deletion log now says when, for a page that is gone. A
projection nothing ever defined still reads as never changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Refuse to start or cancel a rebuild while the wiki is read only

Both endpoints write — a run record, and the jobs that carry it — and neither
asked whether the wiki was accepting writes. They answer 503 with the reason
now, rather than leaving the write to fail somewhere further down with a
half-filed rebuild behind it. Special:GraphStores already checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say what the batch-push catches actually reach

Both comments read as though every failed push were caught there. A web
request's push is deferred past the response, and what runs it logs failures
rather than raising them, so what these see is a job the queue refuses before
the deferral plus everything pushed under the command line.

A continuation the queue will not take now has the test the first push already
had: it ends the run rather than leaving it recorded as going with nothing
carrying it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Redact the credentials a store carries outside its connection URI

Only the userinfo of a connection URI was removed, which is how a Bolt driver
quotes credentials. A SPARQL store carries them in the query string or in an
Authorization header instead, and those messages are kept just as long — on a
terminal, in deployment logs, and in the rebuild run records. Access tokens, API
keys, passwords and bearer tokens are removed too now, leaving what names them
so the message still says how the store was being authenticated to.

Special:GraphStores also declares that it writes, so MediaWiki routes it to the
primary database rather than a replica.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Agree with the script on what a store left out of sync looks like

A rebuild that ran to the end but could not reconcile every page left the
maintenance script exiting non-zero and the page saying "In sync" about the same
records. The store holds a copy of the wiki with holes in it, which is where a
store nothing has ever rebuilt stands, so it is reported the same way.

Rebuild batches are also filed as deduplicable. The queue only drops a batch
matching one still waiting to be claimed, so a run's serial chain is unaffected
and what is dropped is the second copy of a batch that got run twice — which is
where a run would otherwise fork into two chains advancing one cursor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Close the test gaps the graph-store surfaces were left with

The refusals asserted only their status code, so a handler that refused and then
went on to write would have passed; they check the records now too. The CSRF
path was never driven at all, only stubbed past — the real validator refuses
both mutating endpoints in a test. A batch filed for a run nothing exists under
asserted the absence of a run nothing had created, which no change could break;
it asserts what the batch projected and what it said instead.

Stale is the one store state an operator reaches without a rebuild having
failed, and neither the page nor the REST serializer had ever rendered it. Both
do now, over a Mapping edited after the rebuild that read it.

A resume that picks up partway through the removals also has a test: the
removals already made are not made again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tidy the edges the graph-rebuild work left

- A page called Mapping:Native no longer sets an automatic rebuild going. The
  native projection is defined by NeoWiki's own code, which is already why a
  store holding it never reads as stale.
- A rebuild batch carries only the run it advances. The store came along in the
  job parameters as a second copy of what the run record already says, which a
  job filed for one store could have used to advance another's run.
- A job whose parameters are not what this version files reads as run 0 rather
  than fatalling on a missing key.
- The log tells a rebuild started from the wiki to rebuild the store again,
  rather than naming a maintenance-script option whoever started it cannot reach.
- A Mapping edit on a wiki that has not asked for automatic rebuilds registers no
  deferred update at all, and one that has cannot take the edit's deferred work
  down with an unresolvable backend.
- Removed the stray blank line in extension.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Take out what the graph-rebuild work does not use

- GraphStoreStatusLookup::getStatus() and RebuildProgress::getCursor() had no
  production caller.
- The graph-store endpoints share an access rule and an error shape; building a
  serializer is not something they share, only something each of them does.
- The rebuild coordinator's batch size no longer defaults on the factory, so a
  production caller says which size it means rather than the parameter being
  there for tests to override.
- The start lock is a port the rebuild use case depends on, so it and its
  exception sit with the use case rather than in Persistence, giving the REST
  handler one namespace to catch its refusals from. The implementation stays.
- NeoWikiExtension::getStoreProjections() is private; what a Mapping change looks
  at is its own accessor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Share the test helpers the rebuild suites were each keeping

Creating pages that carry a Subject, reading back what a store was given,
deleting a page and flattening a log buffer were written out once per test
class. They are on the shared base class now.

Two suites keep their own page deletion, overriding rather than repeating it:
theirs also runs the deferred updates the projection they watch happens in. The
one that returns the status its test asserts on is named for that instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say in the docs what a rebuild now does about its own failures

- `--resume` continues in whichever of the rebuild's two phases it stopped in.
- A store that stops answering ends the run, with the size at which that stops
  being recognisable.
- Cancelling on the page or over the API also stops a rebuild the script is
  running.
- A rebuild queued on a wiki whose job runner does not load NeoWiki stays queued,
  blocking the next one, until it is cancelled.
- A deleted Mapping page makes its stores stale, as an edited one does.
- The rebuild endpoints answer 503 while the wiki is read only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tighten the prose the background-rebuild work added

- The rebuild's two halves are stated once, where `--resume` needs them, rather
  than restating what the section opener already said.
- Cut the reasoning behind a whole failed batch ending the run, and behind one
  rebuild per store blocking the next: both restate the contract above them.
- The maintenance page links the REST reference for the graph-store endpoints
  rather than repeating paths nothing checks for drift.
- `neowiki-admin` allows viewing rather than reporting, and a Mapping edit
  rather than "it" is what cannot take a hand-started rebuild away.
- The stale state reads "changed at <time>", and a rebuild that could not be
  queued is "a" rather than "the" rebuild.
- qqq: deleting a Mapping page makes its stores stale as editing one does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the graph-store UI text match what the page can do

The cancelled-outcome message promised a resume only the maintenance script
offers; it now states what actually happens. The state label for a store no
run ever fully reconciled read "Never built" beside a listed last rebuild;
it now reads "Not reconciled", which covers both ways of getting there.
Also: the Stale docblock said finished where the code compares against
started, the auto-rebuild docs state what happens to a Mapping edit during a
hand-started run, and the REST doc gives the rebuild endpoints' refusal
order straight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Refusal orders, HTTP codes conventions already imply, security assurances
addressed to a reviewer, tiny-wiki edge cases, and restatements of adjacent
sentences all go. The graph-store gating note shrinks to its one fact; CSRF
was already covered file-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A text-review pass over the reader-facing rebuild docs: drop what the
named reader does not act on — convention-implied status codes, UI and
job-queue narration whose home is elsewhere, derivation clauses, and a
cross-file restatement of the neowiki-admin default, now a link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alistair3149

Copy link
Copy Markdown
Member

Superseded by #1254, which is this branch rebased onto master and reworked onto the page enumeration #1226 introduced. Leaving this open as the record of the original design and its review — worth closing once #1254 is accepted.

#1226 merged first, and as the description above predicted, that means rework rather than conflict resolution: it replaced SubjectPageIdsLookup/SubjectPageRebuilder with PageIdsLookup/PageRebuilder and changed the scope from Subject-bearing pages to every page. Resolved mechanically, the tree would have carried two enumerations of the same wiki and a rebuild reconciling a subset of what every other path projects.

#1254 also carries the 13 review-fix commits that were #1250, folded in before the rework so the same conflicts were resolved once. It is a draft: 12 tests still fail, and three of them raise a question about what wasOfferedToTheStore should mean now that pages without Subjects are projected too — detail in its description.

@alistair3149
alistair3149 force-pushed the graph-rebuild-scoped branch from f633528 to 83f4f8b Compare August 5, 2026 19:03
@alistair3149

Copy link
Copy Markdown
Member

Rewound to 83f4f8bf, its state before the #1250 commits were briefly folded in, so this PR stands as an unmodified record. The review fixes and the rework both live in #1254.

@alistair3149

Copy link
Copy Markdown
Member

Superseded by #1254

@alistair3149

Copy link
Copy Markdown
Member

#1254 — this PR rebased onto master, reworked onto the #1226 page enumeration, and carrying the #1250 review fixes — has been merged. Closing this record PR as superseded; its diff and discussion remain the reference for the original design and its review.

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.

2 participants