Skip to content

Commit 56ae2b7

Browse files
Merge pull request #18 from OpenElementsLabs/feat/009-content-bootstrap-step
Content bootstrap step — startup reindex via SearchIndexBootstrapStep
2 parents 9291705 + a0629be commit 56ae2b7

5 files changed

Lines changed: 246 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
5353
│ ├── ContentIndexer.java # orchestrates discover→diff→fetch→upsert/delete
5454
│ ├── IndexReport.java # per-pass counters
5555
│ ├── ContentIndexStore.java # index read/write seam (StoredDocument)
56-
│ └── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient
56+
│ ├── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient
57+
│ └── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex
5758
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
5859
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
5960
└── docs/
@@ -105,7 +106,10 @@ items via the strategy → batch-upsert, and delete documents that 404 or vanish
105106
`MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to
106107
read state, `addDocuments`+`waitForTask` to upsert, `deleteDocument`), since the library's
107108
`BatchWriter` is package-private. The indexer is the reusable engine for the bootstrap step (009) and
108-
refresh scheduler (010).
109+
refresh scheduler (010). `ContentBootstrapStep` implements the library's `SearchIndexBootstrapStep`:
110+
at startup the library's `MeilisearchBootstrapRunner` discovers it, consumes its lazy `documents()`
111+
stream (over all enabled sources, via `ContentIndexer.streamAllDocuments`) in batches into Meilisearch,
112+
and toggles `SearchReadinessState`.
109113

110114
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
111115
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Implementation Steps: Content Bootstrap Step
2+
3+
## Step 1: `ContentBootstrapStep`
4+
5+
- [x] `@Component` (gated on `meilisearch.enabled`) implementing `SearchIndexBootstrapStep`
6+
- [x] `indexUid()``resolveIndex("content")`
7+
- [x] `documents()` → lazy `flatMap` over enabled sources via `ContentIndexer.streamAllDocuments`
8+
- [x] Per-source fault isolation: a source that fails to stream is logged and skipped, others still contribute
9+
10+
**Acceptance criteria:**
11+
- [x] Project builds; behaviors verified by tests
12+
- [x] Discovered by the library `MeilisearchBootstrapRunner` (auto-injected `List<SearchIndexBootstrapStep>`)
13+
14+
**Related behaviors:** indexUid targets content index; all enabled sources contribute; stream is lazy; failing source does not block others
15+
16+
---
17+
18+
## Step 2: Tests
19+
20+
- [x] `ContentBootstrapStepTest` (real `ContentIndexer` + stub strategy): indexUid, enabled-only sources, laziness, failing-source isolation
21+
22+
**Acceptance criteria:**
23+
- [x] All tests pass (`mvn test`); build green
24+
25+
---
26+
27+
## Behavior Coverage
28+
29+
| Scenario | Layer | Covered in Step |
30+
|----------|-------|-----------------|
31+
| Step is discovered by the runner | Backend (integration) | Delegated to the library `MeilisearchBootstrapRunner` (auto-injects `SearchIndexBootstrapStep` beans); `@Component` registration verified by the full app-context build. Live end-to-end run needs a running Meilisearch. |
32+
| indexUid targets the content index | Backend | Step 2 (`indexUidTargetsContentIndex`) |
33+
| All enabled sources contribute documents | Backend | Step 2 (`onlyEnabledSourcesContribute`) |
34+
| Stream is lazy | Backend | Step 2 (`streamIsLazy`) |
35+
| Readiness flips after bootstrap | Backend (integration) | Owned by the library runner (`markBootstrappingStarted/Finished` on `SearchReadinessState`); needs a live Meilisearch. |
36+
| Search short-circuits during bootstrap | Backend | Deferred to spec 011 (search layer observes `SearchReadinessState.isBootstrapping()`). |
37+
| A failing source does not block others | Backend | Step 2 (`failingSourceDoesNotBlockOthers`) |
38+
39+
All scenarios are backend; there is no frontend in this spec. The readiness/runner scenarios are the library's responsibility and require a live Meilisearch, consistent with the project's established live-search test boundary.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
1414
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction | #11 | done |
1515
| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` | #13 | done |
1616
| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | #15 | done |
17-
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | | open |
17+
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done |
1818
| 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler``@Scheduled` incremental re-crawl || open |
1919
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService``multiSearch` + highlighting facade || open |
2020
| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools || open |
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.openelements.content;
2+
3+
import com.openelements.spring.base.services.search.MeilisearchProperties;
4+
import com.openelements.spring.base.services.search.SearchIndexBootstrapStep;
5+
import java.util.Map;
6+
import java.util.stream.Stream;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
10+
import org.springframework.stereotype.Component;
11+
12+
/**
13+
* Populates the content index at application startup by implementing the library's
14+
* {@link SearchIndexBootstrapStep}.
15+
*
16+
* <p>The step is intentionally thin: it declares the target index and provides a lazy document
17+
* stream over all enabled sources (via {@link ContentIndexer#streamAllDocuments(ContentSource)}). The
18+
* library's {@code MeilisearchBootstrapRunner} discovers the step, consumes the stream in batches,
19+
* and toggles {@code SearchReadinessState} around the run. Reusing {@link ContentIndexer} keeps
20+
* bootstrap and the refresh scheduler (spec 010) from diverging.
21+
*
22+
* <p>Only created when the Meilisearch stack is enabled.
23+
*/
24+
@Component
25+
@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true")
26+
public class ContentBootstrapStep implements SearchIndexBootstrapStep {
27+
28+
private static final Logger log = LoggerFactory.getLogger(ContentBootstrapStep.class);
29+
30+
private final ContentSourceProperties properties;
31+
private final ContentIndexer indexer;
32+
private final MeilisearchProperties meilisearchProperties;
33+
34+
public ContentBootstrapStep(ContentSourceProperties properties, ContentIndexer indexer,
35+
MeilisearchProperties meilisearchProperties) {
36+
this.properties = properties;
37+
this.indexer = indexer;
38+
this.meilisearchProperties = meilisearchProperties;
39+
}
40+
41+
@Override
42+
public String indexUid() {
43+
return meilisearchProperties.resolveIndex("content");
44+
}
45+
46+
@Override
47+
public Stream<Map<String, Object>> documents() {
48+
return properties.sources().stream()
49+
.filter(ContentSource::enabled)
50+
.flatMap(this::streamSourceSafely);
51+
}
52+
53+
/**
54+
* Streams one source's documents, isolating a source-level failure so the remaining sources still
55+
* contribute (per-item fetch/extract errors are already contained as {@code SKIP} by the strategy).
56+
*/
57+
private Stream<Map<String, Object>> streamSourceSafely(ContentSource source) {
58+
try {
59+
return indexer.streamAllDocuments(source);
60+
} catch (Exception e) {
61+
log.warn("Skipping source {} during bootstrap: {}", source.id(), e.toString());
62+
return Stream.empty();
63+
}
64+
}
65+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.openelements.content;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.openelements.spring.base.services.search.MeilisearchProperties;
6+
import java.time.Duration;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.concurrent.atomic.AtomicInteger;
11+
import java.util.stream.IntStream;
12+
import java.util.stream.Stream;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Test;
15+
import org.springframework.util.unit.DataSize;
16+
17+
/**
18+
* Unit tests for {@link ContentBootstrapStep} using a real {@link ContentIndexer} driven by a
19+
* test-double strategy, so document streaming is exercised without a running Meilisearch.
20+
*
21+
* <p>Readiness toggling and batching are the library {@code MeilisearchBootstrapRunner}'s
22+
* responsibility and require a live instance; they are not reproduced here.
23+
*/
24+
@DisplayName("Content bootstrap step")
25+
class ContentBootstrapStepTest {
26+
27+
private final MeilisearchProperties meilisearch =
28+
new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10));
29+
private final StubStrategy strategy = new StubStrategy();
30+
private final ContentIndexer indexer =
31+
new ContentIndexer(new SourceStrategyRegistry(List.of(strategy)), new NoOpStore());
32+
33+
private static ContentSource source(String id, boolean enabled) {
34+
return new ContentSource(
35+
id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled);
36+
}
37+
38+
private static ContentSourceProperties properties(ContentSource... sources) {
39+
return new ContentSourceProperties(
40+
true, null, "UA", 2.0, Duration.ofSeconds(10), DataSize.ofMegabytes(5), List.of(sources));
41+
}
42+
43+
private ContentBootstrapStep step(ContentSourceProperties properties) {
44+
return new ContentBootstrapStep(properties, indexer, meilisearch);
45+
}
46+
47+
@Test
48+
@DisplayName("indexUid targets the prefixed content index")
49+
void indexUidTargetsContentIndex() {
50+
assertThat(step(properties()).indexUid()).isEqualTo("content_content");
51+
}
52+
53+
@Test
54+
@DisplayName("only enabled sources contribute documents")
55+
void onlyEnabledSourcesContribute() {
56+
ContentBootstrapStep step = step(properties(source("a", true), source("b", true), source("c", false)));
57+
58+
List<String> sources = step.documents().map(doc -> (String) doc.get("source")).toList();
59+
60+
assertThat(sources).containsExactlyInAnyOrder("a", "b");
61+
}
62+
63+
@Test
64+
@DisplayName("the document stream is lazy — nothing is fetched until it is consumed")
65+
void streamIsLazy() {
66+
strategy.itemsPerSource = 3;
67+
ContentBootstrapStep step = step(properties(source("a", true)));
68+
69+
Stream<Map<String, Object>> documents = step.documents();
70+
assertThat(strategy.fetchCount.get()).isZero(); // building the stream fetches nothing
71+
72+
List<Map<String, Object>> materialized = documents.toList();
73+
assertThat(materialized).hasSize(3);
74+
assertThat(strategy.fetchCount.get()).isEqualTo(3);
75+
}
76+
77+
@Test
78+
@DisplayName("a source that fails while streaming does not block the others")
79+
void failingSourceDoesNotBlockOthers() {
80+
strategy.throwingSourceIds.add("bad");
81+
ContentBootstrapStep step = step(properties(source("a", true), source("bad", true)));
82+
83+
List<String> sources = step.documents().map(doc -> (String) doc.get("source")).toList();
84+
85+
assertThat(sources).containsExactly("a");
86+
}
87+
88+
/** Test-double strategy: one INDEX document per discovered item; can throw for chosen sources. */
89+
private static final class StubStrategy implements ContentSourceStrategy {
90+
private final List<String> throwingSourceIds = new ArrayList<>();
91+
private final AtomicInteger fetchCount = new AtomicInteger();
92+
private int itemsPerSource = 1;
93+
94+
@Override
95+
public SourceType type() {
96+
return SourceType.WEBSITE;
97+
}
98+
99+
@Override
100+
public List<DiscoveredItem> discover(ContentSource source) {
101+
if (throwingSourceIds.contains(source.id())) {
102+
throw new IllegalStateException("boom " + source.id());
103+
}
104+
return IntStream.range(0, itemsPerSource)
105+
.mapToObj(i -> new DiscoveredItem("https://ex.com/" + source.id() + "/" + i, "L"))
106+
.toList();
107+
}
108+
109+
@Override
110+
public FetchOutcome fetch(ContentSource source, DiscoveredItem item) {
111+
fetchCount.incrementAndGet();
112+
ContentDocument document = new ContentDocument(
113+
ContentDocument.id(source.id(), item.url()), source.id(), "en", item.url(),
114+
"T", "E", "body", "a", List.of(), "2026-01-01", item.lastmod(), null);
115+
return FetchOutcome.index(document);
116+
}
117+
}
118+
119+
/** streamAllDocuments never touches the store, so a no-op suffices. */
120+
private static final class NoOpStore implements ContentIndexStore {
121+
@Override
122+
public Map<String, StoredDocument> loadState(String source) {
123+
return Map.of();
124+
}
125+
126+
@Override
127+
public int upsert(List<Map<String, Object>> documents) {
128+
return documents.size();
129+
}
130+
131+
@Override
132+
public void delete(String id) {
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)