Overview
A narrative introduction to Lucille’s architecture — the problem it solves, the core design, and how documents flow through the system.
Read these pages in order for a complete introduction to Lucille’s architectural design.
- The Problem of Search ETL — Why a simple sequential ingest loop falls short in production and what needs to change.
- Parallelizing Search ETL — How Lucille maps the three ETL functions to concurrent components communicating through queues, and how it tracks document lifecycle across an asynchronous system.
- From Single Process to Distributed — How the same pipeline code runs identically in a single JVM or distributed across machines, and how the Messenger abstraction makes this possible.
- Topology — Batch and streaming models, the WorkerIndexer, and deployment configurations from single-JVM to fully distributed.
- Document Lifecycle — The complete journey of a single Document through the system, plus cross-cutting concerns like logging, metrics, and testing.
- Design Rationale — The 24 requirements that govern Lucille’s architecture and why the system is designed the way it is.
1 - The Problem of Search ETL
Why a simple sequential loop falls short for production search ingestion, and how Lucille addresses the pitfalls.
Organizations build search applications when their users need to find information across large, heterogeneous collections of data — product catalogs, legal documents, support tickets, research papers, internal wikis, file shares. The data lives in databases, cloud storage, APIs, and content management systems, often in different formats and with varying levels of structure. Before any of it becomes searchable, it must be extracted from these sources, cleaned, enriched (with metadata, classifications, embeddings, or extracted entities), and delivered to a search engine or vector database in the right format. This process — getting source data into a search-ready state — is search ETL.
Getting data into a search engine involves three distinct functions:
- Connect to a source system to acquire data.
- Clean and enrich the data.
- Send the data to the search backend.
In a first prototype of a search ingestion process, it is common to perform these functions in a sequential loop:
// as long as the source system has more data for us to consume…
while (source.hasNext()) {
// 1 – create a Document from the next record
Document doc = Document.from(source.next());
// 2 – pass the Document through an enrichment pipeline
pipeline.process(doc);
// 3 – send the document to the search engine
searchClient.index(doc);
}
When you’re ingesting a few hundred documents in a POC, this loop finishes in seconds. It works. It’s easy to reason about. At this point, adopting a search ETL framework feels like overkill.
Then you go to production.
The dataset is no longer a few hundred documents — it’s millions. The loop that finished in ten seconds now takes a full day. And over the course of that day, you run into a series of problems that the simple loop has no answer for.
1. One bad document stops everything
Somewhere in that day-long run, a document fails. Maybe the enrichment logic throws an exception on an unexpected input. Maybe the search backend rejects a malformed field. The loop stops.
Do you fix the document and restart from the beginning? That means re-ingesting everything you already indexed. Do you build checkpointing logic so you can resume from where you left off? Now you need a persistent state store. Or do you skip the bad document and keep going? If you do, where does the failed document go? How do you know at the end of the run which documents succeeded and which didn’t? How do you inspect and reprocess just the failures?
The simple loop gives you no framework for any of these decisions. And search backends make this harder than general ETL in a specific way: bulk indexing APIs return mixed results. A single bulk request can partially succeed — some documents accepted, others individually rejected due to a field type mismatch or a mapping conflict — and the response requires per-document inspection to know what actually happened. You can’t treat indexing as a simple pass/fail operation and retry the batch whole. You need per-document error tracking inside every batch.
2. You can’t see what’s happening
The run has been going for six hours. Is it on track? Is it stuck? Are there bottlenecks building up somewhere? How many documents have been indexed so far, and how many are still waiting?
The loop has no instrumentation. You have no visibility into throughput, no per-stage latency breakdown, no count of in-flight documents, no way to distinguish “slow but progressing” from “silently stuck.” You’re watching a process run for a day with no dashboard and no progress bar.
3. Enrichment logic becomes tangled and unreusable
As the pipeline grows — adding text extraction, NLP, embedding generation, database lookups — the enrichment logic accumulates inside or alongside the loop. It becomes difficult to adjust individual steps without touching the whole thing. Testing a change requires running the full ingest or building a separate harness.
When a second project needs some of the same transformations, there’s no clean mechanism for reuse. The logic gets copied or extracted into a shared library that gradually accumulates its own complexity. Configuration for which steps run and in what order is hardcoded rather than declared.
4. One source document fans out into many index records
In general ETL, a record in the source maps to a record in the destination. Search ingestion breaks that assumption.
For semantic search and RAG pipelines, a single source document — a PDF, a support ticket, a product page — often needs to be split into smaller chunks, each indexed as an independent search record so that retrieval can return the most relevant passage rather than the entire document. One source document becomes tens or hundreds of index records.
The simple loop has no model for this. searchClient.index(doc) indexes one document. If a stage in the pipeline splits a document into chunks, those chunks need to flow through the rest of the pipeline independently, be tracked individually for accounting purposes, and be indexed as separate records. The parent document and its chunks may also need to be indexed in a specific order if the chunks reference the parent by ID.
This 1-to-N fan-out is unique to search ingestion. It doesn’t arise in database ETL, and it breaks both the loop’s execution model and any completion-tracking logic built on top of it.
5. Environment-specific settings are hardcoded
The pipeline runs against a development search backend today. Next week it needs to run against staging, with different credentials, a different Solr URL, and a different collection name. After that, production — with its own connection details, possibly different Kafka brokers, and secrets that shouldn’t be hardcoded.
Managing that across environments — without duplicating code, without accidentally running dev config against prod, with a clear way to inject secrets at runtime — is not a problem the loop was designed to solve.
6. The sequential loop can’t go faster
This is the hardest problem, and the reason all the others matter more once you solve it.
If a full dataset ingest takes a day, the obvious question is: can we make it faster? The answer is yes — but not by iterating on the sequential loop. To go faster, you have to parallelize.
Part of what makes this problem harder in search ingestion than in general ETL is the cost of the enrichment step itself. In database-to-database ETL, transformation is often lightweight — type conversion, field mapping, simple lookups. In search ingestion, enrichment exists specifically to pre-compute things that make search fast and relevant: OCR on scanned documents, named entity extraction, classification, vector embedding generation. This work is genuinely expensive per document. The pipeline is CPU-bound in a way that most ETL pipelines are not, which is exactly why parallelizing it matters so much — and why the gains from doing it correctly are so large.
The loop is slow because its three steps have fundamentally different performance characteristics: connecting to the source system is I/O-bound, pipeline enrichment is often CPU-bound (model inference, API calls, computation), and indexing is I/O-bound but also depends on the CPU of the search backend. The sequential loop adds the latency of all three steps per document:
total time = ((source latency) + (pipeline latency) + (indexing latency)) × (number of documents)
To do better, you need to run these three phases concurrently — reading the next batch of documents while the previous batch is being enriched, while the batch before that is being indexed. And you need multiple enrichment workers running in parallel to saturate available CPU.
But parallelizing the loop creates its own roadblocks. Multiple reader threads need to coordinate so they don’t fetch the same records twice. The enrichment stage may hold expensive resources — models, connection pools — that can’t safely be shared across threads. The search backend performs best when documents arrive in batches, not one at a time, which requires accumulating results from multiple parallel workers before flushing. And if you scale to multiple machines, you need a message queue to distribute work across processes, which means managing Kafka topics, consumer groups, and partition assignment.
Then, once you succeed in parallelizing, a new set of problems emerges. How do you know when the run is complete? With a sequential loop, the answer is obvious: the loop returns. With parallel workers, you can’t know the run is done just because the source is exhausted — documents are still in flight, being processed and indexed. You need an accounting system that tracks every document from publication through its terminal state. How do you handle backpressure? A fast source feeding slow enrichment workers will produce documents faster than they can be consumed, eventually exhausting memory. How do you handle a worker crash mid-run? Kafka’s consumer group protocol can reassign its partitions, but unacknowledged documents need to be redelivered without silently dropping or double-indexing them. What about a document that repeatedly crashes a worker — a malformed input that triggers a bug in third-party code? Without a retry limit and a dead-letter queue, one bad document can loop forever.
Lucille is the system you arrive at after encountering these problems in real production deployments, solving them one by one, and validating the solutions at scale. It is not a theoretical framework — it is the accumulated result of decisions forced by real failure modes.
2 - Parallelizing Search ETL
How Lucille structures search ingestion as three concurrent components communicating through queues, and how it tracks document lifecycle across an asynchronous system.
Lucille addresses all of the pain points described in The Problem of Search ETL — error handling, observability, pipeline composition, fan-out, configuration management, and scalability. This page focuses on the last and most fundamental of those problems: the sequential loop can’t go faster.
Lucille’s answer is a parallel architecture whose components derive directly from the structure of the problem. Each of the three functions of search ETL — acquiring data, enriching it, and indexing it — becomes its own independent concurrent component, operating at its own pace, limited only by its own resources rather than by the speed of the others.
To design any concurrent system, two questions must be answered:
- What are the core components?
- How do they communicate?
The Core Components
Connectors connect to source systems to acquire data. A Connector reads from its source — a filesystem, a database, a Kafka topic, an RSS feed — and emits Documents into the system one at a time. It does not know how many Workers will process those documents, or how long enrichment will take.
Workers enrich documents by passing them through a Pipeline of Stages. Each Stage performs a specific transformation: extracting text, running NLP, generating embeddings, looking up database records. A Worker handles one document at a time and writes the result onward. Multiple Workers run concurrently, each with its own Pipeline instance, so enrichment scales with available CPU.
Indexers receive processed documents and send them in batches to the search backend. An Indexer accumulates documents until a batch is full or a timeout expires, then issues a single bulk API call. Batching is essential for search engine performance — bulk writes are significantly faster than one-at-a-time indexing.
No component waits for another unless it exhausts its backlog of work — or the downstream queue is full, which provides natural backpressure on the upstream component.
How Should They Communicate?
In some distributed systems, asynchronous components require tight, fine-grained communication. Consider a distributed query engine where a central coordinator assigns specific data partitions to worker nodes, monitors their progress with heartbeats, detects straggler tasks, re-assigns failed partitions to other nodes, and waits for explicit acknowledgment from every worker before declaring a query complete. The coordinator and workers exchange a continuous stream of control messages — assignment, acknowledgment, status updates, failure notifications — and the coordinator must maintain a detailed model of the state of every worker at all times.
Lucille takes a different approach. The key observation is that Connectors, Workers, and Indexers can be largely decoupled. No component needs to know what the others are doing in real time. A Connector doesn’t need to know how many Workers exist or whether they are fast or slow — it just needs somewhere to put its output. A Worker doesn’t need to know which Connector produced a document or which Indexer will consume it. An Indexer doesn’t need to know where documents came from or how many are still in the pipeline behind them.
This means it suffices for the components to communicate via queues. Each component places its output on a queue for the next component to read from:
- The Connector puts documents on a processing queue.
- Workers read the processing queue, process documents through the Pipeline, and put results on an indexing queue.
- Indexers read the indexing queue and send documents to the search backend.
No component calls another directly. No component waits for a response. Each simply produces to a queue and consumes from a queue. The queue is the entire communication contract.
// Shared queues connecting the three components.
Queue processingQueue; // documents waiting to be enriched
Queue indexingQueue; // enriched documents waiting to be indexed
// === CONNECTOR ===
while (source.hasNext()) {
Document doc = Document.from(source.next());
processingQueue.put(doc);
}
// === WORKER (N instances, running concurrently) ===
while (running) {
Document doc = processingQueue.poll();
Document result = pipeline.process(doc);
indexingQueue.put(result);
}
// === INDEXER (M instances, running concurrently) ===
while (running) {
Document doc = indexingQueue.poll();
batch.add(doc);
if (batch.isFull() || batch.isExpired()) {
searchClient.bulkIndex(batch.flush());
}
}
This is the core of Lucille’s architecture. The Connector, Workers, and Indexers need no knowledge of each other beyond the existence of these two queues.
What’s Missing?
The pseudocode above captures the essential structure but leaves out something important: there is no way to know when a batch ingest is complete, no way to track how many documents have succeeded or failed, and no support for two common pipeline operations — dropping documents that should not be indexed, and emitting child documents from a single source record.
With the sequential loop, completion was trivial:
while (source.hasNext()) { }
// loop returns → run is done
In a concurrent system, the Connector finishing is not enough. When the Connector stops putting documents on the processing queue, Workers may still be enriching documents, and Indexers may still be sending batches. The system is not done until every document has reached a terminal state — indexed, failed, or dropped.
Tracking this requires knowing about every document in flight: which ones have been introduced, which have completed, and which are still being processed. A Worker can also generate new child documents during pipeline processing — for example, splitting a source document into chunks for a RAG pipeline — and those children must be tracked independently.
One approach would be a central coordinator that determines completion by polling all components — querying the Connector, all Worker instances, and all Indexer instances, and declaring the run done when all queues are empty and all components report no work in flight. Lucille avoids this design: it would require every component to expose a status interface, and the coordinator would need to know the full topology of the running system. Instead, Lucille uses an event-driven model in which components report their own progress without being interrogated.
The Solution: an Event Queue and a Publisher
Lucille’s solution is to introduce two additional elements:
An event queue carries document lifecycle notifications from Workers and Indexers. When a document is successfully indexed, the Indexer sends a FINISH event. When a document fails, a FAIL event is sent. When a Stage deliberately drops a document, a DROP event is sent. When a Worker generates a child document, a CREATE event is sent so that child can be tracked.
A Publisher is used by the Connector to introduce documents into the system. The Publisher stamps each document with a run ID, records its ID as pending, and places it on the processing queue. It then listens to the event queue, reconciling events against its pending set. When a FINISH, FAIL, or DROP event arrives for a document ID, the Publisher removes it from the pending set. When a CREATE event arrives for a child document, the Publisher adds that new ID to the pending set.
The run is complete when three conditions hold simultaneously: the Connector has stopped, the event queue is empty, and the Publisher has no pending IDs. Any one condition alone is insufficient.
This design handles two subtle edge cases. First, a child document can be indexed and receive a FINISH event before the Publisher has processed its CREATE event, because Workers and Indexers run concurrently. The Publisher handles this with a secondary ledger of premature completions that it reconciles when the CREATE event eventually arrives. Second, the pending set is a Bag rather than a Set — two documents with the same ID can be published in the same run, and a Bag counts duplicates, so two publishes of the same ID require two separate terminal events to clear.
The complete pseudocode, now including the event queue and Publisher, is shown below. See Appendix: Full Pseudocode for the detailed version presented as an appendix.
Appendix: Full Pseudocode
// === QUEUES ===
Queue processingQueue; // documents waiting to be enriched by Workers
Queue indexingQueue; // processed documents waiting to be sent to the search backend
Queue eventQueue; // document lifecycle events (CREATE, FINISH, FAIL, DROP)
// === RUNNER / CONNECTOR ===
// Runner launches the connector in its own thread; the main thread waits for completion.
publisher = new Publisher(processingQueue, eventQueue);
new Thread(() -> {
// publisher.publish() is thread-safe, so a connector can spawn multiple
// publishing threads; this example calls publish() sequentially
while (source.hasNext()) {
Document doc = Document.from(source.next());
publisher.publish(doc); // stamps run ID, tracks doc ID, puts on processingQueue
}
}).start();
// main thread: block until all documents (and their children) reach a terminal state
publisher.waitForCompletion();
// === WORKER (N instances) ===
// Each instance has its own Pipeline with its own instances of every Stage.
// Stages can hold stateful resources (models, connections, compiled patterns)
// without synchronization because they are never shared across threads.
pipeline = new Pipeline(stages); // per-thread instance
while (running) {
Document doc = processingQueue.poll(); // blocking poll with timeout
Iterator<Document> results = pipeline.process(doc);
for (Document result : results) {
if (result.isChild()) {
eventQueue.send(CREATE, result); // notify publisher of new child
}
if (result.isDropped()) {
eventQueue.send(DROP, result); // notify publisher, discard document
} else {
indexingQueue.put(result); // send for indexing
}
}
}
// === INDEXER (M instances) ===
// Each instance runs independently, consuming from the shared indexingQueue.
while (running) {
Document doc = indexingQueue.poll(); // blocking poll with timeout
batch.add(doc);
if (batch.isFull() || batch.isExpired()) {
try {
searchClient.bulkIndex(batch.flush());
for (Document indexed : batch) {
eventQueue.send(FINISH, indexed);
}
} catch (Exception e) {
for (Document failed : batch) {
eventQueue.send(FAIL, failed);
}
}
}
}
// === PUBLISHER (event loop, runs on main thread) ===
// Reconciles lifecycle events to determine when all work is complete.
Bag<String> pending = {}; // IDs not yet in a terminal state
on publish(doc):
pending.add(doc.id);
on event(CREATE, id):
pending.add(id);
on event(FINISH | FAIL | DROP, id):
pending.remove(id);
isComplete():
return connector.isDone() && eventQueue.isEmpty() && pending.isEmpty();
Nothing in this pseudocode specifies what the queues actually are — whether they are in-memory data structures or distributed messaging infrastructure. That question is addressed in the next page: Pluggable Queueing and the Deployment Model.
3 - From Single Process to Distributed
How the same pipeline code runs identically in a single JVM or distributed across machines — and how the Messenger abstraction makes this possible.
The previous page showed how to parallelize search ingestion using three concurrent components — Connector, Workers, and Indexers — communicating through a processing queue and an indexing queue. What we have not yet discussed is what those queues actually are.
What a Queue Must Provide
The queues in Lucille’s architecture have a specific set of requirements. They must support concurrent access from multiple producers and consumers. They must support a blocking poll() operation so that a Worker or Indexer that finds an empty queue waits efficiently rather than spinning. And when multiple Workers are calling poll() concurrently, work must be distributed fairly among them — each document should go to exactly one Worker.
The Case Against Hardcoding
In a project like Lucille, it would have been tempting to choose a suitable queue implementation and hardcode it directly into the components that use it. But either obvious choice would have carried a significant cost.
Hardcoding LinkedBlockingQueue would limit scaling to what can be achieved by adding threads inside a single JVM. For large-scale ingestion that needs to be distributed across multiple machines, this ceiling is too low.
Hardcoding Kafka would require every user to have a running Kafka cluster — even for a simple ingest that does not need to scale beyond one JVM. For development, testing, and smaller production workloads, this is unnecessary infrastructure with real operational overhead.
The Key Observation: Queues Are Used in a Limited, Standardized Way
Lucille’s components interact with queues in a narrow and well-defined way: producers call put(), consumers call poll(). There is no complex queue-specific logic embedded in the component code — no partition management, no consumer group coordination, no topic configuration. Since the interface is this simple, the queue implementation can be made pluggable without changing any component code.
Lucille achieves this through a Messenger abstraction. Each component type has its own Messenger interface — PublisherMessenger, WorkerMessenger, IndexerMessenger — that defines exactly the messaging operations that component needs. The Connector and Publisher use a PublisherMessenger to put documents on the processing queue and read from the event queue. Workers use a WorkerMessenger to poll the processing queue and put results on the indexing queue. Indexers use an IndexerMessenger to poll the indexing queue and send events.
Each component is written entirely against its Messenger interface. The component code is identical regardless of which Messenger implementation is in use — it has no knowledge of whether the underlying queue is an in-memory data structure or a Kafka topic.
A Flexible Deployment Model and a Natural Scaling Path
The pluggable Messenger model directly enables a flexible deployment model.
For a simple deployment, the Connector, Workers, and Indexer all run as threads inside a single JVM where the queues are in-memory LinkedBlockingQueue instances with fixed capacity. This is the LocalMessenger implementation. No external infrastructure is required.
To scale the deployment, the Connector, Workers, and Indexers can run in separate JVMs where the queues are Kafka topics. This is the KafkaMessenger implementation. Kafka’s consumer group protocol handles partition assignment and rebalancing as Workers are added or removed.
This gives Lucille a natural scaling path. You can begin with a single-JVM deployment, gain experience with the system, tune your pipeline, and validate your configuration — all without Kafka. When your data volumes require more throughput than a single JVM can provide, you introduce Kafka and distribute the load across multiple machines. The only thing that changes in this transition is the Messenger implementation. Every other code path — the pipeline stages, the accounting logic, the retry behavior, the batching — remains exactly the same. This gives a high degree of confidence that the system will behave identically at scale to how it behaved in a single-JVM deployment.
Testability
The pluggable Messenger model also has significant advantages for testing.
Moving from a sequential loop to a concurrent, queue-based architecture introduces a new challenge: the system becomes harder to test. A simple loop with mocked externals runs trivially inside a unit test. A multi-threaded, queue-based system with concurrent components does not — unless it has been specifically designed to make testing straightforward.
The Messenger abstraction solves this directly. Lucille provides a test mode where a TestMessenger wraps the LocalMessenger and records a history of every message sent between components — every document published, every document sent for indexing, every lifecycle event. After a test run, test code can retrieve this history and make assertions about exactly what happened.
The Indexer runs in bypass mode during tests: the full indexing code path executes — batching, event sending, error handling — right up to the point of communicating with the search backend, which is bypassed. This means the Indexer’s logic is exercised without requiring a live Solr or OpenSearch instance.
Connectors and pipeline stages that communicate with live source systems do need some form of mocking for those external systems — for example, using WireMock to simulate an HTTP endpoint, or an in-memory database for a JDBC connector. But that mocking is scoped to the external system itself. The Lucille components — the Workers, the Indexer, the Publisher, the event queue — are not mocked at all. They run exactly as they would in production.
The practical effect is that end-to-end integration tests of complex pipelines are straightforward to write. The full Lucille system runs inside a unit test, in its complete and realistic form, without requiring Lucille’s own components to be stubbed out. The confidence this provides is significant: a pipeline that passes its tests in this mode has been proved to work with the real Lucille mechanics, not a simplified approximation of them.
The pages so far have focused on the batch ingest model — a bounded run with a defined start and end. Lucille also supports a streaming model for continuous, unbounded ingestion, as well as a hybrid WorkerIndexer deployment pattern that offers a practical middle ground between single-JVM and fully distributed. These are covered in the next page: Topology.
4 - Topology
Lucille can be configured to best support your use case.
Batch and Streaming Models
The pages so far have described Lucille in terms of a batch ingest: a bounded run where a Connector reads a finite dataset to completion, the Publisher tracks every document through to a terminal state, and the run ends when all work is done.
Lucille also supports a streaming model for unbounded, continuous ingestion. In a streaming scenario, documents arrive continuously from an external source and need to be processed and indexed as they arrive. There is no run boundary, no completion accounting, and no Connector lifecycle — the stream simply flows.
From an architectural standpoint, the streaming model is a straightforward variation of the batch model. Because Lucille is a queue-based system, the Connector’s role can be taken over by an external producer that writes documents directly to the processing queue (a Kafka topic). Workers consume from that topic and process documents exactly as they would in batch mode. The rest of the system — Workers writing to an indexing queue, Indexers reading from it and sending batches to the search backend — functions identically.
Crucially, the pipeline stages themselves are unaware of which model they are running under. A Stage that extracts entities or generates embeddings does not know or care whether it is processing a bounded batch or an unbounded stream. This means you can develop and test pipeline logic in batch mode — where the accounting and test infrastructure make correctness verification straightforward — and then deploy the same pipeline in streaming mode for real-time production ingestion.
The WorkerIndexer: A Practical Middle Ground
In the fully distributed deployment, Workers and Indexers run as separate JVM processes connected by a Kafka topic. This maximizes flexibility — each fleet can be scaled independently — but it adds a Kafka round-trip between Worker output and Indexer input.
As a practical simplification, Lucille provides the WorkerIndexer: a single JVM process that pairs a Worker with a co-located Indexer. The Worker reads from Kafka and writes processed documents to an in-memory queue; the paired Indexer reads from that in-memory queue and sends to the search backend. This eliminates the Worker-to-Indexer Kafka round-trip while retaining horizontal scaling — you can run as many WorkerIndexer processes as needed, and Kafka’s consumer group protocol distributes partitions across them automatically.
The WorkerIndexer is a useful middle ground between the fully local single-JVM deployment and the fully distributed model with independent Worker and Indexer fleets. It is the recommended starting point for distributed deployments that do not require separate Worker and Indexer scaling.
Worker Processes and Worker Threads
There are two independent knobs for scaling enrichment throughput, and it is important to distinguish them.
A Worker process is a JVM process running Lucille’s Worker component. In distributed deployments, multiple Worker processes can be launched on separate machines, all consuming from the shared Kafka processing topic.
Within each Worker process, a Worker thread is a single thread executing the pipeline — and each Worker thread is also an independent Kafka consumer. The Kafka poll() call happens inside the thread, so each thread fetches and processes documents directly from the topic. Kafka’s consumer group protocol assigns partitions across all active consumer threads, summed across all Worker processes. A Worker process runs a configurable number of Worker threads (worker.threads), each with its own independent Pipeline instance — its own copy of every Stage, with its own connections, models, and any other stateful resources. Because Stage instances are never shared across threads, they require no synchronization.
The two knobs compose: to maximize throughput, scale out with more Worker processes across machines, and scale up with more Worker threads within each process. One constraint applies: the total number of Worker threads across all processes should not exceed the number of partitions in the Kafka source topic, since Kafka cannot assign a partition to more than one consumer in the same group — excess threads would sit idle. The same distinction applies to WorkerIndexer processes, where each process contains a Worker thread pool paired with an Indexer.
For the commands to start each mode in production, see Production Deployment.
Local Modes
In local modes (also called single-node or standalone modes), all Lucille components run as threads in a single JVM process. Lucille supports two local modes.
Local
All components run as threads in one JVM. Inter-component communication uses in-memory queues. No external dependencies required.

Kafka Local
All components still run as threads in one JVM, but inter-component communication uses an external Kafka instance instead of in-memory queues. Useful for testing Kafka integration without deploying separate processes.

Distributed Modes
Distributed modes are how Lucille scales horizontally. Kafka provides message persistence and fault tolerance, and each Lucille component type runs as one or more separate JVM processes.
Fully Distributed
Best for batch ingest architecture.
The Connector/Publisher, Workers, and Indexers each run as separate JVM processes. All inter-component communication flows through Kafka topics. Workers and Indexers send lifecycle events to a Kafka event topic, which the Publisher polls to track completion.

Connector-less Distributed
Best for streaming ingest architecture.
Like Fully Distributed, but with no Lucille Connector or Publisher. An external system writes documents directly to the Kafka source topic, where Workers pick them up. Because there is no Publisher, there is no run-completion accounting — this mode is intended for unbounded streaming workloads.
If events are enabled, the external publisher must stamp each document with a run_id.

Hybrid
Best for streaming update architecture.
Like Connector-less Distributed, but each Worker is paired with a co-located Indexer in the same JVM (a WorkerIndexer). Workers read from a Kafka source topic and write processed documents to an in-memory queue; the paired Indexer reads from that queue and sends to the search backend. This eliminates one Kafka round-trip per document compared to Fully Distributed.

5 - Document Lifecycle
The complete journey of a single Document through Lucille, from raw data in a source system to a searchable record in the search backend.
This page traces a single Document from the moment it exists only as raw data in a source system to the moment it is retrievable by a search query. It takes the Lucille architecture as a given — for the conceptual explanation of how the system is structured, see Parallelizing Search ETL and Pluggable Queueing.
The Happy Path
Before a document’s lifecycle begins, the system components must be set up. A batch ingest is launched via the Runner, which first validates the configuration. Assuming a single-connector config, the Runner creates a Publisher, passes it to the Connector, and launches the Connector in its own thread. The Runner then calls publisher.waitForCompletion() and blocks. In distributed mode, the Worker and Indexer processes would have been started separately beforehand. In local mode, the Runner starts the Worker and Indexer as threads in the same JVM. With this infrastructure in place, documents can begin their journey.
Step 1 — Raw data in the source system
The document begins as a row in a database table, a file on a filesystem, a message on a Kafka topic, or any other source that Lucille’s Connector knows how to read. At this point it is not yet a Lucille Document — it is raw data in whatever format the source system uses.
Step 2 — The Connector reads the record and creates a Document
Inside connector.execute(publisher), the Connector reads the next record from its source and constructs a Document with a stable, user-defined ID. The ID must be deterministic — if the pipeline is re-run, the same source record must produce the same document ID so that the Indexer’s upsert semantics correctly update rather than duplicate the record in the search backend.
The Connector calls publisher.publish(document).
Step 3 — The Publisher registers the Document and puts it on the processing queue
Before placing the document on the processing queue, the Publisher does two things in order:
- Stamps the document with the
run_id — an immutable field set once and used for log correlation, event routing, and Kafka topic naming throughout the document’s lifetime. - Registers the document’s ID in its accounting ledger (
docIdsToTrack). Registration happens before the document is placed on the queue — the moment it is on the queue, a Worker could process it and the Indexer could send a FINISH event. If the Publisher hadn’t registered the ID yet, it would misclassify that event.
The document is then placed on the processing queue. In distributed mode, this crosses a Kafka boundary: the document becomes a message on the {pipeline}_source Kafka topic, serialized and persisted.
No lifecycle event is sent at this point — the Publisher tracks the document internally through the accounting ledger rather than via the event queue.
Step 4 — A Worker thread picks up the Document
A Worker thread is blocking on processingQueue.poll(). In distributed mode, this is a Kafka consumer poll() call — each Worker thread is an independent Kafka consumer within the consumer group, and Kafka’s consumer group protocol has assigned it one or more partitions of the source topic. The document is delivered to exactly one Worker thread.
The Worker passes the document through each Stage in the pipeline in sequence.
Step 5 — Pipeline processing
Each Stage receives the document, performs its transformation — extracting text, running NLP, generating an embedding, querying a database — and returns the result.
Step 6 — The parent Document moves to the indexing queue
Once the parent document has passed through all pipeline stages, the Worker places it on the indexing queue. In distributed mode, this crosses a second Kafka boundary: the document becomes a message on the {pipeline}_dest Kafka topic.
Step 7 — The Indexer batches and sends the Document
The Indexer thread is blocking on indexingQueue.poll(). It accumulates documents in a batch. When the batch reaches its configured size (indexer.batchSize, default 100 documents) or its timeout elapses (indexer.batchTimeout, default 100ms), the Indexer issues a single bulk API call to the search backend.
Before sending, the Indexer strips reserved internal fields (___dropped, ___skipped, ___children) and applies any configured field whitelist or blacklist. The document that reaches the search backend contains only the fields you want indexed.
The search backend accepts the document. The document now exists in the index.
Step 8 — The Indexer sends a FINISH event
After a successful bulk operation, the Indexer sends a FINISH event to the event queue for each document in the batch — including our document. In distributed mode this event travels on the {pipeline}_event_{runId} Kafka topic, which is unique per run so that events from concurrent runs never interfere with each other.
The Publisher’s polling loop receives the FINISH event, looks up the document’s ID in docIdsToTrack, and removes it. The document’s lifecycle is complete.
Step 9 — The Document is searchable
The document has been accepted by the search backend. Once the backend makes it visible — via a scheduled commit in Solr, or after the refresh interval in Elasticsearch and OpenSearch — a query can retrieve it. Lucille does not issue commits; visibility timing is controlled entirely by the search backend’s configuration. The enrichment performed during pipeline processing — the extracted text, the entity tags, the vector embedding — is available for full-text search, faceting, and semantic retrieval.
After all documents (parents and children) have completed their individual lifecycles, the framework determines that the run is complete. The Publisher continuously evaluates three conditions: (1) the Connector thread has terminated (no more documents will be published), (2) the event queue is empty (no more events are in transit), and (3) docIdsToTrack is empty (every document has reached a terminal state). All three must hold simultaneously. When they do, waitForCompletion() returns and the Runner logs the run summary.
The Detailed Path
The Happy Path above omits edge cases, error handling, logging, metrics, backpressure, routing, and child documents. This section walks through the same journey again, annotating each step with everything that actually happens. A developer debugging “why didn’t my document get indexed?” can walk through these steps to identify where the document’s journey diverged.
Step 1 — Raw data in the source system
Same as the Happy Path. No additional mechanisms apply until the Connector reads the record.
Step 2 — The Connector reads the record and creates a Document
Everything from the Happy Path, plus:
- Backpressure (maxPendingDocs). If
publisher.maxPendingDocs is configured and the pending count has reached the threshold, publisher.publish() blocks here — the Connector thread waits until downstream components process enough documents to bring the count below the max. - Backpressure (queue capacity). In local mode, if
publisher.queueCapacity is reached, the put() call blocks until a Worker consumes a document from the queue. - Pause/resume. If
publisher.pause() has been called (rare, used in specialized deployment patterns), publish() blocks until resume() is called. - Metrics. The Publisher’s timer measures the gap between consecutive
publish() calls — this becomes the “Mean connector latency” metric visible in periodic logs. - Logging. The DocLogger logs:
"Publishing document {id}."
Step 3 — The Publisher registers the Document and puts it on the processing queue
Everything from the Happy Path, plus:
- Collapsing mode. If collapsing mode is active and the previous document had the same ID, the Publisher merges this document’s fields into the previous one via
setOrAddAll() and does NOT place it on the queue yet. It waits for a document with a different ID before sending the merged result. - MDC. The document’s
run_id field is now set and immutable. All subsequent log lines for this document (via MDC) will include this run_id. - Serialization. In local mode, the Document object (a Jackson
ObjectNode wrapper) is placed directly on the in-memory queue — no serialization occurs. In distributed mode, the Document is serialized to a JSON string and produced to the Kafka source topic. The document’s id becomes the Kafka message key (ensuring ordering guarantees), and the full JSON representation becomes the message value. The JSON format means documents on Kafka are human-readable with standard tooling.
Step 4 — A Worker thread picks up the Document
Everything from the Happy Path, plus:
- Deserialization. In distributed mode, the Worker’s Kafka consumer receives the JSON message and deserializes it back into a
KafkaDocument (a Document subclass that also carries the Kafka partition, offset, and key metadata). In local mode, the Worker receives the original Document object directly from the in-memory queue — no deserialization needed. - Stuck-worker detection. The Worker updates its
pollInstant timestamp — the WorkerPool watcher uses this to detect stuck workers. If the Worker doesn’t poll again within worker.maxProcessingSecs, the watcher logs an error (and optionally exits the JVM if worker.exitOnTimeout is true). - MDC in distributed mode. The Worker updates the MDC
run_id from the document’s stamped run_id (since the Worker thread may process documents from different runs over its lifetime). - Poison pill detection. If
worker.maxRetries is configured, the Worker checks the RetryCounter. If this document has exceeded its retry limit (because it crashed Workers on previous attempts), the Worker sends it to the dead letter queue and sends a FAIL event — it never enters the pipeline.
Step 5 — Pipeline processing
Everything from the Happy Path, plus:
- Conditional execution. Before each Stage, the framework evaluates the
conditions block. If conditions don’t match, the Stage is skipped entirely for this document — processDocument() is never called, no time is recorded for that Stage, and the DocLogger logs "Stage {name} did not process {id}." - Dropped/skipped bypass. If the document is marked as dropped (
___dropped = true) or skipped (___skipped = true), ALL remaining stages are bypassed. - Per-stage metrics. For each Stage that does execute, the framework records processing time in a per-stage Timer. This becomes the “Mean latency: X ms/doc” in the per-stage metrics logged at end of run. The stage’s child Counter is incremented for each child emitted.
- Per-stage logging. The DocLogger logs
"Stage {name} to process {id}" before and "Stage {name} done processing {id}" after each stage execution. - Error handling. If a Stage throws
StageException, the Worker catches it, sends a FAIL event, and the document’s lifecycle ends here — it never reaches the Indexer. The stage’s error Counter is incremented. - Child document emission. A Stage can emit child documents by returning them from
processDocument() as an Iterator<Document>. The most common use case is chunking: a ChunkText Stage attaches text chunks to the parent, and a subsequent EmitNestedChildren Stage converts them into independently flowing documents. Each emitted child becomes a separate search record. When the Worker emits a child, it immediately sends a CREATE event to the event queue — before the parent document’s pipeline execution continues. This ordering guarantee ensures the Publisher registers the child’s ID in docIdsToTrack before it could possibly receive a completion event for the parent (which would otherwise make the run appear done while children are still in flight). The child joins the processing queue and is picked up by a Worker thread — possibly the same one, in a later poll iteration — for its own independent pipeline run.
Step 6 — The parent Document moves to the indexing queue
Everything from the Happy Path, plus:
- Dropped documents. If the document is dropped (
___dropped = true), it does NOT go to the indexing queue. The Worker sends a DROP event to the Publisher and discards it. The document’s lifecycle ends here. - Skipped documents. If the document is skipped (
___skipped = true), it DOES go to the indexing queue — it bypassed enrichment stages but still needs to reach the Indexer (typically to issue a delete against the search backend). - Metrics. The Worker’s processing Timer stops — the elapsed time contributes to “Mean pipeline latency” in periodic logs.
- Offset management. In distributed mode, the Worker commits the Kafka offset for this document. In WorkerIndexer mode, the commit is deferred until after indexing succeeds.
- Serialization to indexing queue. In distributed mode, the document is serialized to JSON again and produced to the Kafka dest topic (again with the document ID as the message key). In local mode or WorkerIndexer mode, the Document object is placed directly on an in-memory queue.
Step 7 — The Indexer batches and sends the Document
Everything from the Happy Path, plus:
- Index routing. If
indexer.indexOverrideField is configured and the document has that field, the document is routed to a different index/collection than the default. With MultiBatch, it goes into a separate batch for that destination. - Deletion handling. If the document is marked for deletion (via
deletionMarkerField + deletionMarkerFieldValue), the Indexer issues a delete operation instead of an index operation. If deleteByFieldField and deleteByFieldValue are also present, it’s a delete-by-query. - ID override. If
indexer.idOverrideField is configured, the document’s ID in the search backend is taken from that field rather than the internal document ID. - Field filtering. The configured whitelist/blacklist is applied — only the desired fields reach the search backend. Reserved fields (
___dropped, ___skipped, ___children) are always stripped. - Retries. If the bulk API call fails with a retryable status code (429, 503) and
indexer.maxRetries > 0, the entire batch is retried with exponential backoff. The document may be sent multiple times before succeeding or being declared failed. - Metrics. The Indexer’s Meter and Histogram record the batch: docs/sec throughput and per-doc backend latency.
Step 8 — The Indexer sends a FINISH event
Everything from the Happy Path, plus:
- Per-document failures. If the bulk API reported a per-document failure for this specific document (e.g., schema violation), a FAIL event is sent instead of FINISH. The document was processed successfully through the pipeline but rejected by the search backend.
- Offset commitment. The
batchComplete() call fires in the finally block regardless of success or failure — this allows Kafka offset commits in WorkerIndexer mode even when a batch fails. - Backpressure release. The Publisher receives the event, removes the document’s ID from
docIdsToTrack, and decrements the pending count. If maxPendingDocs was blocking the Connector, this may unblock it. - Run accounting. The document contributes to the run’s
numSucceeded (FINISH), numFailed (FAIL), or numDropped (DROP) count, which appears in the final run summary.
Step 9 — The Document is searchable
Everything from the Happy Path, plus:
- Run summary. The run summary logged at completion includes this document in its counts: e.g.,
"200000 docs succeeded. 0 docs failed. 0 docs dropped." - Per-stage metrics. The per-stage metrics logged at completion show how much time this document (aggregated with all others) spent in each stage.
- Audit trail. If the DocLogger was enabled at INFO level, a complete audit trail exists for this document — every stage entry/exit, every queue transition, the FINISH event — filterable by document ID via MDC.
For detailed explanations of how each component works internally, see the Internals section — particularly Publisher Accounting, Pipeline Internals, Kafka Integration, and Metrics and Observability.
6 - Design Rationale
The guiding principles that govern Lucille’s architecture, and the features that achieve them.
These are the overarching design decisions that shape everything about Lucille. Every architectural choice, every API design, and every operational feature must be consistent with these principles. They emerged from years of building search ingestion frameworks for customer projects and were refined through production deployments.
Part I: Guiding Principles
1. Built for search
The system should be purpose-built for getting data into search engines and vector databases — not a general-purpose ETL tool adapted for search. This focus should shape the document model, the identity model, the wire format, and the operations the system supports natively.
2. Concurrency
The tasks of retrieving data, processing it, and sending it to the search engine should be handled by separate components that run concurrently. No component should wait for another unless it exhausts its backlog of work. While the system should be highly concurrent, pipeline authors should not have to manage concurrency — the framework should handle parallelism transparently.
3. Scalability
It should be easy to scale the system by adding more component instances — especially more Workers — even while the system is running. Scaling up should not require stopping the ingest, modifying configuration, or redeploying.
4. Extensibility
It should be easy to extend the system by adding new implementations of the core component types — new Connectors, new Stages, new Indexers — without modifying the framework itself. Extension points should be defined by small, well-documented interfaces.
5. Deployment versatility
The system should be easy to deploy as a single, self-contained process, and it should have a clear pathway for expanding into a distributed deployment. The codepaths should remain nearly identical when switching from one deployment mode to another.
6. Batch and streaming unity
The system should support both batch architecture (finite data with completion detection) and streaming architecture (unbounded data with no run boundary). Enrichment logic should not know or care which mode it is running under.
7. Minimal framework overhead
The framework itself should never be the bottleneck. Per-document overhead should be negligible relative to the actual enrichment work. Available system resources should be used effectively, with the constraint always being the source system, the enrichment logic, or the search backend — never the framework.
8. Observability
The system should make it easy to understand what is happening during an ingest, what has happened after an ingest, and what went wrong when something fails. Metrics, logging, and status reporting should be built into the framework, not bolted on.
9. Testability
The entire framework should easily run inside a unit test. It should be straightforward to write end-to-end tests of ingestion pipelines that exercise the real framework components with only external systems mocked or bypassed.
10. Configuration-driven operation
All aspects of an ingest should be specified in a readable configuration file. Changes should be possible by editing the file alone, without rebuilding the project. Configuration should be composable, parameterizable, and validatable.
11. Optimistic error handling
The system should ingest as much data as possible, continuing past per-document errors rather than aborting. However, the system should stop immediately on structural errors rather than wasting time on work that cannot succeed.
12. Resilience
The system should recover from failures without losing work. Transient failures should be retried, poison-pill documents should be quarantined, and the system should shut down gracefully when asked, preserving in-flight work rather than abandoning it.
Part II: Features by Principle
1. Built for search
The document model represents data the way search engines think about it: typed fields, single-valued and multi-valued, with operations that map directly to search engine semantics.
- Search-oriented document model. Fields support single and multi-valued access uniformly. The three update modes (overwrite, append, skip) match how search engine fields are actually populated.
- Deterministic document IDs. Connectors derive IDs from source data (file paths, database primary keys, URLs) so that re-ingestion updates existing records rather than creating duplicates.
- ID namespacing. A configurable
docIdPrefix on each Connector prevents ID collisions when multiple connectors write to the same index. - ID override at indexing time. The
idOverrideField setting lets the Indexer use a different field’s value as the document’s ID in the search backend, decoupling internal tracking from index identity. - Deletion markers. Documents can represent delete-by-ID or delete-by-query operations via configurable marker fields, enabling CDC and incremental ingestion patterns.
- Operation ordering. Sequences of creates, updates, and deletes for the same document ID preserve their order in distributed mode by using the document ID as the Kafka message key.
- Zero-cost JSON serialization. The document is backed by a Jackson ObjectNode — already in the wire format that search engine bulk APIs expect. No conversion at the boundary.
- Multiple search backends. Solr, OpenSearch, Elasticsearch, Pinecone, Weaviate, and CSV are supported as indexing destinations.
- Index routing. Documents in the same batch can be routed to different indices or collections via
indexOverrideField, supporting multi-tenant architectures.
2. Concurrency
Connector, Worker, and Indexer run as independent concurrent components communicating through queues. The framework manages all parallelism so that pipeline authors write sequential code.
- Component separation. Each component operates at its own pace. The Connector publishes to a queue, Workers pull and process, the Indexer batches and sends. No component blocks another unless queues are full or empty.
- Per-thread pipeline instantiation. Each Worker thread gets its own Pipeline with its own instances of every Stage. Instance fields in a Stage are effectively thread-local — no synchronization required.
- Event-driven completion detection. The Publisher tracks every document from publication to terminal state (FINISH, FAIL, DROP) using an event queue. The run is complete when all documents reach a terminal state.
- Bag-based accounting. The Publisher uses a multiset (Bag) rather than a Set, correctly handling duplicate IDs published in the same run — each publication requires its own terminal event.
- Concurrent run isolation. Each batch run gets a dedicated event topic (
{pipeline}_event_{runId}), preventing events from one run interfering with another. - Child documents. Stages can generate additional documents mid-pipeline. The Publisher is notified via CREATE events and tracks children independently.
- Sequential composition. Multiple batch ingests can be composed in strict sequence — one ingest cannot start until all work from the previous ingest is complete.
- Document collapsing. The Publisher can merge consecutive same-ID documents into a single document before sending for processing, reducing redundant work in CDC scenarios.
3. Scalability
Adding capacity is an operational action, not a development effort.
- Hot scaling. New Worker instances can join a running ingest without restart or coordination. In distributed mode, a new Worker joins the Kafka consumer group and receives partition assignments immediately.
- Vertical scaling. Within a single process, adding Worker threads is a configuration change (
worker.threads). - Horizontal scaling. Across processes, adding Workers means launching additional JVMs. Both are independent levers.
- WorkerIndexer hybrid. Co-located Worker+Indexer processes provide horizontal scaling without the operational complexity of managing separate fleets.
4. Extensibility
New components are added by implementing an interface, placing the compiled code on the classpath, and referencing it by name in configuration.
- Interface-based extension. Stage, Connector, and Indexer are small, well-defined interfaces. Implementing one is the only requirement for adding new functionality.
- Runtime discovery. Components are instantiated reflectively from class names in configuration. No registration step, no framework modification, no core rebuild required.
- Modular Maven structure. Plugins are separate Maven modules. New connectors, stages, and indexers can be developed and versioned independently of the core.
- Built-in enrichment stages. Common processing tasks are available out of the box: field manipulation, regex, text operations, entity extraction, language detection, embeddings, chunking, database lookups, HTTP enrichment, scripting (JavaScript, Python), and JSONata transformations.
- Source coverage. Built-in connectors for databases (JDBC), filesystems (local, S3, Azure, GCS), CSV/XML/JSON files, Kafka topics, RSS feeds, and search engines.
- Multi-cloud file access. A single FileConnector handles local filesystem, S3, Azure Blob, and GCS through pluggable StorageClient implementations selected by URI scheme.
5. Deployment versatility
The same pipeline configuration, the same component implementations, and the same enrichment logic run regardless of deployment mode.
- Local mode. All components run as threads in a single JVM with in-memory queues. No external infrastructure required.
- Distributed mode. Components run as separate processes communicating via Kafka topics.
- Hybrid mode (WorkerIndexer). Worker and Indexer co-located in a single process, consuming from Kafka but avoiding a second Kafka round-trip between processing and indexing.
- Streaming mode. Workers consume directly from a Kafka topic populated by an external system, with no Runner or Connector.
- Deployment-independent codepaths. Only the messaging implementation changes between modes (in-memory queues vs. Kafka). All other code paths — pipeline stages, accounting logic, retry behavior, batching — remain identical.
- Programmatic run triggering. Runs can be triggered and managed via a REST API (RunnerManager), with support for concurrent runs in the same JVM.
6. Batch and streaming unity
Pipeline logic is written once and deployed in either mode without modification.
- Batch mode. A Runner triggers a finite ingest with completion detection. The Connector retrieves a bounded set of data, and the system reports when all work is done.
- Streaming mode. An external system places documents on a Kafka topic continuously. Workers process them with no run boundary or completion accounting.
- Mode-transparent stages. A Stage that extracts entities or generates embeddings works identically in both modes. It never knows which mode it is running under.
- Incremental/stateful ingestion. Connectors can track what they have previously published (via JDBC-backed state) and process only new or modified data on subsequent runs — bridging batch and streaming patterns.
7. Minimal framework overhead
The bottleneck should always be the source system, the enrichment logic, or the search backend — never the framework’s own overhead.
- Zero-cost JSON serialization. The Document is already in wire format. No conversion step at queue boundaries or when sending to search backends.
- Lazy iterator-based processing. Child documents are produced as an iterator, not materialized into a list. Memory usage is bounded regardless of how many children a Stage produces.
- Batch indexing. Documents are sent to the search backend in configurable batches with both size and timeout thresholds, amortizing network round-trips.
- Backpressure. The Publisher blocks the Connector when too many documents are in flight, preventing out-of-memory conditions without artificial throttling or unbounded queue growth.
8. Observability
Understanding system behavior requires no custom instrumentation by the user.
- Per-component metrics. Each component reports throughput and latency continuously during execution via a shared MetricRegistry.
- Per-stage timing. Processing time is measured per Stage, so pipeline bottlenecks can be identified without adding instrumentation to stage code.
- Run summary. A structured summary at the end of each run shows documents succeeded, failed, dropped, and total elapsed time.
- Inspectable data in flight. Documents on Kafka topics are human-readable JSON. An administrator can inspect any topic with standard Kafka tooling.
- Per-document tracing. The run ID and document ID are pushed into the SLF4J MDC, so every log line emitted while processing a document includes its identity.
- Heartbeat/liveness. Components report liveness for container orchestrators to verify health.
9. Testability
Testing a pipeline requires no external infrastructure — no Kafka, no search engine, no database.
- Test mode (RunType.TEST). The full pipeline runs end-to-end with in-memory messaging and a bypassed indexer. The real framework components execute — real queues, real pipeline processing, real document routing.
- Complete document history. A TestMessenger captures every document published, every document sent for indexing, and every lifecycle event. Test code asserts against this history.
- No external infrastructure. Tests run with in-memory queues. The mocking focuses on external services (source systems, search backends); the Lucille components themselves are real.
10. Configuration-driven operation
Changes are possible by editing a configuration file alone, without rebuilding the project.
- HOCON format. Comments, relaxed syntax, and human-readable structure. Any valid JSON is also valid HOCON.
- Environment variable substitution. Credentials and environment-specific values are injected via
${?ENV_VAR} syntax without code changes. - Config composition (includes). Shared settings — connection strings, common pipeline fragments — are defined once and included everywhere.
- Pre-run validation (SPEC system). Every component declares its expected configuration. All errors are reported at once before execution starts, so a developer can fix all issues in a single pass.
- Config rendering (-render). The fully resolved config can be printed for debugging, showing what values Lucille will actually see at runtime.
- Validation without execution (-validate). Configuration can be checked in CI pipelines without running an ingest.
- Conditional stage execution. Stages execute only when the document meets criteria specified in configuration (field presence, field value, combinations). The framework handles this — stage authors never implement conditional logic.
- Connector lifecycle. Connectors have pre-execution, execution, post-execution, and close phases with defined error semantics.
11. Optimistic error handling
The guiding principle: get as much data into the search engine as possible, but stop as soon as possible if you’d only be wasting your time.
- Per-document error handling. Exceptions during document processing fail the individual document without stopping the ingest. The document is routed to a failure state; other documents continue flowing.
- Structural error fast-fail. Invalid configuration, failed component initialization, or an unreachable search backend cause immediate termination rather than processing documents that can never be indexed.
12. Resilience
The system should recover from failures without losing work and prevent resource exhaustion proactively.
- Poison pill detection. Documents that repeatedly cause a Worker process to crash are detected (via a distributed retry counter) and routed to a Dead Letter Queue after a configurable retry limit.
- Crash resilience. Uncompleted work is resumed when a component restarts, or picked up by another instance via Kafka’s consumer group protocol. Work is not silently dropped.
- Graceful shutdown. Signal handling (SIGINT/SIGTERM) cleanly stops all components — the Connector stops publishing, Workers drain remaining documents, the Indexer flushes its current batch, and the process exits with a run summary.
- Backpressure. The Publisher blocks the Connector when too many documents are in flight or when queues are full, preventing unbounded growth that leads to out-of-memory crashes.
- Indexer retry with exponential backoff. Failed batch calls are retried with configurable maximum attempts, initial wait duration, and retryable status codes. Non-retryable failures fail immediately.