Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci-jdk11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ jobs:
java-version: '11'
distribution: 'temurin'

- name: Verify Docker environment
timeout-minutes: 2
run: |
python3 tools/ci/test_check_docker.py
bash tools/ci/check-docker.sh

- name: Setup Protoc
uses: arduino/setup-protoc@v2
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ jobs:
distribution: 'temurin'
cache: maven

- name: Verify Docker environment
timeout-minutes: 2
run: |
python3 tools/ci/test_check_docker.py
bash tools/ci/check-docker.sh

- name: Setup Protoc
uses: arduino/setup-protoc@v2
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,24 @@
import org.apache.geaflow.ai.graph.io.VertexSchema;
import org.apache.geaflow.ai.index.vector.KeywordVector;
import org.apache.geaflow.ai.operator.GraphSearchStore;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.store.AlreadyClosedException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
* Lifecycle and retrieval-semantics tests for the keyword index, pinning the behavior issue #856
* calls out as fragile: the store is rebuilt during every query, retrieval tolerates common-token
* noise, and deleted entities are filtered at query time rather than removed from the index.
* Lifecycle and retrieval-semantics tests for the keyword index: committed writes remain
* searchable until the store is closed, retrieval tolerates common-token noise, and deleted
* entities are filtered at query time rather than removed from the index.
*
* <p>The usage contract these tests document, in one place:
*
* <ul>
* <li><b>Build, close, search, discard.</b> A query session indexes what it wants searchable,
* closes the store (which commits the segments) and only then searches. Searching before
* close silently returns nothing — the {@code IndexNotFoundException} of the uncommitted
* directory is swallowed. Indexing again after close fails outright, because
* {@code SearchStore} reuses one {@code IndexWriterConfig} across writers. The store is
* therefore single-shot, and {@code SessionOperator} exercises exactly this order.</li>
* <li><b>Build, finish writing, search, close.</b> A query session indexes its entities and calls
* {@link GraphSearchStore#finishWriting()} to commit the segments while retaining the
* directory and analyzer for reads. Searching before the first commit returns no results.
* Closing the store releases its resources and rejects subsequent reads and writes.</li>
* <li><b>Stop words are searchable.</b> The analyzer is built with an empty stop-word set
* (see {@link KeywordTokenizationGoldenTest}), so {@code the} matches documents
* containing {@code the}.</li>
Expand All @@ -76,17 +77,26 @@ public class KeywordIndexLifecycleGoldenTest {
private static final String LABEL = "chunk";
private static final String EDGE_LABEL = "rel";

/** The production order: index, close, search. Everything visible before close is found. */
private final List<GraphSearchStore> stores = new ArrayList<>();

@AfterEach
public void closeStores() {
for (GraphSearchStore store : stores) {
store.close();
}
}

/** Finished writes remain searchable until the store is closed. */
@Test
public void indexedEntitiesAreSearchableAfterClose() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
public void indexedEntitiesAreSearchableAfterFinishWriting() {
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca", "grazes");
store.close();
store.finishWriting();

List<String> found = searchIds(store, accessor, "alpaca");
Assertions.assertEquals(Collections.singletonList("v1"), found,
"a term indexed before close must be found after close");
"a term indexed before finishWriting must be found after finishWriting");
Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "ALPACA"),
"matching is case-insensitive on both sides of the index");
Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "grazes"),
Expand All @@ -96,49 +106,58 @@ public void indexedEntitiesAreSearchableAfterClose() {
}

/**
* Searching before close returns an empty list instead of failing. This is why the
* close-before-search order above is a contract rather than a convention: getting it wrong
* fails silently.
* The first commit makes buffered documents visible; an earlier empty search must not
* prevent the same store from finding those documents after finishWriting.
*/
@Test
public void searchBeforeCloseReturnsEmptyResults() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
public void searchBeforeFinishWritingReturnsEmptyResults() {
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
Assertions.assertEquals(Collections.emptyList(), searchIds(store, accessor, "alpaca"),
"search before close must return empty, not the committed documents (there are none)");
"an uncommitted index has no searchable documents");
store.finishWriting();
Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "alpaca"));
}

/**
* Indexing after close throws: {@code SearchStore} hands the same {@code IndexWriterConfig}
* to a second {@code IndexWriter} and Lucene rejects that. Combined with the test above this
* fixes the lifecycle as index-then-close-then-search with no reopen — the resident index of
* PR #825 is the proposed way out of this single-shot shape.
*/
/** Closing is terminal and rejects subsequent indexing at the store boundary. */
@Test
public void indexingAfterCloseFails() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
store.close();
RuntimeException failure = Assertions.assertThrows(RuntimeException.class,
() -> indexVertex(store, "v2", "lama"),
"indexing into a closed store must fail loudly rather than lose the document");
Assertions.assertTrue(failure.getCause() instanceof IllegalStateException,
"the underlying refusal is Lucene's IndexWriterConfig sharing guard, got: "
+ failure.getCause());
IllegalStateException failure = Assertions.assertThrows(IllegalStateException.class,
() -> indexVertex(store, "v2", "lama"));
Assertions.assertEquals("graph search store is closed", failure.getMessage());
}

@Test
public void closedStoreRejectsReadsAndFinishingAndReleasesDirectory() {
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
store.finishWriting();
store.finishWriting();
Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "alpaca"));
store.close();
store.close();
Assertions.assertThrows(IllegalStateException.class, () -> store.search("alpaca", accessor));
Assertions.assertThrows(IllegalStateException.class, store::finishWriting);
Assertions.assertThrows(AlreadyClosedException.class, () -> store.getDirectory().listAll());
}

/** An empty query reaches the query parser and surfaces as the store's read failure. */
@Test
public void emptyQueryFailsTheSearchStore() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
store.close();
store.finishWriting();
RuntimeException failure = Assertions.assertThrows(RuntimeException.class,
() -> store.search("", accessor));
Assertions.assertEquals("Cannot read search store", failure.getMessage());
Assertions.assertTrue(failure.getCause() instanceof ParseException,
"an empty query must reach the parser while the store is still open");
}

/**
Expand All @@ -148,11 +167,11 @@ public void emptyQueryFailsTheSearchStore() {
*/
@Test
public void multiWordQueriesMatchAnyTerm() {
LocalMemoryGraphAccessor accessor = graph("v1", "v2");
GraphSearchStore store = new GraphSearchStore();
final LocalMemoryGraphAccessor accessor = graph("v1", "v2");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
indexVertex(store, "v2", "grazes");
store.close();
store.finishWriting();

Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "alpaca.lama"),
"OR semantics: only one of the two terms needs to be present");
Expand All @@ -161,10 +180,10 @@ public void multiWordQueriesMatchAnyTerm() {
/** Stop words are ordinary searchable terms (the analyzer has no stop-word list). */
@Test
public void stopWordsAreSearchableTerms() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "the", "fox");
store.close();
store.finishWriting();
Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "the"),
"with an empty stop-word set, 'the' indexes and matches like any other word");
}
Expand All @@ -179,11 +198,11 @@ public void stopWordsAreSearchableTerms() {
*/
@Test
public void unicodeKeywordsMatchByCharacter() {
LocalMemoryGraphAccessor accessor = graph("v1", "v2");
GraphSearchStore store = new GraphSearchStore();
final LocalMemoryGraphAccessor accessor = graph("v1", "v2");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "学习", "效率");
indexVertex(store, "v2", "习题", "效率");
store.close();
store.finishWriting();

Assertions.assertEquals(Arrays.asList("v1", "v2"), searchIds(store, accessor, "效率"),
"an exact two-character keyword matches the documents carrying it");
Expand Down Expand Up @@ -211,15 +230,15 @@ public void commonWrapperTokensDoNotCrowdOutExactAnchoring() {
for (int i = 0; i < corpusSize; i++) {
ids[i] = "v" + i;
}
LocalMemoryGraphAccessor accessor = graph(ids);
final LocalMemoryGraphAccessor accessor = graph(ids);

GraphSearchStore store = new GraphSearchStore();
GraphSearchStore store = createStore();
for (int i = 0; i < corpusSize; i++) {
// One shared filler keyword plus a unique one: every document overlaps its neighbors,
// none of them is distinguishable through the filler alone.
indexVertex(store, ids[i], "shared", "unique" + i);
}
store.close();
store.finishWriting();

Assertions.assertEquals(Collections.singletonList("v17"),
searchIds(store, accessor, "unique17"),
Expand All @@ -234,21 +253,20 @@ public void commonWrapperTokensDoNotCrowdOutExactAnchoring() {
}

/**
* Incremental addition is accumulation within one open session: documents added in several
* batches are all committed by the single close and all searchable. There is no incremental
* visibility into an already-searched index (see {@link #indexingAfterCloseFails()}).
* Documents added in several batches within one write session are committed together by
* finishWriting and remain searchable until close.
*/
@Test
public void incrementalAddsAccumulateUntilClose() {
LocalMemoryGraphAccessor accessor = graph("v1", "v2", "v3");
GraphSearchStore store = new GraphSearchStore();
public void incrementalAddsAccumulateUntilFinishWriting() {
final LocalMemoryGraphAccessor accessor = graph("v1", "v2", "v3");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
indexVertex(store, "v2", "lama");
indexVertex(store, "v3", "zebra");
store.close();
store.finishWriting();

Assertions.assertEquals(Arrays.asList("v1", "v2", "v3"), searchIds(store, accessor, "alpaca lama zebra"),
"every batch added before close is committed and searchable");
"every batch added before finishWriting is committed and searchable");
}

/**
Expand All @@ -258,8 +276,8 @@ public void incrementalAddsAccumulateUntilClose() {
*/
@Test
public void deletedEntitiesAreFilteredAtQueryTime() {
LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = new GraphSearchStore();
final LocalMemoryGraphAccessor accessor = graph("v1");
GraphSearchStore store = createStore();
indexVertex(store, "v1", "alpaca");
// A vertex indexed with a registered label but an id the graph does not hold — the shape
// a deleted entity leaves behind.
Expand All @@ -268,7 +286,7 @@ public void deletedEntitiesAreFilteredAtQueryTime() {
store.indexVertex(new GraphVertex(
new Vertex("unknown", "v9", Collections.singletonList("x"))),
Collections.singletonList(new KeywordVector("orphanword")));
store.close();
store.finishWriting();

Assertions.assertEquals(Collections.singletonList("v1"), searchIds(store, accessor, "alpaca"));
Assertions.assertTrue(searchIds(store, accessor, "ghostword").isEmpty(),
Expand All @@ -279,6 +297,12 @@ public void deletedEntitiesAreFilteredAtQueryTime() {

// ------------------------------------------------------------------ helpers

private GraphSearchStore createStore() {
GraphSearchStore store = new GraphSearchStore();
stores.add(store);
return store;
}

private LocalMemoryGraphAccessor graph(String... ids) {
GraphSchema schema = new GraphSchema();
schema.setName("keyword-golden");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,42 +59,46 @@ public class KeywordIndexSerializationGoldenTest {
private static final String SERIALIZATION_GOLDEN = "/index/keyword/serialized_docs_golden.txt";

/**
* Indexes one vertex, one edge and one vertex carrying two vectors, closes the store the way
* the production path does, then reads every document back field by field and compares against
* Indexes one vertex, one edge and one vertex carrying two vectors, finishes writing, then
* reads every document back before releasing the store and compares against
* the golden file. Rendering is {@code <kind> field=[value] ...} with the production field
* order (vertices id/label/content, edges src/dst/label/content).
*/
@Test
public void indexedDocumentsMatchGolden() throws IOException {
GraphSearchStore store = new GraphSearchStore();
store.indexVertex(new GraphVertex(
new Vertex("chunk", "v1", Collections.singletonList("the alpaca grazes"))),
Collections.singletonList(new KeywordVector("alpaca", "grazes")));
store.indexEdge(new GraphEdge(
new Edge("rel", "v1", "v2", Collections.singletonList("x"))),
Collections.singletonList(new KeywordVector("master")));
store.indexVertex(new GraphVertex(
new Vertex("chunk", "v3", Collections.singletonList("two vectors"))),
Arrays.asList(new KeywordVector("a"), new KeywordVector("b")));
store.close();
try {
store.indexVertex(new GraphVertex(
new Vertex("chunk", "v1", Collections.singletonList("the alpaca grazes"))),
Collections.singletonList(new KeywordVector("alpaca", "grazes")));
store.indexEdge(new GraphEdge(
new Edge("rel", "v1", "v2", Collections.singletonList("x"))),
Collections.singletonList(new KeywordVector("master")));
store.indexVertex(new GraphVertex(
new Vertex("chunk", "v3", Collections.singletonList("two vectors"))),
Arrays.asList(new KeywordVector("a"), new KeywordVector("b")));
store.finishWriting();

List<String> actual = new ArrayList<>();
try (DirectoryReader reader = DirectoryReader.open(store.getDirectory())) {
for (int i = 0; i < reader.maxDoc(); i++) {
Document doc = reader.document(i);
if (doc.get("id") != null) {
actual.add(String.format("vertex id=[%s] label=[%s] content=[%s]",
doc.get("id"), doc.get("label"), doc.get("content")));
} else {
actual.add(String.format("edge src=[%s] dst=[%s] label=[%s] content=[%s]",
doc.get("src"), doc.get("dst"), doc.get("label"), doc.get("content")));
List<String> actual = new ArrayList<>();
try (DirectoryReader reader = DirectoryReader.open(store.getDirectory())) {
for (int i = 0; i < reader.maxDoc(); i++) {
Document doc = reader.document(i);
if (doc.get("id") != null) {
actual.add(String.format("vertex id=[%s] label=[%s] content=[%s]",
doc.get("id"), doc.get("label"), doc.get("content")));
} else {
actual.add(String.format("edge src=[%s] dst=[%s] label=[%s] content=[%s]",
doc.get("src"), doc.get("dst"), doc.get("label"), doc.get("content")));
}
}
}
}

Assertions.assertEquals(goldenLines(), actual,
"the serialized form of indexed keyword vectors changed; review against "
+ SERIALIZATION_GOLDEN);
Assertions.assertEquals(goldenLines(), actual,
"the serialized form of indexed keyword vectors changed; review against "
+ SERIALIZATION_GOLDEN);
} finally {
store.close();
}
}

private static List<String> goldenLines() throws IOException {
Expand Down
Loading
Loading