Skip to content

Commit a365910

Browse files
JeroenDeDauwclaude
andauthored
Create Neo4j uniqueness constraints during graph rebuild (#1083)
* Create Neo4j uniqueness constraints during graph rebuild Fixes #874 Neo4jConstraintUpdater::createDefaultConstraints() could create the Page (wiki_id, id) and Subject.id uniqueness constraints but was only ever called from tests, so a real install ran Neo4j without them: duplicate-id nodes were possible and id lookups were unindexed. Wire constraint creation into RebuildGraphDatabases.php -- the production path that (re)builds the graph from the MediaWiki source of truth -- via a new NeoWikiExtension::createGraphDatabaseConstraints() that no-ops when no Neo4j backend is configured (e.g. a SPARQL-only install) and is idempotent (CREATE CONSTRAINT ... IF NOT EXISTS). Creating the constraints before re-projecting means a rebuilt graph always carries them, and an unchanged re-save still succeeds because the projection MERGEs nodes by id. The graph-model.md claim that the constraints are "not created automatically yet" is corrected. The rebuild is the simplest reliable point: it is already the canonical way to establish the graph, it runs with every backend resolved, and its idempotency makes repeated runs safe. A LoadExtensionSchemaUpdates hook was considered but rejected -- NeoWiki registers no SQL schema updater today, and coupling external-Neo4j reachability to update.php is fragile. Relation (edge) ID uniqueness (#351) is not implemented here. Neo4j relationship uniqueness constraints are per relationship type (on this stack, Neo4j 2026.05.0 Enterprise, a type-less "FOR ()-[r]->() REQUIRE r.id IS UNIQUE" is a syntax error), and Relations use an open, user-defined set of relationship types, so no single constraint can enforce global Relation-id uniqueness. A hard pre-save rejection would need a graph read in the deliberately graph-free validation path, which is out of scope here; analysis is posted on issue #351. Tests: - RebuildGraphDatabasesTest::testRebuildCreatesGraphUniquenessConstraints -- after the rebuild runs, SHOW CONSTRAINTS reports both node constraints; a subject page is seeded so the rebuild re-projects real data under the freshly-created constraints. - RebuildGraphDatabasesTest::testEnsuringConstraintsIsSkippedWhenNeo4jIsNotConfigured -- the guard keeps the rebuild safe on a wiki with no Neo4j backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Correct graph-model.md constraint-creation claim The Constraints section implied the uniqueness constraints are always present and that the rebuild is the path that builds the graph. They are in fact created only by running RebuildGraphDatabases.php; the incremental projection on page edits does not create them, so an existing graph gains them only after a rebuild. State this honestly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Trim RebuildGraphDatabasesTest to the constraint-wiring fact The test re-asserted Neo4jConstraintUpdaterTest's full constraint shape byte-for-byte, so a constraint-shape change would break two tests. Assert only that the rebuild created both named constraints (the name embeds the properties); their shape and enforcement stay owned by Neo4jConstraintUpdaterTest. Also soften the comment (a single subject cannot collide, so the test does not prove non-violation) and add the missing @Covers for the extension method the rebuild exercises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route graph store initialization through the GraphDatabasePlugin system Constraint creation on rebuild ran through a Neo4j-specific NeoWikiExtension::createGraphDatabaseConstraints() facade method that null-checked the Neo4j plugin and built a Neo4jConstraintUpdater itself. That special-cased one backend on the facade, outside the GraphDatabasePlugin seam every other rebuild step already goes through. Add initialize() to the GraphDatabasePlugin interface: a store prepares its backing store for projection there. Neo4jProjectionStore creates its uniqueness constraints (via a Neo4jConstraintUpdater the Neo4j composition root injects from the write engine it already owns); SparqlProjectionStore is a no-op (no store-level structures to create). RebuildGraphDatabases now calls getGraphDatabasePlugin()->initialize() -- the propagating composite over every configured backend -- before re-projecting, so each backend initializes through the same seam it uses for savePage/deletePage, and a no-backend install is just an empty fan-out. initialize() propagates on the rebuild path like savePage/deletePage; FailureIsolatingGraphDatabasePlugin (hook path only, which never initializes) passes it straight through. The facade method and its Neo4jConstraintUpdater import are removed. Behavior is unchanged: the rebuild creates the same two Neo4j constraints, idempotently, and the incremental per-edit path still does not. extending.md documents the new interface method for extension authors; graph-model.md is unaffected. Tests: - testRebuildCreatesGraphUniquenessConstraints stays as the end-to-end proof the rebuild creates both Neo4j constraints, now through the plugin system (verified failing when Neo4jProjectionStore::initialize is stubbed to a no-op). - testEnsuringConstraintsIsSkippedWhenNeo4jIsNotConfigured becomes testInitializingGraphDatabasesDoesNotThrowWithoutABackend: with no backend configured, the rebuild's initialization step (the composite resolved via the facade) is a no-op. - CompositeGraphDatabasePlugin and FailureIsolatingGraphDatabasePlugin gain initialize() fan-out / delegation and failure-propagation coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 58650e0 commit a365910

16 files changed

Lines changed: 173 additions & 5 deletions

docs/api/graph-model.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,16 @@ When a Subject is deleted but still has incoming relations from other Subjects,
107107

108108
## Constraints
109109

110-
Two uniqueness constraints are intended for the graph — the `(wiki_id, id)` pair is unique on `:Page` nodes
111-
([ADR 22](../adr/022-multi-wiki-node-identity.md)), and `Subject.id` is unique. These are **not** created
112-
automatically yet ([#874](https://github.com/ProfessionalWiki/NeoWiki/issues/874)).
110+
Two node uniqueness constraints are defined for the graph: the `(wiki_id, id)` pair is unique on `:Page` nodes
111+
([ADR 22](../adr/022-multi-wiki-node-identity.md)), and `Subject.id` is unique. They are created by running the
112+
`RebuildGraphDatabases.php` maintenance script, and creation is idempotent (`CREATE CONSTRAINT ... IF NOT EXISTS`),
113+
so re-running it is safe. The incremental projection that runs on each page edit does not create them, so an
114+
existing graph gains them only after the rebuild has been run.
115+
116+
Relation (edge) `id` values are not constrained at the graph level: Neo4j relationship uniqueness constraints
117+
are per relationship type, and Relations use an open, user-defined set of relationship types, so no single
118+
constraint can enforce global Relation-`id` uniqueness (see
119+
[#351](https://github.com/ProfessionalWiki/NeoWiki/issues/351)).
113120

114121
## Related Documentation
115122

docs/extending/extending.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ NeoWiki currently supports Neo4j only, but the graph projection is an extension
151151
```php
152152
class MyGraphDatabasePlugin implements GraphDatabasePlugin {
153153

154+
public function initialize(): void {
155+
// Create any store-level structures your backend needs (e.g. constraints or indexes).
156+
}
157+
154158
public function savePage( Page $page ): void {
155159
// Project the page and its subjects into your store.
156160
}
@@ -168,6 +172,10 @@ Register with `NeoWikiRegistrar::addGraphDatabasePlugin()`. Example:
168172
`savePage` hands you the page with all of its Subjects and runs for every revision, so subject edits, undeletions
169173
and page moves all reach you as a save. `deletePage` gets only the page id.
170174

175+
`initialize` runs once at the start of a `RebuildGraphDatabases` run, before any page is projected — create the
176+
store-level structures a fresh store needs there (this is how NeoWiki's own Neo4j backend creates its uniqueness
177+
constraints). Make it idempotent, since every rebuild calls it; it never runs on an individual edit.
178+
171179
**Signal failure by throwing.** On an edit, delete or undelete, NeoWiki logs the failure and lets the user's
172180
operation commit, so a backend being down never blocks the wiki or starves the other backends — your projection is
173181
simply out of sync until someone runs `RebuildGraphDatabases`. During that rebuild, failures propagate instead, so

maintenance/RebuildGraphDatabases.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public function __construct() {
3333
}
3434

3535
public function execute(): void {
36+
$this->initializeGraphDatabases();
37+
3638
$pageIds = $this->getSubjectPageIds();
3739

3840
$this->outputChanneled( 'Rebuilding graph databases for ' . count( $pageIds ) . ' subject pages...' );
@@ -52,6 +54,17 @@ public function execute(): void {
5254
$this->removeDeletedPages();
5355
}
5456

57+
/**
58+
* Initializing the backends before re-projecting guarantees a rebuilt graph carries any store-level
59+
* structures they need (e.g. uniqueness constraints). The rebuild is the production path that
60+
* (re)establishes the graph from the MediaWiki source of truth, so it is the natural, idempotent
61+
* point to ensure those structures exist (#874).
62+
*/
63+
private function initializeGraphDatabases(): void {
64+
$this->outputChanneled( 'Initializing graph databases...' );
65+
NeoWikiExtension::getInstance()->getGraphDatabasePlugin()->initialize();
66+
}
67+
5568
/**
5669
* Re-saving the pages that still exist cannot undo a projection delete that failed, so a page deleted
5770
* while a backend was unreachable would otherwise stay in the graph for good, its Subjects still

src/Domain/GraphDatabase/CompositeGraphDatabasePlugin.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public function __construct( GraphDatabasePlugin ...$plugins ) {
2727
$this->plugins = $plugins;
2828
}
2929

30+
public function initialize(): void {
31+
foreach ( $this->plugins as $plugin ) {
32+
$plugin->initialize();
33+
}
34+
}
35+
3036
public function savePage( Page $page ): void {
3137
foreach ( $this->plugins as $plugin ) {
3238
$plugin->savePage( $page );

src/Domain/GraphDatabase/FailureIsolatingGraphDatabasePlugin.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ public function __construct(
4242
) {
4343
}
4444

45+
/**
46+
* Passed straight through without isolation: this decorator wraps plugins only on the hook-facing
47+
* write path, and that path never initializes — initialize() runs solely on the propagating
48+
* rebuild path (see GraphDatabasePlugin), where a failure must surface rather than be swallowed.
49+
*/
50+
public function initialize(): void {
51+
$this->plugin->initialize();
52+
}
53+
4554
public function savePage( Page $page ): void {
4655
try {
4756
$this->plugin->savePage( $page );

src/Domain/GraphDatabase/GraphDatabasePlugin.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,20 @@
1717
* each plugin (via FailureIsolatingGraphDatabasePlugin), so a throw does not abort the triggering
1818
* user operation and one failing backend does not starve the others.
1919
* - On maintenance rebuilds (RebuildGraphDatabases), the wiring propagates failures so they are
20-
* reported truthfully per page instead of being silently swallowed.
20+
* reported truthfully per page instead of being silently swallowed. The rebuild is also the only
21+
* path that calls initialize(), so an initialize() failure propagates like any other rebuild
22+
* failure; the hook path never initializes.
2123
*/
2224
interface GraphDatabasePlugin {
2325

26+
/**
27+
* Prepares the backing store for projections by creating the store-level structures the backend
28+
* needs — such as uniqueness constraints — where it supports them. Idempotent, so the rebuild can
29+
* run it every time. The RebuildGraphDatabases maintenance path calls this before bulk re-projection
30+
* so a rebuilt graph carries those structures; the incremental per-edit path does not.
31+
*/
32+
public function initialize(): void;
33+
2434
public function savePage( Page $page ): void;
2535

2636
public function deletePage( PageId $pageId ): void;

src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jReadQueryEngine;
1616
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\EntryPoints\ParserFunction\CypherRawParserFunction;
1717
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jClientReadQueryEngine;
18+
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jConstraintUpdater;
1819
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jProjectionStore;
1920
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jResultNormalizer;
2021
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jSubjectUpdaterFactory;
@@ -42,6 +43,7 @@ public function __construct(
4243
LoggerInterface $logger,
4344
string $wikiId,
4445
) {
46+
$this->writeQueryEngine = new Neo4jWriteQueryEngine( $client );
4547
$this->projectionStore = new Neo4jProjectionStore(
4648
client: $client,
4749
subjectUpdaterFactory: new Neo4jSubjectUpdaterFactory(
@@ -50,10 +52,11 @@ public function __construct(
5052
logger: $logger,
5153
wikiId: $wikiId,
5254
),
55+
// Reuse the write engine the plugin already owns so initialize() creates constraints through it.
56+
constraintUpdater: new Neo4jConstraintUpdater( $this->writeQueryEngine ),
5357
wikiId: $wikiId,
5458
);
5559
$this->readQueryEngine = new Neo4jClientReadQueryEngine( $readOnlyClient );
56-
$this->writeQueryEngine = new Neo4jWriteQueryEngine( $client );
5760
$this->readOnlyClient = $readOnlyClient;
5861
}
5962

src/GraphDatabasePlugins/Neo4j/Persistence/Neo4jProjectionStore.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@
2121
public function __construct(
2222
private ClientInterface $client,
2323
private Neo4jSubjectUpdaterFactory $subjectUpdaterFactory,
24+
private Neo4jConstraintUpdater $constraintUpdater,
2425
private string $wikiId,
2526
) {
2627
}
2728

29+
public function initialize(): void {
30+
$this->constraintUpdater->createDefaultConstraints();
31+
}
32+
2833
public function savePage( Page $page ): void {
2934
$this->client->writeTransaction( function ( TransactionInterface $transaction ) use ( $page ): void {
3035
$properties = $page->getProperties()->asArray();

src/GraphDatabasePlugins/Sparql/Persistence/SparqlProjectionStore.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public function __construct(
4545
) {
4646
}
4747

48+
public function initialize(): void {
49+
// Nothing to prepare: a SPARQL graph store has no store-level structures (e.g. uniqueness
50+
// constraints) to create up front, unlike Neo4j.
51+
}
52+
4853
public function savePage( Page $page ): void {
4954
$projector = $this->resolveProjector();
5055
$graph = $this->namespaces->graph( $this->projectionName, $page->getId() );

tests/RedHerb/src/RedHerbGraphDatabasePlugin.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class RedHerbGraphDatabasePlugin implements GraphDatabasePlugin {
2121
/** @var PageId[] */
2222
public array $deletedPageIds = [];
2323

24+
public function initialize(): void {
25+
// A real backend would create its store-level structures (e.g. uniqueness constraints) here.
26+
// This example store needs none, so initialization is a no-op.
27+
}
28+
2429
public function savePage( Page $page ): void {
2530
$this->savedPages[] = $page;
2631
}

0 commit comments

Comments
 (0)