Skip to content

Run graph rebuilds in the background with admin, API, and auto triggers - #1236

Merged
JeroenDeDauw merged 27 commits into
graph-rebuild-scopedfrom
graph-rebuild-background
Aug 2, 2026
Merged

Run graph rebuilds in the background with admin, API, and auto triggers#1236
JeroenDeDauw merged 27 commits into
graph-rebuild-scopedfrom
graph-rebuild-background

Conversation

@JeroenDeDauw

@JeroenDeDauw JeroenDeDauw commented Aug 2, 2026

Copy link
Copy Markdown
Member

Fixes #1230.

Stacked on #1233 (base graph-rebuild-scoped); the diff shows only this PR's commits. Rebuilds become background work with admin and API triggers, and Mapping edits stop silently leaving stores stale.

Execution model. A background run is a chain of neowikiGraphRebuild jobs, one batch per execution. Between batches the run row is the only coordination state: each job re-reads it, stops if it is terminal, executes one batch through the same step the CLI loop uses, and pushes its continuation. The alternative — the job queue's own root-job/dedup machinery — was rejected because its supersede state lives in a WAN cache, while a row is durable and inspectable. Every run-row write is conditional on the run still being active, and the what-ended-it re-read is a locking read, because a plain re-read inside the job's REPEATABLE READ transaction returns the pre-cancellation snapshot (regression-pinned; the naive version resurrected cancelled runs). Cancelling — from UI, API, or SQL — is one conditional UPDATE; a CLI run observes it at its next batch boundary. Run creation additionally holds a per-store advisory DB lock, start-time only, so concurrent starts cannot double-create; jobs never hold it.

Run semantics. New Queued status and Api/Ui/Auto triggers. Both phases checkpoint their cursor (nwrr_phase distinguishes them), so --resume continues within whichever phase was recorded. A store dying mid-walk is recognized when a batch that filled its limit had every still-existing page fail (at least two) — the run ends failed with the cursor rewound to that batch, in either phase. A run that finished but left pages failed reports as never built ("no run has reconciled the whole wiki"), making the Special page agree with the CLI's non-zero exit.

Surfaces, all behind the new neowiki-admin right (sysop by default; deliberately in no OAuth grant): GET /neowiki/v0/graph-stores — per store: projection, in sync / stale / never built, active and last successful run; never endpoint URLs or tokens — plus POST/DELETE /neowiki/v0/graph-stores/{name}/rebuild (202/409/404), for farm tooling and orchestrators; and Special:GraphStores for humans — server-rendered, CSRF-protected forms, progress on reload. Staleness is derived, not stored: an ontology-projection store is stale when its Mapping: page was edited or deleted after the last successful run started (start-anchored on purpose: pages projected before a mid-run edit used the old rules). Native and Neo4j stores can only be in sync or never built.

Auto-rebuild ($wgNeoWikiAutoRebuildOnMappingChange, default off) supersedes only Auto-triggered runs: a Mapping save or delete cancels-and-restarts the affected stores' auto runs, deferred past the edit's transaction; an admin's CLI/API/UI run is never stomped, and rapid edits coalesce because each restart replaces the previous auto run. Enabling it hands everyone with neowiki-mapping-edit (all logged-in users by default) the power to start a full reprojection — the same cost as a CLI rebuild of that store.

Accepted trade-offs and deferrals, for review:

  1. Progress needs a job runner. A wiki with no runner leaves a queued run parked; a runner without the extension leaves it active until cancelled — cancellation is also the stuck-run recovery, and the docs say so.
  2. A retried job can briefly fork a run into two chains. Bounded: progress writes are absolute (duplicate batches are idempotent), each execution pushes at most one continuation, and both chains die at the next terminal check; best-effort job dedup collapses the common case. A generation column is the clean fix if this ever matters in practice.
  3. The nwrr_phase schema patch exists only because Scope graph rebuilds per store, batched and resumable, with run records #1233 is unmerged — folding the column into Scope graph rebuilds per store, batched and resumable, with run records #1233's table definition at merge time deletes the patch machinery.

AI-authored — Claude Code, Fable 5 (max); PR 2 of the two-PR plan agreed with @JeroenDeDauw, spec settled in-session; diff not yet human-reviewed; independent review pass with live DB experiments applied (three blocking findings fixed with regression tests, several mutation-verified), CI green on MW 1.43–master.

Production notes

Design and supervision by Fable 5 (max); implementation, review fixes, and text pass by Opus 5 (max) subagents (commit trailers on the implementation commits name the harness-configured Fable string; the code was written by Opus). The review's live experiments: two-session InnoDB snapshot proofs for the cancellation race, and wikitext injection on Special:GraphStores reaching Scribunto from a GET — both now fixed and pinned.

JeroenDeDauw and others added 27 commits August 2, 2026 02:21
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
- `--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>
- 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>
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>
@JeroenDeDauw
JeroenDeDauw marked this pull request as ready for review August 2, 2026 20:48
@JeroenDeDauw
JeroenDeDauw merged commit 1f21e87 into graph-rebuild-scoped Aug 2, 2026
14 checks passed
@JeroenDeDauw
JeroenDeDauw deleted the graph-rebuild-background branch August 2, 2026 23:14
alistair3149 pushed a commit that referenced this pull request Aug 4, 2026
…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>
alistair3149 pushed a commit that referenced this pull request Aug 5, 2026
…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>
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