Architecture
Understanding Lucille’s core components, topology, and design.
Lucille separates the three concerns of ETL — reading, transforming, and writing — into distinct components that run concurrently.
Start Here
| Section | What It Covers |
|---|
| Overview | The problem Lucille solves, the core architecture, topology, document lifecycle, and design rationale |
| Components | Reference pages for each component (Document, Pipeline, Publisher, Connectors, Indexers, Stages, Config) |
How Components Interact
The components communicate through queues. In local mode these are in-memory LinkedBlockingQueue instances. In distributed mode they are Kafka topics. The component code is identical in both cases — only the messenger implementation changes.
Connector → [processing queue] → Worker(s) → [indexing queue] → Indexer
↓ events ↑
Publisher (run accounting)
For in-depth explanations of how each subsystem works internally, see Internals.
1 - 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.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.
1.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.
1.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.
1.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.

1.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.
1.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.
2 - Components
Conceptual guide to the core components of Lucille and how they work together.
Lucille is built from a small set of components that work together to move data from source systems into a search backend.
| Component | Role |
|---|
| Connectors | Read data from a source system and emit Documents into the pipeline. |
| Stages | Process and enrich Documents. Composed into Pipelines. |
| Indexers | Batch processed Documents and send them to the search backend. |
| Pipeline | An ordered sequence of Stages applied to each Document. |
| Worker | Pulls Documents from the source queue, runs them through the Pipeline, and forwards results. |
| Publisher | Tracks every Document from publication to terminal state; provides backpressure. |
| Runner | Orchestrates a complete run: validates config, starts components, waits for completion. |
| Document | The basic unit of data flowing through the system. |
| Config | Why Lucille is configuration-driven, and how HOCON and Typesafe Config shape the system. |
For a catalogue of specific implementations — built-in connectors, stages, indexers, file handlers, and plugins — see the Ingest Design section.
2.1 - Document
The basic unit of data that is sent through a Pipeline and eventually indexed into a search engine.
Search engines are designed to handle messy, incomplete, heterogeneous, loosely structured data. The basic unit of data in a search engine is typically called a “document” — it is simply a set of named fields, where each field may hold a single value or a list of values.
In Lucille, a Document is the basic unit of data that flows through a pipeline and gets indexed. Lucille’s Document aims to be as close to a search engine document as possible. The idea is that you don’t want to wait until the last minute to convert your data into a search-engine-friendly representation; you want to start with a search-engine-friendly representation the moment you acquire data from the source system, and use that representation throughout the entire enrichment pipeline. This means no intermediate object model, no mapping layer at the end — just fields in, fields out.
Why not POJOs?
If you’re coming from a background where encapsulation and strong typing are second nature, your first instinct might be to define POJOs for each entity type — a Product, a SupportTicket, a LegalDocument — with transformation logic behind well-named methods. That approach works when your domain has a small number of well-understood record types with stable schemas.
Search ingestion rarely looks like that. A typical project pulls data from multiple source systems, each with its own schema. The fields vary wildly from one record to the next — a database row has structured columns, a PDF has extracted text and metadata, a JSON API response has nested objects. Even within a single source, records are often inconsistent: optional fields that are sometimes present and sometimes not, multi-valued fields with unpredictable cardinality, fields whose meaning changes depending on the record type. Trying to capture all of this in a POJO hierarchy quickly becomes impractical — you end up with dozens of classes, most of which are just bags of optional fields, and the type system works against you rather than for you.
The pragmatic alternative is a generic map — something like Map<String, Object>. This gives you the flexibility to handle arbitrary fields, but at a cost: no typed access, no distinction between single-valued and multi-valued fields, verbose null-checking on every read, manual serialization logic at every boundary, and no built-in support for the update patterns (overwrite, append, skip) that search ingestion requires constantly.
Lucille’s Document is the middle ground. It has the flexibility of a map — any field name, any number of fields, no fixed schema — but with a purpose-built API that eliminates the boilerplate. Typed getters, uniform single/multi-valued access, update modes as first-class operations, and zero-cost JSON serialization because the document is already in the format that search engines expect. You get the adaptability of a schemaless representation without giving up the ergonomics of a well-designed API.
2.1.1 - Overview
Document structure, field types, reading and writing fields, child documents, and serialization.
A Document is an ordered, named set of fields. Each field may hold a single value or a list of values (multi-valued). All field values are ultimately represented in JSON. Every Document has a unique id.
Creating a Document
Use the static factory methods on the Document interface:
// Create with an explicit ID
Document doc = Document.create("my-doc-123");
// Create with an auto-generated UUID
Document doc = Document.create();
Connectors typically create Documents and call publisher.publish(doc) to send them into the pipeline.
Reserved Fields
Lucille reserves several field names for internal use. Do not use these for application data.
| Field | Description |
|---|
id | The unique document ID. Immutable once set. |
run_id | The run ID stamped by the Publisher. Immutable once set. |
___children | Internal: tracks child documents generated in the pipeline. |
___dropped | Set to true when a document is dropped (not sent to the Indexer). |
___skipped | Set to true when a document should bypass Stages but still reach the Indexer (used for deletions). |
Field Types
Lucille Documents support the following value types:
StringBooleanIntegerDoubleFloatLongjava.time.Instantbyte[]com.fasterxml.jackson.databind.JsonNodejava.sql.Timestampjava.util.Date
Reading Fields
Single-Valued Access
String title = doc.getString("title");
int count = doc.getInt("count");
double score = doc.getDouble("score");
float weight = doc.getFloat("weight");
long size = doc.getLong("size");
boolean flag = doc.getBoolean("active");
Instant ts = doc.getInstant("created_at");
byte[] raw = doc.getBytes("content");
JsonNode json = doc.getJson("metadata");
Multi-Valued Access
These methods also wrap a single value in a list if needed:
List<String> titles = doc.getStringList("title");
List<Integer> counts = doc.getIntList("counts");
List<Double> scores = doc.getDoubleList("scores");
List<Long> sizes = doc.getLongList("sizes");
Checking Field Existence
if (doc.has("title")) {
String t = doc.getString("title");
}
Writing Fields
setField — Overwrite
Replaces any existing value(s) and makes the field single-valued:
doc.setField("title", "Hello World");
doc.setField("count", 42);
doc.setField("active", true);
doc.setField("score", 0.95);
doc.setField("created_at", Instant.now());
addToField — Append
Appends a value, converting the field to multi-valued if it was single-valued:
doc.addToField("tags", "search");
doc.addToField("tags", "etl");
// tags is now ["search", "etl"]
setOrAdd — Create or Append
Creates the field as single-valued if it does not exist; appends if it does:
doc.setOrAdd("tags", "search");
doc.setOrAdd("tags", "etl");
update — Controlled Write with UpdateMode
The update method accepts an UpdateMode enum that covers the three most common write patterns:
import com.kmwllc.lucille.core.UpdateMode;
// OVERWRITE: first value replaces all existing values; additional values are appended
doc.update("title", UpdateMode.OVERWRITE, "New Title");
// APPEND: all values are appended (field becomes or remains multi-valued)
doc.update("tags", UpdateMode.APPEND, "search", "etl");
// SKIP: field is left unchanged if it already has a value
doc.update("title", UpdateMode.SKIP, "Default Title");
Nested JSON
Documents support reading and writing values within nested JSON objects and arrays using dot-path notation (e.g., "metadata.author.name") or structured List<Document.Segment> paths.
Reading Nested Values
JsonNode node = doc.getNestedJson("metadata.author.name");
// Or using structured path segments
List<Document.Segment> path = Document.Segment.parse("metadata.items[2].title");
JsonNode node2 = doc.getNestedJson(path);
Writing Nested Values
ObjectMapper mapper = new ObjectMapper();
doc.setNestedJson("metadata.score", mapper.valueToTree(0.95));
Removing Nested Values
doc.removeNestedJson("metadata.tempField");
Path Segments
List<Document.Segment> segments = Document.Segment.parse("a.b[2].c");
String path = Document.Segment.stringify(segments); // "a.b[2].c"
Dropping and Skipping
Dropping removes the document from the pipeline entirely. It will not reach the Indexer.
Use the DropDocument Stage in config, or call setDropped() in stage code:
Skipping causes the document to bypass all downstream Stages but still reach the Indexer. This is used for deletion markers, so the Indexer can issue a delete against the search backend.
Use the SkipDocument Stage in config, or call setSkipped() in stage code:
Child Documents
A Stage may generate child documents — additional Documents that flow through the remaining pipeline stages as independent records and are indexed alongside the parent. A Stage returns children from processDocument() as an Iterator<Document>.
Children are always emitted before the parent document, ensuring the Publisher’s accounting registers child IDs before it sees the parent’s completion event.
Iterating Fields
for (String fieldName : doc) {
// iterate over all field names in the Document
}
Serialization
Documents serialize to and from JSON. In Kafka-distributed mode, Documents flow between components as JSON bytes. The id and run_id are always included.
String json = doc.toString();
2.1.2 - Document IDs
Why IDs must be deterministic, how Lucille handles duplicates, and the idOverrideField mechanism.
Why IDs Must Be Deterministic
In search ingestion, the document ID serves as the primary key in the search index. When you index a document with ID “ABC”, the search engine either creates a new record or updates the existing record with that ID (upsert semantics). This has a critical implication:
If you run the same ingest twice, you want the same documents to get the same IDs. Otherwise, the second run creates duplicates instead of updating existing records. A search index with 100,000 documents would become 200,000 documents after a re-run — all duplicates.
Deterministic IDs mean:
- Re-ingestion is safe. Running the pipeline again updates existing documents rather than creating duplicates.
- Incremental updates work. A document that changes in the source system gets re-indexed under the same ID, replacing the old version.
- Deletions are possible. To delete a document from the index, you need to know its ID. If the ID was random, you’d have no way to reference it later.
- Parent-child relationships are stable. Child documents reference their parent by ID. If the parent’s ID changes on re-run, the relationship breaks.
This is why Connectors are responsible for creating documents with meaningful, stable IDs derived from the source data — a file path, a database primary key, a URL — rather than random UUIDs.
Why IDs Are Immutable in Lucille
Once a Document is created with an ID, that ID cannot be changed through the normal Document API. The id field is in the RESERVED_FIELDS set, so setField("id", ...), addToField("id", ...), and renameField("id", ...) all throw IllegalArgumentException.
This immutability exists because the ID is used as a tracking key throughout the system:
- The Publisher registers the ID in its accounting ledger when the document is published. If the ID changed mid-pipeline, the Publisher would never receive a terminal event for the original ID — the run would hang.
- The Worker sends CREATE events for child documents using their IDs. If a child’s ID changed after the CREATE event was sent, the Publisher’s accounting would be corrupted.
- The Kafka message key is the document ID. Changing the ID mid-pipeline would break ordering guarantees (the document would land on a different partition after the change).
- The Indexer uses the ID to send upserts and deletions to the search backend. The ID must be the same value that was tracked through the entire pipeline.
In short: the ID is the document’s identity across all components. Mutating it would break accounting, ordering, and idempotency simultaneously.
How Lucille Handles Duplicate IDs
Duplicate IDs — multiple documents with the same ID published in the same run — are a legitimate scenario. They arise in CDC (Change Data Capture) scenarios where a source system emits multiple updates for the same record, or when a connector reads from a source that contains duplicate entries.
Lucille handles duplicates at two levels:
At the Publisher Level: The Bag Data Structure
The Publisher’s docIdsToTrack is a Bag<String> (multiset), not a Set<String>. If two documents with ID “doc-1” are published, the Bag count for “doc-1” becomes 2. The Publisher expects to receive two separate terminal events for that ID. Each terminal event decrements the count by one. The run is not considered complete until the count reaches zero for all IDs.
// Two documents with same ID published → bag count is 2
docIdsToTrack.add("doc-1"); // count: 1
docIdsToTrack.add("doc-1"); // count: 2
// First terminal event → count drops to 1
docIdsToTrack.remove("doc-1", 1); // count: 1
// Second terminal event → count drops to 0
docIdsToTrack.remove("doc-1", 1); // count: 0, now removed
This means duplicate IDs don’t corrupt the accounting — each published document is tracked independently even if it shares an ID with another.
At the Publisher Level: Collapsing Mode
For connectors that emit multiple consecutive documents with the same ID (common in CDC), the Publisher supports a collapsing mode (requiresCollapsingPublisher() = true). In this mode, consecutive same-ID documents are merged into a single document via setOrAddAll() before being sent for processing:
if (previousDoc.getId().equals(document.getId())) {
previousDoc.setOrAddAll(document); // merge fields into one document
} else {
sendForProcessing(previousDoc); // different ID — send the previous one
previousDoc = document; // hold the new one
}
This reduces N consecutive same-ID documents to one document with multi-valued fields, which is then processed and indexed once. numReceived counts all N inputs; numPublished counts only the single merged output.
At the Indexer Level: Upsert Semantics
When two documents with the same ID reach the Indexer (either because collapsing is not enabled, or because they were non-consecutive), the search engine’s upsert semantics handle it: the second document overwrites the first in the index. The final state of the index reflects the last document indexed with that ID. Combined with Lucille’s ordering guarantees (same ID → same partition → same consumer → sequential processing), this means the final state is deterministic.
At the Indexer Level: Ordering Within a Batch
If a batch contains both an upsert and a delete for the same ID, the Indexer implementations (SolrIndexer, OpenSearchIndexer) explicitly handle ordering — flushing pending upserts before processing a delete for the same ID, or vice versa. This ensures that the operations are applied in the correct sequence even within a single batch.
The idOverrideField: Decoupling Internal ID from Index ID
Sometimes the ID used internally for tracking is not the ID you want in the search index. For example:
- A Connector might use a composite key (source + record number) for tracking uniqueness, but the search index expects a simpler ID.
- A pipeline might compute a better ID during enrichment (e.g., hashing certain fields to create a deduplication key).
- A document might need different IDs in different destination indices.
Lucille solves this with indexer.idOverrideField — a configuration option that tells the Indexer to use a different field’s value as the document’s ID when sending to the search backend, without modifying the document’s internal ID:
indexer {
idOverrideField: "computed_id"
}
How It Works
- The Connector creates the document with a stable internal ID (e.g.,
"source-record-42") - A pipeline Stage computes a better ID and stores it in a field (e.g.,
doc.setField("computed_id", "hash-abc123")) - The document flows through the system tracked by its internal ID (
"source-record-42") - At indexing time, the Indexer calls
getDocIdOverride(doc) which returns "hash-abc123" - The document is sent to the search backend with ID
"hash-abc123"
What Uses Which ID
The internal ID ("source-record-42") is used for:
- Publisher accounting
- Kafka message key (ordering)
- Event tracking (CREATE, FINISH, FAIL)
- Logging and debugging
The override ID ("hash-abc123") is used only for:
- The document’s ID in the search index
This separation means the pipeline’s internal correctness guarantees (ordering, accounting, fault tolerance) are never affected by what ID appears in the search index. The override is applied at the very last moment — after all tracking is complete — as a pure presentation concern.
ID Generation Strategies: Good and Bad
How Existing Connectors Generate IDs
FileConnector: MD5 hash of the full file path.
String docId = DigestUtils.md5Hex(fullPath);
Document doc = Document.create(StorageClient.createDocId(docId, params));
The FileConnector uses the MD5 hash of the file’s full URI (e.g., s3://bucket/path/to/file.pdf) as the document ID. This is a good strategy because:
- It’s deterministic — the same file always produces the same ID
- It’s stable across re-runs — re-ingesting the same file updates rather than duplicates
- It handles special characters — file paths with spaces, unicode, or long lengths are reduced to a fixed-length hex string that’s safe for any search engine
- It’s unique — different file paths produce different hashes (collision probability is negligible)
The docIdPrefix is then prepended via StorageClient.createDocId(), producing IDs like "file-a1b2c3d4e5f6...".
DatabaseConnector: Value from a configured ID column.
String id = createDocId(rs.getString(idColumn));
Document doc = Document.create(id);
The DatabaseConnector reads the ID from a column specified in config (idField). This is the natural choice for database sources because:
- The database already has a primary key that uniquely identifies each record
- It’s deterministic and stable across re-runs
- It matches what the user expects — the search index ID corresponds to the database primary key
ChunkText: Parent ID + chunk number.
String id = parentId + "-" + (i + 1);
Document childDoc = Document.create(id);
Child documents derive their IDs from the parent ID plus a positional suffix. This ensures:
- Children have unique IDs (parent ID is unique, suffix is unique within the parent)
- The relationship to the parent is visible in the ID itself
- Re-chunking the same parent produces the same child IDs (deterministic)
Good ID Strategies
| Strategy | When to Use | Example |
|---|
| Database primary key | Source has a natural unique key | "customer-42", "order-10051" |
| File path (or hash of it) | File-based sources | md5("s3://bucket/file.pdf") |
| URL | Web crawling | md5("https://example.com/page") |
| Composite key | Multiple fields needed for uniqueness | "source-table-pk" → "crm-accounts-42" |
| Parent ID + suffix | Child documents | "doc-123-chunk-1", "doc-123-chunk-2" |
| Content hash | Deduplication across sources | md5(title + body) |
Bad ID Strategies
| Strategy | Why It’s Bad |
|---|
UUID.randomUUID() | Not deterministic — re-running creates duplicates in the index |
| Auto-incrementing counter | Not stable — if source order changes, IDs shift; not unique across runs |
| Timestamp | Not unique if two documents are created in the same millisecond |
| Row number in result set | Changes if query order changes or rows are added/deleted |
| Mutable source field | If the field changes in the source, the document gets a new ID and the old one becomes an orphan in the index |
When Random UUIDs Are Acceptable
There is one scenario where random UUIDs are acceptable: when the index is always rebuilt from scratch (full re-index, not incremental). If you drop and recreate the index on every run, duplicate IDs from re-runs are not a concern because the old index is gone. However, this limits you to full batch mode and prevents incremental updates.
Hashing as an ID Strategy
Hashing (MD5, SHA-256) is useful when:
- The natural key is too long for a search engine ID field (some have length limits)
- The natural key contains characters that are problematic in URLs or APIs
- You want to deduplicate across sources (hash the content, not the source path)
The FileConnector’s use of DigestUtils.md5Hex(fullPath) is a good example. The tradeoff is that the ID is opaque — you can’t look at it and know which file it represents. The FileConnector mitigates this by also storing the full path in a file_path field on the document.
The docIdPrefix: Namespacing IDs by Connector
When multiple connectors feed documents into the same index, their IDs might collide. A database connector and a file connector might both produce a document with ID “1”. The docIdPrefix configuration on a connector prepends a string to all document IDs it creates:
connectors: [
{
name: "database"
class: "com.kmwllc.lucille.connector.DatabaseConnector"
docIdPrefix: "db-"
# produces IDs like "db-1", "db-2", ...
},
{
name: "files"
class: "com.kmwllc.lucille.connector.FileConnector"
docIdPrefix: "file-"
# produces IDs like "file-/path/to/doc.pdf", ...
}
]
The prefix is applied by the connector when creating documents via AbstractConnector.createDocId(id). It becomes part of the document’s immutable ID from that point forward.
Summary
| Concern | Mechanism |
|---|
| Deterministic IDs | Connector derives ID from source data |
| ID immutability | Reserved field protection in Document API |
| Duplicate ID accounting | Bag (multiset) in Publisher, not Set |
| Consecutive duplicate merging | Collapsing mode in Publisher |
| Non-consecutive duplicates | Search engine upsert semantics + ordering guarantees |
| Same-ID operations in one batch | Explicit ordering logic in Indexer implementations |
| Different ID in search index | indexer.idOverrideField |
| ID collision across connectors | docIdPrefix on each connector |
2.1.3 - Document Model
Why the Document is backed by a Jackson ObjectNode, the API design choices, and the tradeoffs.
Why the Document API Matters
At first glance, the idea of a “document” in a search ingestion system seems simple: it’s a bag of named fields with values. A Map<String, Object> would seem to suffice. In practice, a well-designed Document API turns out to be one of the most important practical considerations in a search ETL framework, for reasons that only become apparent when you’ve written dozens of pipeline stages and dealt with the realities of search engine field models.
Every pipeline stage reads fields, transforms them, and writes results back. A stage might be three lines of logic surrounded by ten lines of field access boilerplate — checking if a field exists, handling null, deciding whether to overwrite or append, converting types, dealing with single-valued vs. multi-valued fields. If the Document API is clumsy, that boilerplate dominates every stage you write. If the API is well-designed, stages are concise and the intent is clear.
Lucille’s Document API is the result of iterating on this problem across many real-world pipelines. Every method exists because a common pattern in search ingestion code demanded it.
Matching the Search Engine’s Field Model
Search engines (Solr, Elasticsearch, OpenSearch) have a field model that differs from a typical programming language map in important ways:
Single-valued vs. multi-valued fields. In a search engine schema, a field can hold one value or a list of values. A document might have a single title but multiple tags. This distinction matters for how the field is indexed, how it’s queried, and how it’s displayed. A Map<String, Object> does not capture this distinction — is the value a String or a List<String>? You’d need to check at every access point.
Lucille’s Document makes this explicit:
getString("title") returns the single value (or the first value if multi-valued).getStringList("tags") returns all values as a list (wrapping a single value in a list if necessary).setField("title", "Hello") creates a single-valued field.addToField("tags", "search") converts the field to multi-valued if it wasn’t already.setOrAdd("tags", "etl") creates the field as single-valued if absent, or appends if present.
This mirrors exactly how search engines think about fields. A stage author doesn’t need to write if (value instanceof List) checks — the API handles the single/multi distinction uniformly.
The three update patterns. When a stage writes to a field, there are exactly three things it might want to do:
- Overwrite whatever was there before.
- Append to whatever was there (creating a multi-valued field).
- Skip — write only if the field doesn’t already exist (don’t clobber earlier enrichment).
These three patterns appear so frequently in search ingestion code that Lucille provides them as a first-class UpdateMode enum, usable with the update() method:
doc.update("title", UpdateMode.OVERWRITE, "New Title");
doc.update("tags", UpdateMode.APPEND, "tag1", "tag2");
doc.update("summary", UpdateMode.SKIP, "Default summary");
The update() method also accepts varargs, so a stage can write multiple values in a single call. Without this, every stage would implement its own if/else logic for these three cases — and get it subtly wrong in edge cases (e.g., forgetting to convert a single-valued field to multi-valued before appending).
Why JSON Backing (ObjectNode) Is the Right Choice
Lucille’s Document is implemented as a thin wrapper around a Jackson ObjectNode. This is not an obvious choice — a HashMap<String, Object> would be simpler to implement. The JSON backing is motivated by the realities of how documents flow through the system.
Documents cross boundaries constantly in Lucille: they’re placed on queues (in-memory or Kafka), sent to search backends via bulk APIs, logged for debugging, and captured in test mode for assertions. Every boundary crossing requires serialization.
With a JSON-backed document, serialization is trivial: data.toString() produces valid JSON. No conversion step, no schema, no type registry.
Critically, JSON’s type system eliminates the need to store explicit type information with each field. Jackson’s ObjectNode stores values as typed nodes — TextNode, IntNode, BooleanNode, ArrayNode, etc. When serialized to JSON, the types are implicit in the syntax:
"title": "Hello" — string (quoted)"count": 42 — integer (unquoted number)"active": true — boolean (literal)"tags": ["a", "b"] — array (brackets)
When deserialized, Jackson reconstructs the correct node types from the JSON syntax. No type annotations, no class names in the serialized form, no versioning concerns. Compare this to Java serialization or a HashMap-based approach where you’d need to store type discriminators alongside values to reconstruct them correctly on the other end.
This matters operationally: documents on Kafka topics are human-readable JSON. An administrator can inspect them with standard Kafka tooling and understand what they contain without a decoder ring.
Zero-Cost Boundary Crossings
Because the document is JSON internally, there is no impedance mismatch at any boundary:
- From a JSON source (file, HTTP response, Kafka message): Parse the JSON into an ObjectNode and it becomes the Document’s backing store directly. No field-by-field copying.
- To a search engine: The bulk API for Elasticsearch, OpenSearch, and Solr all accept JSON. The document is already in the right format.
- To Kafka: Serialize the ObjectNode to a string. Done.
- From Kafka: Parse the string back to an ObjectNode. Done.
- In test assertions:
document.toString() gives you the complete state as readable JSON.
A HashMap-backed document would require a serialization pass at every one of these boundaries — and in a system where a document crosses 4+ boundaries (source → processing queue → worker → indexing queue → indexer → search backend), that overhead is significant.
Native Nested Structure Support
Modern search ingestion often involves complex nested data: JSON responses from HTTP enrichment stages, structured extraction results from LLMs, nested document schemas in Elasticsearch. An ObjectNode naturally represents nested JSON (objects within objects, arrays of objects).
Lucille’s getNestedJson/setNestedJson API works directly on the tree structure:
// Read a nested value
JsonNode author = doc.getNestedJson("metadata.author.name");
// Set a nested value (creates intermediate objects as needed)
doc.setNestedJson("metadata.source.url", TextNode.valueOf("https://..."));
// Array indexing
JsonNode thirdTag = doc.getNestedJson("results[0].metadata.tags[2]");
With a HashMap, nested access would require ((Map) ((Map) map.get("metadata")).get("author")).get("name") — casting at every level, null-checking at every level, and no type safety. The Jackson tree API provides a typed, null-safe traversal.
Interop with the Jackson Ecosystem
Stages that call external APIs (HTTP enrichment, LLM calls, search engine queries) typically use HTTP clients that return Jackson JsonNode objects. These can be stored directly on the Document without conversion:
JsonNode apiResponse = httpClient.get(url); // returns JsonNode
doc.setField("enrichment_result", apiResponse); // stored directly
Similarly, Lucille’s JSONata transformation support operates on the Jackson tree natively — the Document’s backing ObjectNode is the input to the JSONata expression, and the result is written back as a JsonNode.
The Tradeoffs
The JSON backing is not free:
Per-field access overhead. Getting a String from an ObjectNode means node.get("field").asText() rather than (String) map.get("field"). The Document API hides this behind typed getters, but the implementation does more work per access than a HashMap. For stages that read many fields in a tight loop, this is measurably slower.
Memory overhead. Each field value is wrapped in a JsonNode subclass (TextNode, IntNode, etc.) rather than stored as a raw Java object. For documents with many small fields, the per-field wrapper overhead adds up.
No arbitrary Java types. A HashMap can store any Java object. An ObjectNode can only store JSON-representable types. Lucille works around this (byte arrays are stored as base64-encoded binary nodes, Instants are stored as ISO-8601 strings), but the Document cannot natively hold arbitrary domain objects.
Deep copy cost. Copying a document requires objectNode.deepCopy(), which recursively copies the entire JSON tree. A HashMap with immutable String values would be cheaper to shallow-copy.
In practice, these tradeoffs are acceptable because pipeline stages typically read a small number of fields, do expensive work (API calls, model inference, text processing), and write a small number of fields. The per-access overhead is negligible relative to the actual enrichment work. The serialization savings at every boundary crossing more than compensate.
Notable Design Choices in the API
The getStringList() method returns a List<String> regardless of whether the field is single-valued or multi-valued. If the field is single-valued, it wraps the value in a singleton list. This means a stage that processes “all values of a field” doesn’t need to check whether the field is single or multi-valued first — it can always iterate over the list.
Conversely, getString() always returns the first value, whether the field is single or multi-valued. A stage that only cares about the primary value doesn’t need to handle the list case.
The setOrAdd Pattern
setOrAdd() is a single method that handles the most common field-writing pattern in search ingestion: “if this field doesn’t exist yet, create it; if it does, append to it.” Without this, every stage that accumulates values would need:
if (doc.has("tags")) {
doc.addToField("tags", newTag);
} else {
doc.setField("tags", newTag);
}
With setOrAdd, it’s one line: doc.setOrAdd("tags", newTag). Across a pipeline with dozens of stages, this eliminates hundreds of lines of boilerplate.
Typed Getters with Null Semantics
Every getter returns null for both “field absent” and “field present but null.” The has() method distinguishes between these cases when it matters. This is a deliberate choice: most stage logic doesn’t care why a value is missing — it just needs to handle the missing case. The rare stage that needs to distinguish “field not set” from “field explicitly null” can use has() + hasNonNull().
Reserved Fields with Triple-Underscore Prefix
Internal control fields (___dropped, ___skipped, ___children) use a triple-underscore prefix. This keeps them out of the normal field namespace — a triple-underscore prefix is highly unlikely to collide with any user-defined field name — and makes them visually distinct from user data fields. The validateFieldNames() method prevents stages from accidentally writing to reserved fields by checking membership in RESERVED_FIELDS.
Field Name Validation on Every Write
Every setter calls validateFieldNames() before writing. This catches two classes of bugs immediately:
- Attempting to write to a reserved field (like
id or run_id). - Passing a null or empty field name.
The validation happens at write time, not at indexing time, so bugs are caught in the stage that caused them rather than surfacing later in the pipeline.
Insertion Order Preservation
ObjectNode uses a LinkedHashMap internally, so getFieldNames() returns fields in insertion order. This is a subtle but useful property: when a document is serialized to JSON, the fields appear in the order they were added. This makes debugging easier (the ID is always first, enrichment fields appear in pipeline order) and produces deterministic output for testing.
JSONata Integration
The transform() method applies a JSONata expression directly to the Document’s backing ObjectNode. JSONata is a query and transformation language for JSON — think of it as XPath/XSLT for JSON. Because the Document is already JSON, there’s no conversion step. A stage can reshape a document’s structure with a single expression:
Jsonata expr = Jsonata.jsonata("{ 'fullName': firstName & ' ' & lastName }");
doc.transform(expr);
This is particularly powerful for stages that need to restructure complex nested data without writing procedural Java code.
The asMap() Escape Hatch
asMap() converts the Document to a Map<String, Object> using Jackson’s MAPPER.convertValue(). This is the escape hatch for code that needs a plain Map — typically when interfacing with libraries that expect Map input. It’s deliberately not the primary API because it loses the typed access, the single/multi-valued distinction, and the zero-cost serialization. But it exists for interop.
Summary
Lucille’s Document API is designed around three principles:
- Match the search engine’s field model — single/multi-valued distinction, typed access, update modes that reflect how search fields are actually populated.
- Minimize serialization cost — JSON backing means zero-cost boundary crossings in a system where documents cross many boundaries.
- Eliminate stage boilerplate —
setOrAdd, update with UpdateMode, uniform list access, and null handling reduce the per-stage code that isn’t core logic.
The result is that a typical pipeline stage is a few lines of domain logic rather than a page of field-access ceremony. Across a pipeline with dozens of stages, this compounds into significantly less code, fewer bugs, and faster development.
2.2 - Connector
A component that retrieves data from a source system and packages the data into Documents in preparation for transformation.
What a Connector Does
A Connector is the component responsible for acquiring data from a source system and introducing it into Lucille as Documents. It is the entry point for all data in the system.
A Connector reads from its source — a database, a filesystem, a Kafka topic, an RSS feed, a search engine — and emits Documents one at a time by calling publisher.publish(doc). It does not know how many Workers will process those documents, how long enrichment will take, or where the documents will ultimately be indexed. Its only job is to produce Documents and hand them off.
Lifecycle
Every Connector goes through four lifecycle phases on each run:
preExecute(runId) — Called before execute. Use for setup: acquiring locks, creating temporary tables, validating source accessibility.execute(publisher) — The main phase. Read from the source and call publisher.publish(doc) for each Document.postExecute(runId) — Called only if execute succeeds. Use for cleanup: releasing locks, writing completion markers.close() — Always called, even on failure. Use for releasing resources.
This lifecycle is enforced by the framework. The separation of preExecute from execute allows setup that should not be repeated on retry. The guarantee that close() is always called — regardless of whether execute or postExecute threw — ensures resources are never leaked.
Sequential Execution
When multiple Connectors are defined in a single run, they execute in sequence. Each Connector runs to completion — all its documents processed and indexed — before the next begins. This ordering guarantee is enforced automatically by the Publisher’s accounting system, without external orchestration.
This enables patterns like indexing parent documents before child documents that reference them by ID, or running a full ingest followed by a deletion pass.
Decoupling from Downstream
A Connector is fully decoupled from the rest of the system. It does not know:
- How many Worker threads or processes will consume its output
- What pipeline will be applied to its documents
- Which search backend the documents will reach
- Whether the system is running in local or distributed mode
This decoupling is what allows the same Connector implementation to work identically in all deployment modes. The Connector publishes to a queue; everything downstream is the framework’s concern.
Practical Guide
For how to configure connectors — common parameters, config syntax, and the full catalogue of built-in connectors — see Connectors in the Ingest Designer Guide.
For how to build a custom Connector, see Developing Connectors.
2.3 - Publisher
Provides a way to publish Documents for processing by the pipeline, and tracks their lifecycle until completion.
The Publisher is the internal accounting system that tracks every Document from the moment it is submitted to the pipeline until it reaches a terminal state (indexed, failed, or dropped).
What the Publisher Does
- Accepts documents from a Connector via
publisher.publish(doc). - Stamps the run ID on each document before it enters the pipeline.
- Registers the document in its accounting ledger so it can track completion.
- Buffers documents on the source queue for Workers to consume.
- Receives events (FINISH, FAIL, DROP, CREATE) from Workers and the Indexer.
- Determines run completion when all submitted documents have reached a terminal state.
Run Completion
The Publisher declares a run complete only when all three of the following are simultaneously true:
- The Connector thread has finished publishing all documents.
- All document IDs in the accounting ledger have been accounted for (each has a terminal event).
- The event queue is drained (no more events arriving).
This ensures that even out-of-order events and child documents generated mid-pipeline are correctly accounted for before the run is declared complete.
The internal accounting ledger is a Bag (multiset), not a Set. This means a Connector can legitimately publish two documents with the same ID in a single run — each is tracked independently and must individually reach a terminal event before the run completes.
Document Lifecycle Events
| Event | Meaning |
|---|
CREATE | A child document was generated by a Stage and needs to be tracked. |
FINISH | A document was successfully indexed. |
FAIL | A document failed during pipeline processing or indexing. |
DROP | A document was explicitly dropped and will not be indexed. |
Backpressure
The Publisher implements backpressure to prevent a fast Connector from overwhelming the system:
- In local mode:
publisher.queueCapacity bounds the in-memory source and destination queues. publish() blocks when the queue is full. - In distributed mode:
publisher.maxPendingDocs blocks publish() when too many documents are in flight (pending completion).
publisher {
# Local mode: max docs in each queue (source and destination queues share this limit)
queueCapacity: 10000
# Distributed mode: block the Connector when this many docs are pending
maxPendingDocs: 80000
}
Collapsing Mode
When a Connector emits multiple consecutive Documents with the same user-visible ID (e.g., a CDC stream with multiple updates to the same record), the Publisher can merge them into a single Document with multi-valued fields before passing them to the pipeline. This is enabled by setting requiresCollapsingPublisher() to true in the Connector implementation.
numReceived counts every call to publisher.publish().numPublished counts only the documents actually sent downstream after collapsing.
Run Statistics
The Publisher tracks the following counts for each run:
| Stat | Description |
|---|
numPublished | Documents submitted to the pipeline (after collapsing). |
numReceived | Total calls to publish() (before collapsing). |
numPending | Documents currently in flight (submitted but not yet terminal). |
numSucceeded | Documents that reached the Indexer successfully. |
numFailed | Documents that failed during processing or indexing. |
numDropped | Documents explicitly dropped by a Stage. |
These are reported in the run summary at completion:
connector1: complete. 200000 docs succeeded. 0 docs failed. 0 docs dropped.
Pause and Resume
The Publisher supports pausing and resuming document publication. publish() blocks when paused and wakes when resume() is called. This is used internally in some specialized deployment patterns.
Event Handling in Distributed Mode
In local mode, events flow through an in-memory queue. In distributed mode, events flow through a dedicated Kafka event topic. The topic name is derived from the run ID, ensuring isolation between concurrent runs.
See Events for more details.
2.3.1 - Publisher Accounting
The Bag data structure, out-of-order event handling, the waitForCompletion loop, backpressure, and thread safety.
Overview
The Publisher is Lucille’s bookkeeper. It tracks every document from the moment it enters the system until it reaches a terminal state (indexed, failed, or dropped). This accounting is what allows the Runner to know when a connector’s work is truly complete.
The core implementation lives in PublisherImpl, which maintains an in-memory ledger of pending documents. By design, the Publisher does not remember all documents it has ever published — only those currently in-flight. This keeps memory bounded regardless of how many documents flow through the system.
The Central Data Structure: docIdsToTrack
private final Bag<String> docIdsToTrack = SynchronizedBag.synchronizedBag(new HashBag<>());
This is the Publisher’s primary ledger — a synchronized Bag<String> from Apache Commons Collections. Every document ID that is currently “in-flight” (published but not yet terminal) lives here.
Why a Bag Instead of a Set
A Bag (multiset) allows duplicate entries. This matters because the same document ID can legitimately appear multiple times in a single run. If a connector publishes two documents with ID “doc-1”, the Publisher expects to receive two separate terminal events for that ID. With a Set, removing the ID after the first terminal event would leave the second document untracked. With a Bag, each remove call decrements the count by one:
// Two documents with same ID published → bag count is 2
docIdsToTrack.add("doc-1"); // count: 1
docIdsToTrack.add("doc-1"); // count: 2
// First terminal event → count drops to 1
docIdsToTrack.remove("doc-1", 1); // count: 1
// Second terminal event → count drops to 0
docIdsToTrack.remove("doc-1", 1); // count: 0, now removed
The SynchronizedBag wrapper ensures thread safety since publish() and handleEvent() run on different threads.
The Secondary Ledger: docIdsIndexedBeforeTracking
private final Bag<String> docIdsIndexedBeforeTracking = SynchronizedBag.synchronizedBag(new HashBag<>());
This handles a race condition with child documents. When a Worker creates a child document during pipeline processing, two things happen asynchronously:
- A
CREATE event is sent to the Publisher (so it starts tracking the child) - The child is processed and eventually reaches a terminal state (
FINISH or FAIL)
These events can arrive out of order. If the terminal event arrives before the CREATE event, the Publisher can’t find the ID in docIdsToTrack. Rather than ignoring this, it records the ID in docIdsIndexedBeforeTracking. When the late CREATE event eventually arrives, the Publisher checks this secondary ledger first:
// In handleEvent(), when event.isCreate():
if (!docIdsIndexedBeforeTracking.remove(docId, 1)) {
docIdsToTrack.add(docId);
}
If the ID is found in docIdsIndexedBeforeTracking, the Publisher knows the child already completed — no need to start tracking it.
The waitForCompletion Polling Loop
This is the method that blocks the main thread until all work is done:
public PublisherResult waitForCompletion(ConnectorThread thread, int timeout) throws Exception {
while (true) {
Event event = messenger.pollEvent();
if (event != null) {
handleEvent(event);
}
// Three termination conditions:
if (!thread.isAlive() && !hasPending() && event == null) {
return new PublisherResult(!thread.hasException(), null);
}
}
}
The loop terminates when all three conditions are met simultaneously:
- Connector thread is dead (
!thread.isAlive()) — no more documents will be published - No pending documents (
!hasPending()) — every published document and child has reached a terminal state - Event queue is empty (
event == null) — the previous poll returned nothing, meaning no more events are in transit
Condition 3 is critical. Even if conditions 1 and 2 are met, there might be events still in the queue that would change the pending count (e.g., a CREATE event for a child that hasn’t been accounted for yet).
The messenger.pollEvent() call is a blocking operation with a timeout (typically 50ms for local, 2000ms for Kafka), preventing a busy-wait while still checking termination conditions periodically.
Thread Interaction: handleEvent() vs publish()
The Publisher is designed for concurrent access from two threads:
- Connector thread calls
publish() — adds IDs to docIdsToTrack - Main thread (in
waitForCompletion) calls handleEvent() — removes IDs from docIdsToTrack
Both methods mutate docIdsToTrack, which is why it must be a SynchronizedBag. The publish() method can also be called from multiple connector threads simultaneously (except in collapsing mode).
The maxPendingDocs Backpressure Mechanism
When configured, this prevents the connector from overwhelming downstream components:
private final ReentrantLock lockForPendingDocs = new ReentrantLock();
private final Condition pendingDocsBelowMaxCondition = lockForPendingDocs.newCondition();
In publish(), if the pending count exceeds the threshold, the calling thread blocks:
if (maxPendingDocs != null) {
lockForPendingDocs.lock();
while (docIdsToTrack.size() >= maxPendingDocs) {
pendingDocsBelowMaxCondition.await(10, TimeUnit.SECONDS);
}
lockForPendingDocs.unlock();
}
In handleEvent(), when a terminal event reduces the pending count below the max, blocked threads are signaled:
if (docIdsToTrack.size() < maxPendingDocs) {
pendingDocsBelowMaxCondition.signalAll();
}
The 10-second timeout on await() is a safety net — if a signal is somehow missed, the thread will re-check the condition periodically.
Important concurrency note: If N threads are blocked on publish() and the pending count drops to maxPendingDocs - 1, all N threads are signaled simultaneously. Each may then publish a document, causing the actual pending count to temporarily exceed maxPendingDocs by up to N-1. This is acceptable because each thread will block again on its next publish() call.
Collapsing Mode
When isCollapsing == true, consecutive documents with the same ID are merged into one:
private void publishInternal(Document document) throws Exception {
if (!isCollapsing) {
sendForProcessing(document);
return;
}
if (previousDoc == null) {
previousDoc = document;
return;
}
if (previousDoc.getId().equals(document.getId())) {
previousDoc.setOrAddAll(document); // merge fields
} else {
sendForProcessing(previousDoc);
previousDoc = document;
}
}
The Publisher holds onto the previous document. If the next document has the same ID, fields are merged. If the ID differs, the previous document is finally sent for processing. The flush() method handles the last held document.
Thread safety caveat: Collapsing mode is NOT thread-safe for multiple publishing threads because previousDoc is shared mutable state without synchronization.
numPublished vs numReceived
numReceived — incremented every time publish() completes (counts inputs)numPublished — incremented every time sendForProcessing() is called (counts outputs)
In non-collapsing mode, these are equal. In collapsing mode, numPublished <= numReceived because multiple inputs may collapse into one output.
Registration Ordering
A critical invariant: the document ID is added to docIdsToTrack before the document is placed on the processing queue:
private void sendForProcessing(Document document) throws Exception {
document.initializeRunId(runId);
String docId = document.getId();
// Track FIRST
docIdsToTrack.add(docId);
try {
// Send SECOND
messenger.sendForProcessing(document);
} catch (Exception e) {
// Rollback tracking if send fails
docIdsToTrack.remove(docId, 1);
throw e;
}
numPublished.incrementAndGet();
}
If the order were reversed (send first, then track), a fast Worker could process the document and emit a terminal event before the Publisher starts tracking it. The event would then be misclassified as “early” and placed in docIdsIndexedBeforeTracking, corrupting the accounting.
Pause/Resume Mechanism
The Publisher supports pausing all publishing threads:
private final ReentrantLock lockForPauseResume = new ReentrantLock();
private volatile Condition resumeCondition = null;
pause() creates a Condition object. Any thread calling publish() checks for this condition and blocks if it’s set:
if (resumeCondition != null) {
lockForPauseResume.lock();
if (resumeCondition != null) { // double-check after acquiring lock
while (resumeCondition != null) {
resumeCondition.await(); // loop handles spurious wakeups
}
}
lockForPauseResume.unlock();
}
resume() signals all waiting threads and nulls out the condition. The double-checked locking pattern (check volatile field, then acquire lock and re-check) avoids lock contention in the common case where the Publisher is not paused.
Thread Safety Summary
| Field | Protection | Accessed By |
|---|
docIdsToTrack | SynchronizedBag | publish thread(s) + event handling thread |
docIdsIndexedBeforeTracking | SynchronizedBag | event handling thread only (in practice) |
numReceived | AtomicLong | multiple publish threads |
numPublished | AtomicLong | multiple publish threads |
numCreated/Failed/Succeeded/Dropped | unsynchronized long | event handling thread only |
previousDoc | none (collapsing mode is single-thread only) | single publish thread |
maxPendingDocs blocking | ReentrantLock + Condition | publish thread(s) + event thread |
pause/resume | ReentrantLock + volatile Condition | publish thread(s) + external caller |
firstDocStopWatch | volatile + synchronized block | publish thread(s) |
timerContext | ThreadLocal | per-thread |
2.4 - Event
As Lucille runs, it generates Events that track document lifecycle and enable run completion accounting.
Lucille Events
As a Document passes through the Lucille pipeline, Event messages are generated at key transitions. The Publisher consumes these events to track the lifecycle of every document in the run and determine when all work is complete.
Event Types
| Event | Who Sends It | Meaning |
|---|
CREATE | Worker (on behalf of a Stage) | A child document was generated inside the pipeline and must be tracked. |
FINISH | Indexer | A document was successfully sent to the search backend. |
FAIL | Worker or Indexer | A document failed during processing or indexing. |
DROP | Worker | A document was explicitly dropped and will not be indexed. |
Events include the document’s id and run_id, allowing the Publisher to match each event to its corresponding accounting entry.
Event Flow
Worker → [event queue] → Publisher
Indexer → [event queue] → Publisher
In local mode, events flow through an in-memory queue on the main thread’s polling loop.
In distributed mode, events flow through a dedicated Kafka event topic. The topic name is derived from the run ID, ensuring that events from different concurrent runs are always isolated.
Event Topics (Kafka)
In distributed mode, each run creates its own event topic named based on the run ID and pipeline name. This ensures that the Publisher for a given run only sees events for its own documents. Because Workers and Indexers are long-running processes that serve multiple runs over their lifetime, a document’s run_id is the mechanism that routes its events to the correct Publisher — enabling multiple concurrent Runner invocations to share the same Worker and Indexer pool without their accounting interfering with each other. See Long-Running Workers and Indexers for the full operational pattern.
When kafka.events is set to false in the config, event messages are not sent to Kafka. This is appropriate only in streaming mode (no Runner) where run completion tracking is not needed.
kafka {
events: true # default; set to false only in pure streaming mode
}
Connector-less (Streaming) Mode
In connector-less distributed mode, a third-party publisher writes Documents directly to a Kafka source topic. There is no Lucille Runner or Publisher. In this case:
- If
kafka.events is true, the third-party publisher must include a run_id on each document (it can choose its own run ID value). - Workers and the Indexer send events to the Kafka event topic as usual.
- Since there is no Publisher polling the event topic, events accumulate in the topic and are not consumed (unless you route them to your own consumer).
If run tracking is not needed in streaming mode, set kafka.events: false to suppress event production entirely.
Child Documents
Child documents generated by Stages must be registered with the Publisher before the parent document reaches the Indexer. The Worker sends a CREATE event for each child as soon as it is emitted, before the parent’s pipeline execution completes. This ordering guarantee ensures the Publisher never declares a run complete while child documents are still in flight.
Out-of-Order Events
The Publisher handles out-of-order events correctly. A child document can complete (receive a FINISH event from the Indexer) before the Publisher has even received the child’s CREATE event (because Workers and Indexers run concurrently). In this case, the Publisher stores the premature FINISH in a secondary buffer and reconciles it when the CREATE event subsequently arrives.
2.5 - Stage
A Stage performs a specific transformation on a Document.
What a Stage Does
A Stage is the fundamental unit of document transformation in Lucille. Each Stage performs a single, focused operation on a Document: extracting text, renaming fields, generating embeddings, looking up data from an external system, or any other enrichment task.
Stages are composed into Pipelines. When a Document flows through a Pipeline, it passes through each Stage in sequence. Each Stage receives the Document as mutated by all previous Stages and can read fields, write fields, or emit child documents.
The Stage Contract
A Stage implementation must provide one method: processDocument(Document doc). This method receives a Document, performs its transformation (typically by reading and writing fields), and returns an iterator of result documents. For most stages, the iterator contains just the input document (now modified). Stages that generate child documents return the children followed by the parent.
The framework handles everything else:
- Instantiation — Stages are created from class names in configuration via reflection.
- Lifecycle —
start() is called once before processing begins (for resource acquisition); stop() is called once after processing ends (for cleanup). - Condition evaluation — The framework checks conditions before calling
processDocument(). If conditions are not met, the Stage is skipped entirely. - Thread isolation — Each Worker thread gets its own Stage instance. No synchronization is needed.
- Error handling — If
processDocument() throws, the framework catches the exception, marks the document as failed, and continues processing other documents.
Conditions as a Design Decision
Rather than supporting sub-pipelines or branching, Lucille provides per-stage conditions that control whether a Stage applies to a given Document. This keeps the pipeline linear while allowing different processing for different document types.
Conditions are evaluated by the framework before invoking the Stage. A Stage author never implements conditional logic — they write a Stage that does one thing, and the configuration determines which documents it applies to. This separation means Stages are simpler to write, simpler to test, and reusable across pipelines with different condition configurations.
Disabling Stages
As a convenience, you can set enabled: false on any Stage in your Config. By doing so, the Stage is not instantiated, start() and stop() are never called,
and no Document is processed by that Stage. The Stage will still be validated against its Spec, warning you of any missing, invalid, or unknown properties in the Config.
Child Document Emission
A Stage can produce additional documents — children — that flow through the remaining pipeline stages independently. This is how Lucille handles 1-to-N fan-out (e.g., chunking a document into embedding-sized pieces). Children are tracked by the Publisher’s accounting system and indexed as independent records.
The iterator-based return type (Iterator<Document>) means children are produced lazily. Memory usage is bounded regardless of how many children a Stage generates.
Practical Guide
For how to configure stages — syntax, conditions, conditionPolicy, and the full catalogue of built-in stages — see Stages in the Ingest Designer Guide.
For how to build a custom Stage, see Developing Stages.
2.6 - Pipeline
The ordered sequence of Stages that transform Documents before they are indexed.
A Pipeline is an ordered sequence of processing Stages. When a Connector publishes a Document, that Document is picked up by a Worker and passed through every Stage in the configured Pipeline before being sent to the Indexer.
Linear Execution Model
Stages execute in the order they are listed. Each Stage receives the Document as mutated by all previous Stages. If a Stage generates child documents, those children flow through the remaining stages of the same pipeline independently.
This is a deliberate architectural choice: a pipeline is a linear sequence, not an arbitrary graph. There are no branches, no sub-pipelines, and no conditional routing to different pipeline paths.
Why No Sub-Pipelines
Experience has shown that sub-pipelines and branching graphs introduce significant cognitive and testing complexity. They make pipelines harder to reason about and harder to troubleshoot — before you can diagnose a problem, you first have to determine which route a document took. In most real-world ingestion scenarios, sub-pipelines do not turn out to be necessary.
Lucille provides three mechanisms that cover the cases where branching might seem attractive:
Conditions. Every stage supports a conditions block in configuration that determines whether that stage should process a given document. Conditions can check field presence, field values, or combinations (with all/any policy). The framework evaluates conditions before invoking the stage — stage authors never implement conditional logic themselves. This allows a single linear pipeline to apply different processing to different document types without branching.
Custom stages for complex logic. If you need decision logic more complex than what conditions can express, the pathway is to write a custom stage where that logic is implemented and tested in Java — or in Python using Lucille’s EmbeddedPython or ExternalPython stages. A custom stage can inspect any aspect of the document and take arbitrary action, including setting fields that downstream conditions can check.
Config includes for reuse. If the concern is reusing common sequences of stages across multiple pipelines, those sequences can be defined in a separate config file and composed into a larger pipeline definition using HOCON’s array concatenation. The pipeline remains a single linear sequence at execution time — the composition happens at config resolution time, not at runtime.
Per-Thread Isolation
When multiple Worker threads are active, each thread gets its own Pipeline instance — and its own instance of every Stage. This means:
- Stages can safely hold stateful resources (database connections, loaded models, compiled patterns) without synchronization.
- Expensive setup (model loading, connection pool creation) happens once per Worker thread at startup.
- Per-thread isolation eliminates a whole class of concurrency bugs in Stage implementations.
This design means that pipeline authors write sequential code — read a field, transform it, write a field — and the framework handles parallelism. The complexity of concurrent execution is the framework’s responsibility, not the user’s.
Multiple Pipelines
Multiple pipelines can be defined in a single run, each serving different connectors. All pipelines feed the same Indexer. This allows a single Lucille invocation to ingest from multiple sources with different enrichment logic, all writing to the same search backend.
Practical Guide
For how to define pipelines in configuration — syntax, connecting connectors, conditions, reuse patterns, and examples — see Defining Pipelines in the Ingest Designer Guide.
2.6.1 - Pipeline Internals
How lazy iterator chaining works, why in-place modification is the right choice, and memory implications.
What a Pipeline Is
A Pipeline is an ordered sequence of Stages. When a Document enters the Pipeline, it flows through each Stage in order. Each Stage modifies the Document in place and may optionally generate child Documents. The Pipeline returns an Iterator over all Documents that emerged from the processing — the original (possibly modified) Document plus any children generated along the way.
The key insight is that the Pipeline does not eagerly process everything and return a collection. It returns a lazy Iterator that processes Documents on demand as next() is called. This has profound implications for memory usage, especially when stages generate children.
How Pipeline.processDocument() Works
The implementation is deceptively simple:
public Iterator<Document> processDocument(Document document) throws StageException {
Iterator<Document> result = document.iterator();
for (Stage stage : stages) {
result = stage.apply(result);
}
return result;
}
This builds a chain of lazy iterators — one per stage — and returns the outermost one. No processing has happened yet. The stages are not called until next() is called on the returned Iterator.
What happens when next() is called
Consider a pipeline with stages S1, S2, S3. When the Worker calls next() on the returned Iterator:
- The outermost iterator (S3’s wrapper) calls
next() on its input iterator (S2’s wrapper). - S2’s wrapper calls
next() on its input iterator (S1’s wrapper). - S1’s wrapper calls
next() on its input iterator (the original document’s singleton iterator). - The original document is returned.
- S1’s wrapper calls
apply(document) — which calls processConditional(document) — which calls S1’s processDocument(document). S1 modifies the document in place and returns an iterator of children (or null). - S1’s
apply(document) returns an IteratorChain(children, parent) — children first, then the parent. - S2’s wrapper receives the first element from S1’s output (a child, if any, or the parent) and calls
apply() on it. - This continues up the chain until S3 produces its first output document.
The critical property: each document is processed through the full pipeline one at a time. The pipeline doesn’t process all documents through S1, then all through S2, then all through S3. It processes one document through all stages before starting the next.
Children flow through downstream stages only
If S2 generates a child document, that child is passed through S3 but NOT back through S1. This is because the iterator chain is built left-to-right: S1’s output feeds S2, S2’s output feeds S3. A child generated by S2 enters the chain at S2’s output position and only flows forward.
This is the correct semantic for search ingestion: if S1 extracts text from a PDF and S2 chunks that text into pieces, the chunks should flow through S3 (which might generate embeddings) but should NOT flow back through S1 (which would try to extract text from them again).
Depth-first traversal order
The iterator chain traverses children depth-first. When a stage produces children, the first child flows through all downstream stages — and if those stages produce their own children, the first grandchild flows through all stages below it — before the second child at any level is even requested from its producing stage. This means that at any point during pipeline execution, only one document per level of the chain is actively being processed. Children do not accumulate in memory waiting for downstream processing — each child is fully processed and handed off to the Worker before the next sibling is pulled from the iterator.
This property holds as long as child-generating stages return lazy iterators. A stage that materializes all children into a list before returning will hold those children in memory at that level, but they still flow through downstream stages one at a time. For maximum memory efficiency in stages that produce many children (e.g., text chunking), return a lazy iterator that generates children on demand rather than building a complete list.
Why Iterators Instead of Lists
The memory problem with lists
Consider what would happen if processDocument() returned a List<Document> instead of an Iterator<Document>:
// HYPOTHETICAL: if stages returned lists
List<Document> processDocument(Document doc) {
List<Document> results = List.of(doc);
for (Stage stage : stages) {
List<Document> nextResults = new ArrayList<>();
for (Document d : results) {
nextResults.addAll(stage.process(d)); // returns list of doc + children
}
results = nextResults;
}
return results;
}
This approach has a critical flaw: all documents must be held in memory simultaneously.
Consider a pipeline that chunks a large document into 1,000 pieces (S1), then generates an embedding for each piece (S2), then formats each for indexing (S3):
- After S1: 1,000 documents in memory
- After S2: still 1,000 documents in memory (each now with an embedding vector)
- After S3: still 1,000 documents in memory
With the list approach, all 1,000 chunks exist in memory at once. With the iterator approach, only one chunk at a time flows through S2 and S3. The previous chunk has already been handed off to the Worker (which sends it to the indexing queue) before the next chunk is generated.
Compounding with multiple child-generating stages
The problem compounds when multiple stages generate children. Consider:
- S1: Extracts 10 files from a zip archive (10 children)
- S2: Extracts text from each file, producing 5 pages each (50 children)
- S3: Chunks each page into 20 pieces (1,000 children)
With lists: after S3, you’d have 1,000 documents in memory simultaneously.
With iterators: at any given moment, you have at most one document at each level of the chain being processed. The zip is opened lazily, files are extracted one at a time, pages are produced one at a time, chunks are produced one at a time. Memory usage is proportional to the depth of the pipeline, not the breadth of the output.
The lazy evaluation model
The iterator chain implements pull-based lazy evaluation. Nothing happens until the consumer (the Worker) calls next(). Each next() call pulls exactly one document through the full pipeline. This means:
- Documents are produced one at a time
- Each document is fully processed (through all stages) before the next begins
- Memory usage is bounded by the pipeline depth, not the number of children
- The Worker can send each document to the indexing queue immediately after receiving it, freeing memory
In-Place Modification vs. Copying
Lucille stages modify Documents in place rather than creating new copies. This is a deliberate design choice with significant implications.
How it works
@Override
public Iterator<Document> processDocument(Document doc) throws StageException {
// modifies doc directly — no copy is made
String value = doc.getString("input");
doc.setField("output", value.toUpperCase());
return null;
}
The same Document object flows through all stages. S1 modifies it, then S2 sees the modifications S1 made, then S3 sees the modifications both S1 and S2 made.
Why in-place modification is the right choice for search ETL
Memory efficiency. A search ingestion document can be large — it might contain extracted text from a PDF (megabytes), binary content, or dozens of fields. Copying the entire document at each stage would multiply memory usage by the number of stages. With 20 stages and a 5MB document, that’s 100MB per document in flight vs. 5MB.
Simplicity for stage authors. The mental model is straightforward: “I receive a document, I modify it, I’m done.” There’s no need to construct a new Document, copy all existing fields, add new fields, and return it. The boilerplate savings are significant across dozens of stages.
Natural accumulation. In search ingestion, stages typically add information to a document: S1 extracts text, S2 detects language, S3 extracts entities, S4 generates embeddings. Each stage enriches the document with new fields. The in-place model makes this accumulation natural — each stage sees everything previous stages added.
Performance. No allocation, no copying, no garbage collection pressure from intermediate Document objects. For pipelines processing millions of documents, this matters.
The tradeoff: no rollback on failure
If S3 throws an exception after S1 and S2 have already modified the document, the document is in a partially-enriched state. There’s no way to “undo” the modifications from S1 and S2. In Lucille, this is acceptable because:
- A failed document is routed to a failure state — it’s not indexed in its partial form.
- Search ingestion is generally idempotent — if the document is retried, it will be processed from scratch (a fresh copy from the source).
- The alternative (copying at each stage for rollback capability) would impose a performance cost on every document for a scenario that affects a tiny minority.
The tradeoff: stage ordering matters
Because stages see each other’s modifications, the order of stages in the pipeline is significant. S2 can depend on fields that S1 created. This is a feature, not a bug — it’s how enrichment pipelines naturally work (extract text before you can detect language, detect language before you can apply language-specific NER). But it means reordering stages can change behavior, and a stage that expects a field to exist will fail if the stage that creates it is moved downstream.
When copies are necessary
The one case where copies are needed is child document creation. When a stage generates a child, it creates a new Document object (via Document.create(childId)). The child is a separate object from the parent — modifications to the child don’t affect the parent and vice versa. This is correct because children are independent documents that will be indexed separately.
The Stage.apply() Contract
Each Stage has two apply methods that implement the iterator chaining:
apply(Document doc) — process one document
public Iterator<Document> apply(Document doc) throws StageException {
Iterator<Document> children = processConditional(doc);
Iterator<Document> parent = doc.iterator();
if (children == null) {
return parent; // no children — just the (modified) parent
}
// wrap children to copy run ID and count metrics
Iterator<Document> wrappedChildren = new Iterator<>() { ... };
return new IteratorChain(wrappedChildren, parent); // children first, then parent
}
Key details:
- Children come before the parent in the returned iterator. This ensures the Worker sends CREATE events for children before the parent completes, so the Publisher knows about children before it might declare the parent done.
- The run ID is copied from parent to child automatically.
- Child count metrics are incremented as children are produced (lazily, as
next() is called).
apply(Iterator<Document> docs) — wrap an iterator
public Iterator<Document> apply(Iterator<Document> docs) throws StageException {
return new Iterator<>() {
Iterator<Document> current = null;
public boolean hasNext() {
return (current != null && current.hasNext()) || docs.hasNext();
}
public Document next() {
if (current != null && current.hasNext()) {
return current.next();
}
Document d = docs.next();
current = apply(d); // process this document, get iterator of children + parent
return current.next(); // return first element
}
};
}
This wraps an input iterator so that each document pulled from it is processed through the stage. If a document produces children, they are returned before the next document from the input iterator is pulled. This is how the “children flow through downstream stages” behavior works — a child produced by S2 enters S3’s input iterator and is processed by S3 before the next document from S2’s output.
What the Pipeline Framework Gives You
As a stage author, you don’t think about:
- Iterator mechanics. You implement
processDocument(Document doc) and return null or an iterator of children. The framework handles the chaining. - Conditional execution. The framework evaluates conditions before calling your method. If conditions don’t match, your code is never called.
- Dropped/skipped documents. The framework checks these flags before calling your method.
- Metrics. Processing time, error count, and child count are tracked automatically.
- Logging. Stage entry/exit is logged per document automatically.
- Child document lifecycle. Run ID copying, CREATE event sending, and downstream routing are handled by the framework.
- Thread safety. Each Worker thread has its own Pipeline instance with its own Stage instances.
- Memory management. The lazy iterator model ensures bounded memory usage regardless of how many children are generated.
As a pipeline designer, you get:
- Composability. Stages are independent units that can be reordered, added, or removed without modifying other stages.
- Conditional execution via config. Skip stages for certain documents without writing code.
- Heterogeneous document handling. A single pipeline can process different document types differently using conditions.
- Predictable ordering. Stages execute in config order. Children flow through downstream stages only.
- Bounded memory. Even pipelines that generate millions of children from a single input document operate in bounded memory.
Compared to writing your own processing loop:
A naive processing loop (for each stage: stage.process(doc)) would need to handle:
- What if a stage produces children? Do you process them through remaining stages?
- What if children produce grandchildren?
- How do you avoid holding all children in memory?
- How do you ensure children are emitted before parents for accounting?
- How do you handle conditional execution?
- How do you handle dropped/skipped documents?
- How do you track metrics per stage?
- How do you handle errors in one stage without affecting others?
Lucille’s Pipeline handles all of this in ~50 lines of iterator-chaining code that stage authors never see. The stage author’s world is simple: receive a document, modify it, optionally return children. Everything else is the framework’s problem.
Summary
The Pipeline’s design rests on three key decisions:
Lazy iterators instead of eager lists. Documents are produced one at a time, bounding memory usage regardless of how many children are generated. Processing is pull-based — nothing happens until the consumer asks for the next document.
In-place modification instead of copying. Stages enrich a document by adding fields to it directly. No allocation overhead, no copy overhead, natural accumulation of enrichment across stages. The tradeoff (no rollback on failure) is acceptable because failed documents are discarded, not indexed in a partial state.
Children before parents in the output order. This ensures the accounting system learns about children before it might consider the parent complete, preventing premature run-completion detection.
Together, these decisions produce a pipeline framework that is memory-efficient, simple for stage authors, and correct for the accounting system — without requiring stage authors to understand any of the underlying mechanics.
2.7 - Worker
A thread that retrieves published documents and passes them through a pipeline, then forwards completed documents to the Indexer.
A Worker is a thread that pulls Documents from the source queue, runs them through a Pipeline of Stages, and pushes the processed results onto the destination queue for the Indexer to consume.
What the Worker Does
When a Worker starts, it:
- Constructs its own instance of the configured Pipeline (including a private instance of every Stage).
- Enters a polling loop, pulling Documents from the source queue one at a time.
- Passes each Document through every Stage in the Pipeline in order.
- Pushes the processed Document (and any child documents) to the destination queue.
- Sends lifecycle events (FINISH, FAIL, DROP, CREATE) to the Publisher via the event queue.
Per-Thread Pipeline Isolation
Each Worker thread has its own isolated Pipeline instance. This is a deliberate design choice:
- Stages can hold stateful resources (database connections, loaded ML models, compiled regexes) initialized once in
start() and reused across all documents that thread processes — no synchronization needed. - A large NLP model loads once per Worker thread at startup and lives for the thread’s lifetime.
- The memory cost of N model instances is the price of N-way parallelism without lock complexity.
Multiple Workers
In local mode, you can run multiple Worker threads within a single JVM:
worker {
threads: 4
}
Each thread runs its own Pipeline instance concurrently.
In distributed mode, you start multiple Worker processes. Each process consumes from the same Kafka source topic, and Kafka’s consumer group protocol distributes work across them automatically.
Configuration
worker {
# Number of worker threads to start in local mode (default: 1)
threads: 2
# Maximum time (seconds) between Kafka polls before the worker shuts down
# (only relevant in Kafka mode; requires exitOnTimeout: true)
# Must be greater than Lucille's internal poll timeout (50ms local, 2s distributed),
# otherwise an idle worker can be incorrectly flagged as stuck.
maxProcessingSecs: 600
# Shut down if no message is polled within maxProcessingSecs
exitOnTimeout: true
# Maximum number of processing attempts for any document across all workers.
# Requires zookeeper.connectString to be configured.
# Documents exceeding this limit are routed to a dead-letter queue and
# do not block the rest of the run. Omit to disable retry tracking entirely.
maxRetries: 3
# Write a heartbeat.log file periodically for liveness checks.
# Frequency is controlled by log.seconds.
enableHeartbeat: true
}
# Required when worker.maxRetries is set
zookeeper {
connectString: "localhost:2181"
}
# Controls how often Workers, Publishers, and Indexers log status updates and heartbeats
log {
seconds: 30
}
Error Handling
Per-Document Failures
If a Stage throws an exception while processing a document, the Worker:
- Logs the failure (including document ID and run ID in the MDC).
- Sends a FAIL event to the Publisher.
- Continues processing the next document.
The run does not stop on a per-document failure. Individual document failures are counted and reported in the run summary.
Poison Pills
A “poison pill” is a document that repeatedly causes the Worker process itself to crash. If worker.maxRetries is configured (requires ZooKeeper), the retry counter tracks crash counts across all Worker instances. When a document exceeds the retry limit, it is routed to a dead-letter queue, and the rest of the ingest continues.
Metrics
Each Worker reports Codahale metrics to the shared registry:
- Document processing time: Mean latency per document through the full pipeline.
- Error counts: Number of documents that caused exceptions.
The WorkerPool logs a periodic status update every log.seconds seconds:
INFO WorkerPool: 27017 docs processed. One minute rate: 1787.10 docs/sec. Mean pipeline latency: 10.63 ms/doc.
Lifecycle Events
| Event | Sent When |
|---|
CREATE | A child document is generated by a Stage. |
FINISH | A document is successfully indexed (sent by the Indexer, not the Worker). |
FAIL | A document fails during Stage processing. |
DROP | A document is marked as dropped (isDropped() == true). |
The Publisher’s accounting system uses these events to determine when a run is complete.
Running a Worker Standalone
In distributed mode, start a Worker as a separate process:
java \
-Dconfig.file=<PATH/TO/YOUR/CONFIG.conf> \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Worker \
<pipeline-name>
The pipeline name argument tells the Worker which pipeline to run and which Kafka source topic to consume from.
WorkerIndexer
WorkerIndexer is a hybrid entry point that pairs one Worker thread with one Indexer in a single JVM. It is useful in Kafka-distributed deployments where you want a single process to handle both pipeline processing and indexing, without the overhead of coordinating separate Worker and Indexer processes.
How it differs from a standalone Worker:
- Consumes documents from Kafka (source topic) — same as a standalone Worker.
- Routes processed documents to an in-memory queue rather than back to Kafka.
- The co-located Indexer reads from that in-memory queue and sends to the search backend.
- Eliminates the Kafka hop between processing and indexing, reducing latency.
Start a WorkerIndexer:
java \
-Dconfig.file=<PATH/TO/YOUR/CONFIG.conf> \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.WorkerIndexer \
<pipeline-name>
Internally, WorkerIndexer creates a WorkerIndexerPool that manages multiple Worker+Indexer thread pairs within the same JVM. The worker.threads config controls how many pairs run (default: 1):
worker {
threads: 4 # 4 Worker+Indexer thread pairs in this JVM
}
In a multi-node deployment, you can run multiple WorkerIndexer processes consuming from the same Kafka topic. Kafka’s consumer group protocol distributes source documents across them automatically. WorkerIndexer is particularly useful in streaming mode where no Runner is coordinating the run.
Practical Guide
For deployment instructions — starting Workers and WorkerIndexers in local and distributed mode, scaling, and operational considerations — see Deployment.
For Worker-related configuration parameters, see Writing a Config.
2.8 - Indexer
An Indexer sends processed Documents to a specific destination.
What an Indexer Does
An Indexer is the component responsible for delivering processed Documents to their final destination — typically a search engine or vector database. It is the last component in the data flow: Connectors produce Documents, Workers enrich them, and the Indexer sends them to the search backend.
Batching
Indexers do not send documents one at a time. They accumulate documents into batches and flush them as a single bulk API call. This is essential for search engine performance — bulk writes are significantly faster than individual indexing requests, often by an order of magnitude.
A batch is flushed when either of two conditions is met: the batch reaches a configured size, or a timeout expires since the last flush. The timeout ensures documents are not left waiting indefinitely in low-volume scenarios.
Single Indexer Per Run
Only one Indexer can be defined in a Lucille run. All pipelines feed to the same Indexer. This simplifies the system — there is one destination, one set of batching parameters, one connection to manage — and reflects the common reality that a search ingestion project writes to a single search backend.
When documents from different pipelines need to land in different indices within the same backend, Lucille supports index routing: a field on the document determines which index it is sent to, without requiring multiple Indexer definitions.
Deletion Support
Indexers support two deletion mechanisms — delete-by-ID and delete-by-query — triggered by marker fields on the document. This enables CDC patterns and incremental ingestion: a Connector can emit a document that represents “delete this record from the index” rather than “index this record,” and the Indexer translates that intent into the appropriate backend operation.
Field Filtering
The Indexer applies field filtering at the boundary — stripping internal fields, applying whitelist/blacklist rules — so that only the intended fields reach the search backend. This filtering happens at indexing time, not during pipeline processing, so Stages always see the full document.
Error Handling at the Batch Level
Search engine bulk APIs can return mixed results: some documents accepted, others rejected. The Indexer inspects per-document responses and reports individual failures without failing the entire batch. Documents that succeed are marked complete; documents that fail are marked failed. Both are tracked in the run summary.
Practical Guide
For how to configure indexers — generic parameters, field filtering, deletion mechanics, and backend-specific settings — see Indexers in the Ingest Designer Guide.
For how to build a custom Indexer, see Developing Indexers.
2.9 - Runner
Component that manages a Lucille Run end-to-end.
The Runner is the command-line entry point for launching a Lucille run. When invoked, it reads the configuration file, validates all component configurations, generates a unique runId, launches the configured components, waits for all work to complete, and prints a run summary.
What a Run Is
A Lucille Run is a sequence of Connectors executed one after the other. Each Connector feeds a specific Pipeline. A run can include multiple Connectors feeding multiple Pipelines, all sharing the same Indexer.
Connectors run strictly in sequence: the next Connector does not start until all documents from the previous Connector have been fully processed and indexed. This ordering guarantee is enforced automatically by the Publisher’s accounting system.
Run Lifecycle
For each Connector in the configured sequence, the Runner:
- Validates the full configuration (fails fast on any misconfiguration).
- Starts a
WorkerPool (N Worker threads based on worker.threads). - Starts an Indexer thread.
- Creates a
PublisherImpl and launches the Connector in a ConnectorThread. - Blocks on
publisher.waitForCompletion() until all work is done. - Logs the run summary and moves to the next Connector (or exits).
Starting a Run
Local mode (default):
java \
-Dconfig.file=/path/to/config.conf \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Runner
Kafka-distributed mode:
java \
-Dconfig.file=/path/to/config.conf \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Runner \
-usekafka
Kafka-local mode (single JVM, Kafka messaging):
java \
-Dconfig.file=/path/to/config.conf \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Runner \
-usekafka -local
Config validation only (no run):
Validates every Connector, Stage, and Indexer spec and prints all errors. Exits without executing anything.
java \
-Dconfig.file=/path/to/config.conf \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Runner \
-validate
Render effective config (no run):
Prints the fully resolved configuration after HOCON substitutions (environment variables, include directives, etc.). Useful for debugging config variable expansion.
java \
-Dconfig.file=/path/to/config.conf \
-cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*' \
com.kmwllc.lucille.core.Runner \
-render
Run Configuration
runner {
# Log detailed stage-by-stage metrics at end of run (default: INFO)
metricsLoggingLevel: "INFO"
# Connector timeout in milliseconds (default: 86400000 = 24 hours; set <= 0 to disable)
connectorTimeout: 86400000
}
Run ID
The Runner generates a UUID runId for each run. The run ID is:
- Stamped on every Document by the Publisher (
run_id field). - Used as part of the Kafka event topic name in distributed mode.
- Included in log MDC so all log lines during a run include the run ID for filtering.
Run Summary
At the end of every run, the Runner logs a structured summary:
RUN SUMMARY: Success. 1/1 connectors complete. All published docs succeeded.
connector1: complete. 200000 docs succeeded. 0 docs failed. 0 docs dropped. Time: 416.47 secs.
Run took 417.46 secs.
A connector that failed entirely is distinguished from one that completed with individual document failures. Connectors after a failed one are listed as skipped.
Graceful Shutdown
The Runner handles SIGINT (Ctrl+C) and SIGTERM. On signal receipt:
- The Connector stops publishing.
- Workers drain remaining documents.
- The Indexer flushes its current batch.
- A partial run summary is logged.
RunType
Lucille supports four run types, selected via command-line flags:
| RunType | Flag(s) | Description |
|---|
LOCAL | (none) | Single JVM, in-memory queues. Default. |
KAFKA_LOCAL | -usekafka -local | Single JVM, Kafka messaging. |
KAFKA_DISTRIBUTED | -usekafka | Separate JVMs per component, Kafka messaging. |
TEST | (API only) | Single JVM, in-memory, search backend bypassed, messages captured. |
Practical Guide
For deployment instructions — starting runs in each mode, command-line flags, and operational considerations — see Deployment.
For runner and other top-level configuration parameters, see Writing a Config.
2.9.1 - Runner Orchestration
How Runner.run() coordinates the full lifecycle — validation, connector loop, signal handling, and reporting.
Overview
The Runner is Lucille’s top-level orchestrator. It coordinates the full lifecycle of a run: validating configuration, instantiating components, executing connectors sequentially, and reporting results. All methods are static — the Runner is never instantiated.
A “run” is a sequential execution of one or more Connectors. Each Connector’s work must complete before the next begins. If any Connector fails, the run aborts.
How Runner.run() Coordinates the Full Lifecycle
The main execution flow:
public static RunResult run(Config config, RunType type, String runId) throws Exception {
if (runId == null) {
runId = Runner.generateRunId(); // UUID
}
MDC.put(RUNID_FIELD, runId);
// 1. Validate FIRST
Map<String, List<Exception>> validationErrors = runInValidationMode(config);
if (!validationErrors.isEmpty()) {
return new RunResult(false, ...);
}
// 2. Create connectors
List<Connector> connectors = Connector.fromConfig(config);
// 3. Execute each connector sequentially
for (Connector connector : connectors) {
// Create messenger factories based on RunType
// Run connector with components
ConnectorResult result = runConnectorWithComponents(...);
if (!result.getStatus()) {
return new RunResult(false, ...); // Abort on failure
}
}
return new RunResult(true, ...);
}
The Validation Step
Before any work begins, the Runner validates the entire configuration:
Map<String, List<Exception>> validationErrors = runInValidationMode(config);
This validates:
- Pipelines — each pipeline’s stages are instantiated to check for config errors
- Connectors — each connector’s config is checked for required/optional properties
- Indexer — the indexer config block is validated
- Other parents — publisher, runner, kafka, and other top-level config blocks
Validation is fail-all, not fail-fast. All errors are collected and reported together. If any validation errors exist, the run returns immediately with a failure result.
The Connector Loop
For each connector, the Runner:
- Creates messenger factories appropriate for the RunType
- Calls
runConnectorWithComponents() which:- Starts a
WorkerPool (if local mode) - Creates and starts an
Indexer thread (if local mode) - Creates a
Publisher - Calls
runConnector() which:- Calls
connector.preExecute(runId) - Launches a
ConnectorThread that calls connector.execute(publisher) then publisher.flush() - Calls
publisher.waitForCompletion(connectorThread, timeout) - Calls
connector.postExecute(runId) (only if publishing succeeded)
- Stops WorkerPool and Indexer in the
finally block
private static ConnectorResult runConnectorWithComponents(...) {
try {
if (startWorkerAndIndexer && connector.getPipelineName() != null) {
workerPool = new WorkerPool(config, pipelineName, localRunId, workerMessengerFactory, metricsPrefix);
workerPool.start();
IndexerMessenger indexerMessenger = indexerMessengerFactory.create();
indexer = IndexerFactory.fromConfig(config, indexerMessenger, bypassIndexer, metricsPrefix, localRunId);
indexerThread = new Thread(indexer);
indexerThread.start();
}
publisher = new PublisherImpl(config, publisherMessenger, runId, ...);
return runConnector(config, runId, connector, publisher);
} finally {
if (workerPool != null) { workerPool.stop(); workerPool.join(3000); }
if (indexerThread != null) { indexer.terminate(); indexerThread.join(3000); }
}
}
How RunType Affects Component Startup
public enum RunType {
LOCAL, // Workers + Indexer as threads; in-memory queues
TEST, // Same as LOCAL but bypass indexer backend; record message history
KAFKA_LOCAL, // Workers + Indexer as threads; Kafka for messaging
KAFKA_DISTRIBUTED // No local Workers/Indexer; Kafka messaging; assume external processes
}
| RunType | Start Worker/Indexer? | Bypass Indexer Backend? | Messenger Type |
|---|
LOCAL | Yes | No | LocalMessenger |
TEST | Yes | Yes | TestMessenger |
KAFKA_LOCAL | Yes | No | KafkaWorkerMessenger / KafkaIndexerMessenger |
KAFKA_DISTRIBUTED | No | No | KafkaWorkerMessenger / KafkaIndexerMessenger |
The key decision:
boolean startWorkerAndIndexer = !type.equals(RunType.KAFKA_DISTRIBUTED);
boolean bypassSolr = type.equals(RunType.TEST);
The MessengerFactory Pattern
The Runner uses factory interfaces to decouple component creation from the RunType decision:
if (RunType.TEST.equals(type)) {
TestMessenger messenger = new TestMessenger();
history.put(connector.getName(), messenger);
workerMessengerFactory = WorkerMessengerFactory.getConstantFactory(messenger);
indexerMessengerFactory = IndexerMessengerFactory.getConstantFactory(messenger);
publisherMessengerFactory = PublisherMessengerFactory.getConstantFactory(messenger);
} else if (RunType.LOCAL.equals(type)) {
LocalMessenger messenger = new LocalMessenger(config);
workerMessengerFactory = WorkerMessengerFactory.getConstantFactory(messenger);
// ...
} else {
workerMessengerFactory = WorkerMessengerFactory.getKafkaFactory(config, connector.getPipelineName());
// ...
}
For LOCAL/TEST modes, a single messenger instance is shared (via getConstantFactory). For Kafka modes, each factory call creates a new messenger with its own Kafka consumer/producer.
Signal Handling for Clean Shutdown
When running via main(), the Runner registers an INT signal handler:
state = new RunnerState();
Signal.handle(new Signal("INT"), signal -> {
if (state != null) {
state.close(); // Close connector, publisher, workerPool, indexer
}
SystemHelper.exit(0);
});
RunnerState holds references to the currently-active components. When a connector starts, the state is populated:
if (state != null) {
state.set(publisher, connector, workerPool, indexer, indexerThread);
}
When the connector finishes, the state is cleared. The RunnerState.close() method attempts orderly shutdown of each component, logging errors but not throwing.
ConnectorResult and RunResult Reporting
ConnectorResult captures per-connector outcomes:
- Status (success/failure)
- Error message (if failed)
- Duration in seconds
- Document counts from the Publisher (succeeded, failed)
RunResult aggregates across all connectors:
- Overall status
- List of all ConnectorResults
- Summary message (e.g., “2/3 connectors complete. Some docs failed.”)
- For TEST mode: a
Map<String, TestMessenger> containing message history per connector
The Connector Timeout Mechanism
Each connector has a configurable timeout (default: 24 hours):
final int connectorTimeout = config.hasPath("runner.connectorTimeout") ?
config.getInt("runner.connectorTimeout") : DEFAULT_CONNECTOR_TIMEOUT;
pubResult = publisher.waitForCompletion(connectorThread, connectorTimeout);
Inside waitForCompletion, the timeout is checked on each poll iteration:
if (timeout > 0 && ChronoUnit.MILLIS.between(start, Instant.now()) > timeout) {
return new PublisherResult(false, "Connector timeout.");
}
This prevents a stuck connector from blocking the entire run indefinitely.
Sequential Connector Composition
Connectors execute strictly sequentially. The next connector only starts after the previous one fully completes (all documents indexed or failed):
for (Connector connector : connectors) {
ConnectorResult result = runConnectorWithComponents(...);
if (!result.getStatus()) {
log.error("Aborting run because " + connector.getName() + " failed.");
return new RunResult(false, ...);
}
}
Each connector gets its own WorkerPool, Indexer, and Publisher. Components from one connector are fully stopped before the next connector’s components are created.
The main() Method and CLI Options
Options cliOptions = new Options()
.addOption(Option.builder("usekafka").hasArg(false)
.desc("Use Kafka for inter-component communication").build())
.addOption(Option.builder("local").hasArg(false)
.desc("Modifies useKafka mode to execute pipelines locally").build())
.addOption(Option.builder("validate").hasArg(false)
.desc("Validate the configuration and exit").build())
.addOption(Option.builder("render").hasArg(false)
.desc("Print out the configuration file with substitutions applied and exit").build());
| Flag | Effect |
|---|
| (none) | RunType.LOCAL — full local execution with in-memory queues |
-usekafka | RunType.KAFKA_DISTRIBUTED — only run connectors, assume external workers/indexers |
-usekafka -local | RunType.KAFKA_LOCAL — local workers/indexers but communicate via Kafka |
-validate | Validate config and exit (no execution) |
-render | Print resolved config as JSON and exit |
The -validate and -render flags can be combined. All args are lowercased before parsing.
Thread Model (Local Mode)
For each connector in a local run, there are 4+ threads:
- Main thread — runs
waitForCompletion(), polling for events - ConnectorThread — calls
connector.execute(publisher), publishes documents - Worker thread(s) — poll documents, run pipeline, emit results (configurable count)
- Indexer thread — polls processed documents, sends to search engine
Plus a WorkerPool watcher thread that monitors worker health and logs statistics.
2.10 - Config
Why Lucille is configuration-driven, and how the choice of HOCON and Typesafe Config shapes the system.
Configuration as an Architectural Principle
Lucille is a configuration-driven system. Every aspect of an ingest — which sources to read, which enrichment stages to apply, which search backend to write to, how many worker threads to run, what retry policy to use — is declared in a configuration file rather than hardcoded in application logic.
This is not merely a convenience. It is a fundamental architectural decision with consequences that ripple through the entire system:
Separation of concerns. The framework provides the execution engine; the configuration provides the instructions. A developer can change what an ingest does — add a stage, switch a connector, adjust batch sizes — without modifying or rebuilding any code. The same compiled JAR serves every ingest; only the config file changes.
Composability. Because ingests are defined declaratively, they can be composed from reusable pieces. A connector definition, a pipeline fragment, or a set of connection parameters can be defined once and included in multiple configs. This prevents drift and duplication across an organization’s ingests.
Validatability. Because every component declares what configuration it expects (via the SPEC system), the entire config can be validated before any work begins. Errors are caught at startup — all at once, not one at a time — rather than surfacing mid-run after hours of processing.
Environment portability. The same config file works across development, staging, and production by substituting environment-specific values (URLs, credentials, index names) from environment variables at load time. No code changes, no separate config files per environment, no rebuild.
Why HOCON and Typesafe Config
The choice of configuration format and library is not a peripheral implementation detail. In a system where every component — every Stage, Connector, and Indexer — receives its instructions through configuration, the config library is effectively the API between the user and the framework. Its capabilities and limitations shape what users can express and how the system behaves.
Lucille uses HOCON (Human-Optimized Config Object Notation) parsed by the Typesafe Config library. This choice provides several properties that matter architecturally:
Comments. A config file that defines an entire ingestion pipeline — potentially dozens of stages, multiple connectors, connection details, tuning parameters — requires annotation. JSON does not support comments. HOCON does. This is not a minor ergonomic preference; it is the difference between a config that is self-documenting and one that requires external documentation to understand.
Environment variable substitution. The ${?ENV_VAR} syntax allows credentials and environment-specific values to be injected at load time without any application code involvement. This means the framework never needs to implement its own env-var resolution logic, and the precedence rules (file value vs. env var) are defined in one place — the config file itself — rather than scattered across component implementations.
File includes. HOCON’s include directive enables config composition. Shared settings (connection strings, common pipeline fragments) are defined once and included everywhere. This is what makes it practical for an organization to maintain dozens of ingests against shared infrastructure without duplicating connection details that drift out of sync.
Internal variable substitution. HOCON supports references within a config file, so a value defined once can be reused across multiple blocks. Combined with environment variable substitution, this eliminates repetition and ensures consistency — change a value in one place and it propagates everywhere it’s referenced.
Relaxed syntax. HOCON allows omitting quotes around keys, using = or : for assignment, and trailing commas. This makes configs more readable and less error-prone to edit by hand than strict JSON.
List concatenation. HOCON supports appending to lists and concatenating adjacent arrays. This enables patterns like composing a stages list from multiple included fragments — each fragment contributes its stages, and HOCON merges them into a single list at resolution time.
Typed access with path expressions. The Typesafe Config library provides getString(), getInt(), getConfigList(), and other typed accessors with dot-path navigation. This means component code reads configuration through a clean, typed API rather than parsing raw text. Type errors are caught at access time with clear messages.
Automatic resolution order. The library merges configuration from system properties, the application config file, and reference defaults in a defined precedence. This means any config value can be overridden via a system property (-Dproperty=value) without modifying the file — useful for one-off test runs and CI overrides.
Config file selection at runtime. The -Dconfig.file system property tells Lucille which config file to use. This keeps the application generic — the same JAR, the same classpath, the same entrypoint — with only the config file varying between runs. In containerized deployments, this means a single Docker image can serve any ingest by varying an environment variable at launch time.
These properties combine to make configuration a first-class architectural concern rather than an afterthought. The config file is not just “where you put settings” — it is the primary interface through which users interact with the framework, and its expressiveness directly determines how maintainable, portable, and correct an ingest can be.
Practical Guides
For how to write a config file, see Writing a Config in the Ingest Designer Guide.
For operational patterns — environment variable substitution, containerized deployment, config composition, and distributed mode configuration — see Configuration Management in the Operations Guide.
3 - Internals
In-depth explanations of how each architectural subsystem works internally and why it was designed that way.
These pages go beyond the component reference to explain the internal mechanics of each subsystem. They are useful for developers who need to understand why the system behaves the way it does, debug unexpected behavior, or evaluate whether Lucille’s design fits their use case.
Each page is self-contained — you can read them in any order based on what you need to understand.
Deep dives that have been merged into their component pages:
3.1 - Messenger Abstraction
The interfaces that make deployment-mode independence possible — LocalMessenger, TestMessenger, Kafka messengers, and the factory pattern.
Overview
The Messenger layer is the architectural seam that makes Lucille deployment-mode independent. The same Connector, Worker, and Indexer code runs identically whether messages flow through in-memory queues, Kafka topics, or a hybrid of both. This is achieved through three interfaces and a factory pattern that selects the right implementation at runtime.
The Three Messenger Interfaces
Each Lucille component has its own messenger interface, exposing only the operations that component needs:
WorkerMessenger
public interface WorkerMessenger {
Document pollDocToProcess() throws Exception; // Receive work
void commitPendingDocOffsets() throws Exception; // Acknowledge work
void sendForIndexing(Document document) throws Exception; // Forward results
void sendFailed(Document document) throws Exception; // Dead letter queue
void sendEvent(Document document, String message, Event.Type type) throws Exception;
void sendEvent(Event event) throws Exception; // Lifecycle events
void close() throws Exception;
}
IndexerMessenger
public interface IndexerMessenger {
Document pollDocToIndex() throws Exception; // Receive processed docs
void sendEvent(Event event) throws Exception; // Report completion/failure
void sendEvent(Document document, String message, Event.Type type) throws Exception;
void close() throws Exception;
void batchComplete(List<Document> batch) throws Exception; // Offset feedback
}
PublisherMessenger
public interface PublisherMessenger {
void initialize(String runId, String pipelineName) throws Exception;
String getRunId();
void sendForProcessing(Document document) throws Exception; // Publish to workers
Event pollEvent() throws Exception; // Receive lifecycle events
void close();
}
LocalMessenger: One Class, Three Interfaces
LocalMessenger implements all three interfaces using shared LinkedBlockingQueue instances:
public class LocalMessenger implements IndexerMessenger, PublisherMessenger, WorkerMessenger {
private final BlockingQueue<Event> pipelineEvents = new LinkedBlockingQueue<>();
private final BlockingQueue<Document> pipelineSource; // Publisher → Worker
private final BlockingQueue<Document> pipelineDest; // Worker → Indexer
}
The data flow:
sendForProcessing(doc) → puts on pipelineSourcepollDocToProcess() → takes from pipelineSourcesendForIndexing(doc) → puts on pipelineDestpollDocToIndex() → takes from pipelineDestsendEvent(event) → puts on pipelineEventspollEvent() → takes from pipelineEvents
All polls use a 50ms timeout (POLL_TIMEOUT_MS) to allow polling loops to check termination conditions.
Queue capacity is configurable via publisher.queueCapacity (default: 10,000). The put() calls will block if the queue is full, providing natural backpressure.
Key simplifications in local mode:
commitPendingDocOffsets() is a no-op (no offsets to commit)sendFailed() is a no-op (no dead letter queue)batchComplete() is a no-op (no offset feedback needed)close() is a no-op (queues are garbage collected)
TestMessenger: Recording History
TestMessenger wraps a LocalMessenger and intercepts writes to record message history:
public class TestMessenger implements IndexerMessenger, PublisherMessenger, WorkerMessenger {
private final LocalMessenger messenger;
private List<Event> savedEventMessages = Collections.synchronizedList(new ArrayList<>());
private List<Document> savedSourceMessages = Collections.synchronizedList(new ArrayList<>());
private List<Document> savedDestMessages = Collections.synchronizedList(new ArrayList<>());
}
The interception points:
sendForProcessing(doc) → saves to savedSourceMessages, then delegatessendForIndexing(doc) → saves to savedDestMessages, then delegatessendEvent(event) → saves to savedEventMessages, then delegates
After a test run, you can inspect:
getDocsSentForProcessing() — documents the connector publishedgetDocsSentForIndexing() — documents that completed pipeline processinggetSentEvents() — all lifecycle events (CREATE, FINISH, FAIL, DROP)
The lists are synchronizedList because Worker and Indexer threads write concurrently.
KafkaWorkerMessenger: Full Kafka Mode
Reads documents from a Kafka source topic, writes processed documents to a dest topic, and sends events to an event topic:
public class KafkaWorkerMessenger implements WorkerMessenger {
private final Consumer<String, KafkaDocument> sourceConsumer;
private final KafkaProducer<String, Document> kafkaDocumentProducer;
private final KafkaProducer<String, String> kafkaEventProducer;
}
Key behaviors:
pollDocToProcess() — polls the source topic with KafkaUtils.POLL_INTERVAL (2 seconds). Returns at most one record per poll (MAX_POLL_RECORDS_CONFIG = 1). Sets Kafka metadata on the returned KafkaDocument.commitPendingDocOffsets() — calls sourceConsumer.commitSync(). Offsets are committed synchronously to minimize reprocessing after crashes.sendForIndexing(doc) — produces to the dest topic using the document ID as the Kafka key. Calls .get() to wait for acknowledgment, then flush().sendFailed(doc) — produces to the fail topic (dead letter queue).sendEvent(event) — serializes the event as JSON string and produces to the event topic.
KafkaIndexerMessenger: Consume and Commit
Reads processed documents from the dest topic:
public class KafkaIndexerMessenger implements IndexerMessenger {
private final Consumer<String, KafkaDocument> destConsumer;
private final KafkaProducer<String, String> kafkaEventProducer;
}
Key difference from the Worker: offsets are committed immediately after polling, before the document is indexed:
public Document pollDocToIndex() throws Exception {
ConsumerRecords<String, KafkaDocument> consumerRecords = destConsumer.poll(KafkaUtils.POLL_INTERVAL);
if (consumerRecords.count() > 0) {
destConsumer.commitSync(); // Commit immediately
// return the document
}
return null;
}
This means a document might be indexed twice if the indexer crashes after committing but before sending the FINISH event. This is acceptable because indexing is idempotent (upsert semantics).
HybridWorkerMessenger: Kafka In, Memory Out
The hybrid mode reads from Kafka but writes to an in-memory queue shared with a co-located Indexer:
public class HybridWorkerMessenger implements WorkerMessenger {
private final Consumer<String, KafkaDocument> sourceConsumer;
private final KafkaProducer<String, String> kafkaEventProducer;
private final LinkedBlockingQueue<Document> pipelineDest; // Shared with Indexer
private final LinkedBlockingQueue<Map<TopicPartition, OffsetAndMetadata>> offsets; // From Indexer
}
Key behaviors:
pollDocToProcess() — reads from Kafka (same as KafkaWorkerMessenger)sendForIndexing(doc) — puts on the shared pipelineDest queue (in-memory, not Kafka)commitPendingDocOffsets() — drains the offsets queue and commits each batch of offsets to Kafka. These offsets come from the Indexer after it successfully indexes documents.
The offset feedback loop:
- Worker polls document from Kafka (records topic/partition/offset)
- Worker processes document, puts on
pipelineDest - Indexer picks up document, indexes it, puts offset info on
offsets queue - Worker’s next
commitPendingDocOffsets() call commits those offsets to Kafka
This ensures offsets are only committed after documents are actually indexed, providing at-least-once delivery guarantees without requiring the Indexer to have direct Kafka access.
HybridIndexerMessenger: Memory In, Offsets Out
The counterpart to HybridWorkerMessenger:
public class HybridIndexerMessenger implements IndexerMessenger {
private final LinkedBlockingQueue<Document> pipelineDest;
private final LinkedBlockingQueue<Map<TopicPartition, OffsetAndMetadata>> offsets;
private final KafkaProducer<String, String> kafkaEventProducer;
private final Set idSet; // Optional: tracks unique indexed doc IDs
}
Key behaviors:
pollDocToIndex() — takes from the shared pipelineDest queue (50ms timeout)sendEvent(event) — produces to the Kafka event topic (same as other Kafka messengers). Also adds the doc ID to idSet if configured.batchComplete(batch) — extracts Kafka metadata from each KafkaDocument in the batch, builds an offset map, and puts it on the offsets queue for the Worker to commit:
public void batchComplete(List<Document> batch) throws InterruptedException {
Map<TopicPartition, OffsetAndMetadata> batchOffsets = new HashMap<>();
for (Document doc : batch) {
if (!(doc instanceof KafkaDocument)) continue;
KafkaDocument kDoc = (KafkaDocument) doc;
TopicPartition tp = new TopicPartition(kDoc.getTopic(), kDoc.getPartition());
OffsetAndMetadata offset = new OffsetAndMetadata(kDoc.getOffset() + 1); // +1 per Kafka convention
batchOffsets.put(tp, offset);
}
if (!batchOffsets.isEmpty()) {
offsets.put(batchOffsets);
}
}
The MessengerFactory Pattern
Each messenger type has a factory interface:
public interface WorkerMessengerFactory {
WorkerMessenger create();
static WorkerMessengerFactory getConstantFactory(WorkerMessenger messenger) {
return () -> messenger; // Always returns the same instance
}
static WorkerMessengerFactory getKafkaFactory(Config config, String pipelineName) {
return () -> new KafkaWorkerMessenger(config, pipelineName); // New instance each time
}
}
The getConstantFactory is used for LOCAL/TEST modes where a single messenger instance is shared. The getKafkaFactory creates a new messenger (with its own Kafka consumer) for each Worker thread — necessary because Kafka consumers are not thread-safe.
How the Runner Uses Factories
if (RunType.TEST.equals(type)) {
TestMessenger messenger = new TestMessenger();
workerMessengerFactory = WorkerMessengerFactory.getConstantFactory(messenger);
indexerMessengerFactory = IndexerMessengerFactory.getConstantFactory(messenger);
publisherMessengerFactory = PublisherMessengerFactory.getConstantFactory(messenger);
} else if (RunType.LOCAL.equals(type)) {
LocalMessenger messenger = new LocalMessenger(config);
workerMessengerFactory = WorkerMessengerFactory.getConstantFactory(messenger);
indexerMessengerFactory = IndexerMessengerFactory.getConstantFactory(messenger);
publisherMessengerFactory = PublisherMessengerFactory.getConstantFactory(messenger);
} else {
workerMessengerFactory = WorkerMessengerFactory.getKafkaFactory(config, pipelineName);
indexerMessengerFactory = IndexerMessengerFactory.getKafkaFactory(config, pipelineName);
publisherMessengerFactory = PublisherMessengerFactory.getKafkaFactory(config);
}
Architectural Significance
The Messenger abstraction is what makes Lucille’s deployment flexibility possible:
- Development/Testing: Use
LocalMessenger or TestMessenger — no external dependencies, fast, inspectable - Single-node production: Use
LocalMessenger — all components in one JVM, no Kafka overhead - Distributed production: Use Kafka messengers — Workers and Indexers can scale independently across machines
- Hybrid: Use Hybrid messengers — read from Kafka for distribution, but avoid Kafka overhead for the Worker→Indexer hop within the same JVM
The components (Worker, Indexer, Publisher) never know which messenger implementation they’re using. They code against the interface, and the Runner wires in the appropriate implementation at startup.
3.2 - Message Ordering
How Kafka keys preserve operation order across distributed components, and why WorkerIndexer pairs 1:1.
Why Ordering Matters
Search ingestion often involves sequences of operations on the same document: a create, followed by one or more updates, possibly followed by a delete. If these operations are reordered, the final state of the search index is wrong. For example, if a sequence of create → update → delete is reordered so that the delete is processed before the update, the document survives in the index when it should have been removed. Or if the create arrives after the delete, the document reappears.
Lucille guarantees that operations on the same document ID are processed and indexed in the order they were published, even when multiple Workers and Indexers run concurrently.
The Mechanism: Document ID as Kafka Key
Lucille uses the document ID as the Kafka message key at every stage of the pipeline:
- Publisher → processing topic:
new ProducerRecord(sourceTopicName, document.getId(), document) - Worker → indexing topic:
new ProducerRecord(destTopicName, document.getId(), document) - Worker/Indexer → event topic:
new ProducerRecord(eventTopicName, event.getDocumentId(), event)
Kafka’s partitioning guarantee is: messages with the same key are always routed to the same partition, and messages within a partition are consumed in order by a single consumer. This means that all operations for a given document ID land on the same partition at each stage, and are consumed sequentially by whichever component owns that partition.
Ordering in Fully Distributed Mode
In fully distributed mode, Workers and Indexers are separate processes, each consuming from Kafka topics via consumer groups.
Processing topic → Workers:
All messages for document ID “D” land on the same partition P of the processing topic. Kafka assigns partition P to exactly one Worker in the consumer group. That Worker consumes messages from P in order, processing them sequentially (one at a time). The Worker will not pick up the second message for “D” until it has finished processing the first.
Indexing topic → Indexers:
When the Worker produces processed documents to the indexing topic, it again uses document.getId() as the key. All messages for document “D” land on the same partition Q of the indexing topic. Kafka assigns partition Q to exactly one Indexer in the consumer group. That Indexer consumes messages from Q in order.
The ordering guarantee holds end-to-end because:
- Same document ID → same partition (at each topic), guaranteed by Kafka’s partitioner.
- Same partition → same consumer (Worker or Indexer), guaranteed by Kafka’s consumer group protocol.
- Same consumer → sequential processing, guaranteed by the single-threaded polling loop in each component.
Multiple Indexers do not break ordering. Even with N Indexers running, each Indexer owns a disjoint set of partitions. All messages for document “D” are on partition Q, and only one Indexer consumes from Q. There is no shared queue where multiple Indexers could pick up different parts of a sequence for the same document.
Ordering in WorkerIndexer Mode
In WorkerIndexer mode, a Worker and an Indexer are paired in the same JVM, communicating via an in-memory LinkedBlockingQueue rather than a Kafka topic between them.
Why the 1:1 pairing preserves ordering:
Each WorkerIndexer pair owns one or more Kafka partitions of the processing topic exclusively. All operations for a given document ID land on the same partition, so they are consumed by the same Worker, processed in order, and placed on the in-memory queue in order. The paired Indexer reads from that queue (which is FIFO) and indexes them in order.
What would break ordering without the 1:1 pairing:
If multiple Worker threads wrote to a single shared in-memory indexing queue, the ordering of messages for the same document ID would still be preserved — because all messages for the same ID come from the same partition, consumed by the same Worker thread, and placed on the shared queue in order. Interleaving of messages for different document IDs is not a problem for ordering.
The real problem arises on the consumption side: if multiple Indexer threads read from that shared queue, different Indexers could consume different portions of an ordered sequence for the same document ID. Indexer A might pick up the create, Indexer B might pick up the delete, and Indexer B might send its batch to the search backend before Indexer A does — resulting in the document surviving when it should have been deleted.
In fully distributed mode, this problem does not arise even with multiple Indexers, because the indexing topic is a Kafka topic with partition-based consumer assignment. All messages for document “D” are on the same partition, consumed by the same Indexer. But in WorkerIndexer mode, the “indexing queue” is an in-memory LinkedBlockingQueue — it has no concept of partitions or consumer assignment. Any thread reading from it can pick up any message. This is why the 1:1 pairing is necessary: with exactly one Indexer reading from the queue, all messages are consumed in FIFO order, preserving the ordering that the Worker established.
Why WorkerIndexer Pairs Each Worker with a Dedicated Indexer
The 1:1 pairing serves two purposes: offset management and message ordering.
Offset Management
The Worker reads from Kafka and should only commit an offset after the document has been indexed — not just processed. The mechanism:
- The Worker reads a document from Kafka (noting its topic/partition/offset).
- The Worker processes it and places it on the in-memory queue.
- The paired Indexer picks it up, batches it, and sends the batch to the search backend.
- After the batch succeeds, the Indexer places the offsets of the indexed documents on an in-memory offset queue.
- The Worker reads from that offset queue and commits those offsets back to Kafka.
This closed loop between one Worker and one Indexer is simple because there’s no ambiguity about which Worker should commit which offset. If multiple Workers shared an Indexer, the Indexer would need to route offset information back to the correct Worker — adding coordination complexity.
Message Ordering
As described above, the in-memory queue between Worker and Indexer has no partition semantics. With a single Indexer consuming from it, FIFO order is preserved. With multiple Indexers, ordering would be lost for documents whose operations span multiple batch flushes.
The Tradeoff
The 1:1 pairing means you cannot independently scale Workers and Indexers within a single process — they scale together as pairs (via WorkerIndexerPool). If you need independent scaling (e.g., many Workers but few Indexers, or vice versa), use fully distributed mode with separate Worker and Indexer processes communicating via Kafka topics, where Kafka’s partition-based assignment provides both ordering and independent scaling.
When Could Ordering Break?
Consumer group rebalance. If Kafka triggers a rebalance (e.g., a Worker or Indexer is slow, or a new instance joins the group), partitions may be reassigned. A document that was in-flight on the old consumer might be redelivered to the new consumer. This can cause a duplicate — but not a reordering, because the duplicate appears later in the partition’s log than the original.
Topic partition count changes. Kafka’s default partitioner hashes the key modulo the partition count. If the partition count changes mid-ingest, the same document ID might hash to a different partition than before. Operations published before the change and operations published after could end up on different partitions, breaking the ordering guarantee. In practice, partition counts should not be changed during an active ingest.
Processing time variance does not break ordering. Even if one document takes much longer to process than another, ordering is preserved because the Worker processes messages from a partition sequentially. It does not start the next message until the current one is complete.
Summary
| Deployment Mode | Ordering Mechanism | Why It Works |
|---|
| Fully distributed | Kafka partition assignment at both hops | Same doc ID → same partition → same consumer at each stage |
| WorkerIndexer | Kafka partition assignment + FIFO in-memory queue | Same doc ID → same partition → same Worker → single Indexer reads FIFO |
| Local (single JVM) | In-memory queues with single Worker/Indexer threads | Sequential processing within each thread; no concurrency to reorder |
The ordering guarantee holds as long as:
- Document ID is used as the Kafka key (always true in Lucille).
- Partition counts remain stable during an ingest.
- No consumer group rebalance causes partition reassignment mid-sequence (rare, and results in duplicates rather than reordering).
3.3 - Error Handling and Fault Tolerance
The error handling philosophy, every failure scenario catalogued, and how fault tolerance is inherited from Kafka.
Philosophy
Most search ingestion projects take an optimistic approach to error handling: ingest as much content as possible and continue past errors. A search index is often useful even if it doesn’t contain 100% of the data from the source system. Source systems routinely contain messy data where some records will inevitably cause errors in processing — a malformed PDF, a record with unexpected encoding, a field that exceeds a size limit. Stopping the entire ingest because one document out of millions is problematic would be counterproductive.
At the same time, it is a waste of time and resources to continue with an ingestion process if there is a configuration or initialization error that prevents it from working properly. If the pipeline configuration references a nonexistent class, or a Stage can’t connect to a required external resource during startup, or the Indexer can’t reach the search backend, there is no point in processing documents — they will all fail.
Furthermore, when an ingestion process consists of a sequence of connectors, we want to proceed with the next connector only if the previous one was able to complete successfully. This is because later connectors may depend on data produced by earlier ones (e.g., parent documents must be indexed before child documents that reference them).
This leads to a two-level error handling philosophy:
Continue past per-document errors. If a single document fails during pipeline processing or indexing, log the failure, count it, and move on. The run continues. A Lucille run with a sequence of 5 connectors can complete and report success even if individual documents encountered errors during processing or indexing and failed to reach the search backend. The run succeeded in the sense that all connectors completed their work and all documents reached a terminal state — some of those terminal states just happen to be “failed” rather than “indexed.”
Stop immediately on structural errors. If the configuration is invalid, if a Stage can’t initialize, if the Indexer can’t connect, or if a Connector throws an exception during its lifecycle — stop. Don’t waste time and resources on work that cannot succeed. Abort the run and report the failure clearly.
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.
Error Scenarios
1. Invalid Configuration
Before any run starts, Runner.run() calls runInValidationMode(config) which validates all pipelines (every Stage’s SPEC), all connectors, the indexer config, and other parent configs (publisher, worker, etc.). If any validation errors are found, the run is aborted immediately — no connectors are started, no documents are processed. A RunResult with status=false is returned. All errors are reported at once (not fail-fast on the first one).
Severity: Fatal to the run. Nothing executes.
2. Connector preExecute() Throws
The Runner catches the ConnectorException, logs it, and returns a failing ConnectorResult with message “preExecute failed.” Neither execute() nor postExecute() are called. The run is aborted (subsequent connectors in the sequence are skipped). close() is still called on the connector.
Severity: Fatal to the connector and the run.
3. Connector execute() Throws
The connector runs inside a ConnectorThread. If execute() throws, the exception is captured on the thread (ConnectorThread.exception). The Publisher’s waitForCompletion() detects that the connector thread has died with an exception and returns a failing PublisherResult. postExecute() is NOT called. The run is aborted. close() is still called.
Severity: Fatal to the connector and the run.
4. Connector postExecute() Throws
postExecute() is only called if both preExecute() and execute() succeeded AND all published documents completed (the Publisher reported success). If postExecute() throws, the Runner catches it and returns a failing ConnectorResult with message “postExecute failed.” The run is aborted. close() is still called.
Severity: Fatal to the connector and the run. Note that all documents were already successfully processed and indexed at this point — the data is in the search engine, but the run is reported as failed.
5. Stage start() Throws
Pipeline.startStages() is called during Pipeline.fromConfig(), which is called inside the Worker constructor. If any Stage’s start() throws a StageException, the Worker constructor throws, which propagates up to WorkerPool.start(). The WorkerPool catches the exception, calls stop() on any threads that were already started, and re-throws. The Runner catches this in runConnectorWithComponents() and returns a failing ConnectorResult with message “Error starting workers for pipeline X.” The run is aborted.
Severity: Fatal to the run. No documents are processed.
6. Stage processDocument() Throws for a Given Document
The Worker catches the exception, logs it via the DocLogger ("Document FAILED during pipeline processing: " + doc.getId()), sends a FAIL event to the Publisher via messenger.sendEvent(doc, null, Event.Type.FAIL), commits offsets, and continues processing the next document. The failed document is counted in the Publisher’s numFailed tally. The run does NOT stop.
Severity: Per-document failure. The run continues. Other documents are unaffected.
7. Worker Process Crashes While Processing a Document
This depends on the deployment mode:
Local mode: The Worker thread dies. Since the Worker is a thread in the Runner’s JVM, the Runner’s waitForCompletion() will eventually time out or detect that work is not completing (pending documents never reach a terminal state). The run fails.
Kafka-distributed mode: The Worker process dies. Kafka’s consumer group protocol detects the dead consumer and reassigns its partitions to another available Worker instance. The document that was being processed when the crash occurred was not committed (offset not advanced), so it will be redelivered to the new Worker. The document gets reprocessed.
Severity: In local mode, fatal to the run. In distributed mode, recoverable — the document is retried on another Worker.
8. Same Document Causes Workers to Crash Multiple Times (Poison Pill)
When worker.maxRetries is configured, a RetryCounter (backed by ZooKeeper or Redis) tracks how many times each document has been attempted across all Worker instances. Each time a Worker picks up a document, it calls counter.add(doc). If the retry count exceeds the configured maximum, the Worker:
- Sends the document to a “failed” / dead-letter queue via
messenger.sendFailed(doc) - Sends a FAIL event to the Publisher
- Skips processing and moves on
The document is effectively quarantined. The rest of the ingest continues unaffected.
Severity: Per-document failure. The poison pill is isolated. The run continues.
9. Indexer Can’t Connect to the Search Backend
Before the Indexer thread starts processing, validateConnection() is called. If it returns false:
In the Runner (local/Kafka-local mode): The Runner logs “Indexer could not connect”, calls indexer.closeConnection(), and returns a failing ConnectorResult. The run is aborted.
In standalone Indexer mode (distributed): The Indexer logs the error and calls System.exit(1).
In WorkerIndexer mode: The start() method throws an IndexerException("Indexer could not connect"), which prevents the WorkerIndexer from starting.
Severity: Fatal. No documents are indexed.
10. A Batch Fails During Indexing
There are two sub-cases:
10a. The entire batch call throws an exception (batch-level failure):
The Indexer catches the exception, logs it, and sends a FAIL event for every document in the batch. If retries are configured (indexer.maxRetries > 0) and the failure’s status code is in indexer.retryableStatusCodes (default [429, 503, -1]), the batch is retried with exponential backoff before being declared failed. After all retries are exhausted (or if the status code is not retryable), all documents in the batch receive FAIL events. The Indexer continues processing subsequent batches. The batchComplete() call in the finally block always fires regardless of outcome.
10b. The bulk API succeeds but reports per-document failures:
sendToIndex() returns a set of failed document/reason pairs. The Indexer sends FAIL events for those specific documents and FINISH events for the rest. The Indexer continues normally.
Severity: Per-batch or per-document failure. The run continues. Failed documents are counted in the Publisher’s accounting.
11. Indexer Process Crashes
Local mode: The Indexer thread dies. Documents accumulate on the indexing queue but are never consumed. The Publisher’s waitForCompletion() will eventually time out because pending documents never reach a terminal state. The run fails.
Kafka-distributed mode: The Indexer process dies. Kafka’s consumer group protocol reassigns its partitions to another available Indexer instance. Unacknowledged documents (those in the batch that was being processed) are redelivered to the new Indexer. Since search engine upserts are idempotent, re-indexing an already-indexed document produces the same result.
Severity: In local mode, fatal to the run. In distributed mode, recoverable.
12. Runner Process Crashes
The Runner has a signal handler (Signal.handle(new Signal("INT"), ...)) that attempts a clean shutdown: closing the Connector, closing the Publisher, stopping the WorkerPool, and terminating the Indexer. If the Runner crashes hard (e.g., OOM, kill -9):
Local mode: Everything dies — all components are threads in the same JVM. The run is lost. Documents that were in-flight are lost (they were in in-memory queues).
Kafka-distributed mode: Only the Connector and Publisher die. Workers and Indexers are separate processes and continue running. However, no new documents will be published, and the Publisher’s accounting is lost. Documents already on Kafka topics will still be processed and indexed by the Workers and Indexers, but there is no completion detection — the run has no coordinator to declare it done.
Severity: Fatal to the run in all modes. In distributed mode, in-flight work is not lost (it’s on Kafka), but run-level accounting is.
13. Additional Error Scenarios
Publisher fails to send an event (messenger.sendEvent throws):
The Worker and Indexer both catch this and log it. The consequence is severe: if a FINISH or FAIL event is lost, the Publisher will never learn that the document completed. waitForCompletion() will hang until timeout. The code logs "RUN WILL HANG" in this case. This is a rare edge case — it would require the event queue (Kafka topic or in-memory queue) to be unavailable.
Worker watcher detects a stuck Worker:
The WorkerPool runs a watcher thread that checks each Worker’s last poll timestamp. If a Worker hasn’t polled in worker.maxProcessingSecs seconds (default 10 minutes), it logs an error. If worker.exitOnTimeout is configured, it calls System.exit(1). This handles the case where a Stage enters an infinite loop or deadlock on a particular document.
Connector’s close() throws:
The Runner catches this and returns a failing ConnectorResult. Since close() is called in a finally block after all other work, the documents may have been successfully processed, but the run is reported as failed.
Publisher’s waitForCompletion() times out:
If the configured runner.connectorTimeout (default 24 hours) is exceeded, waitForCompletion() returns a failing PublisherResult. The run is reported as failed. This catches scenarios where documents are stuck (lost events, hung Workers, etc.).
Kafka offset commit fails:
The Worker calls messenger.commitPendingDocOffsets() after processing each document. If this throws, the Worker logs the error and continues. The consequence is that if the Worker later crashes, the document may be redelivered and reprocessed (at-least-once semantics). This is safe because pipeline processing is expected to be idempotent.
Document publish fails (publisher.publish() throws):
If the Publisher’s sendForProcessing() throws (e.g., the processing queue is broken), the exception propagates up to the Connector’s execute() method. Unless the Connector catches it, this becomes scenario #3 (connector execute throws) and the run fails.
Fault Tolerance
Single-JVM Mode Is Not Fault-Tolerant
In local mode, all components — Connector, Workers, Indexer, Publisher — run as threads inside a single JVM. If the JVM crashes or the server goes down, everything is lost: in-flight documents were in in-memory queues and are gone. There is no recovery mechanism. The run must be restarted from the beginning.
This is an acceptable tradeoff for development, testing, and small production jobs where a restart is cheap. For workloads where fault tolerance matters, Lucille’s distributed mode with Kafka provides the answer.
Fault Tolerance Is Inherited from Kafka
Lucille does not implement its own fault-tolerance logic (no custom write-ahead logs, no checkpointing to disk, no replication protocol). Instead, it inherits fault tolerance from Kafka by treating Kafka topics as durable queues and relying on Kafka’s consumer group protocol for work reassignment when a process dies.
The key to making this work is proper offset management. Lucille’s contract is:
A Worker only commits a Kafka offset once the current document has been fully processed and placed on the destination queue.
If the Worker crashes while processing a document, that Kafka message’s offset has not been committed. When Kafka detects the dead consumer (via missed heartbeats), it reassigns that partition to another Worker in the consumer group. The new Worker begins consuming from the last committed offset — which is before the document that caused the crash. That document is redelivered and reprocessed.
Two Levels of Offset Commitment
Lucille has two deployment patterns with different offset semantics:
Fully distributed mode (separate Worker and Indexer processes):
The Worker commits offsets via KafkaWorkerMessenger.commitPendingDocOffsets() after processing each document and placing it on the indexing topic. The guarantee is: if the Worker crashes, unprocessed documents are redelivered. However, documents that were processed and placed on the indexing topic but not yet indexed are safe — they’re on Kafka and will be picked up by an Indexer.
WorkerIndexer mode (paired Worker + Indexer in one process):
The offset flow is more sophisticated. The Indexer, after successfully sending a batch to the search backend, places the offsets of the indexed documents on an in-memory queue. The Worker reads from this queue during its commitPendingDocOffsets() call and commits them back to Kafka. The guarantee is stronger: offsets are only committed after documents are indexed, not just processed. If the WorkerIndexer crashes, documents that were processed but not yet indexed are redelivered and re-indexed. Since search engine upserts are idempotent, this produces correct results.
What Fault Tolerance Guarantees
- No data loss. A document that enters the system (is placed on a Kafka topic) will eventually be processed and indexed, or will exhaust its retry count and be routed to a dead-letter queue. It will not silently disappear.
- At-least-once processing. A document may be processed more than once if a crash occurs after processing but before offset commit. Pipeline stages and indexing operations should be idempotent (search engine upserts naturally are).
- Automatic recovery. No manual intervention is required when a Worker or Indexer crashes. Kafka’s consumer group protocol handles partition reassignment automatically. New instances can be added at any time.
What Fault Tolerance Does Not Guarantee
- Exactly-once processing. There is a window between completing work and committing the offset where a crash causes redelivery. This is the standard Kafka at-least-once pattern.
- Run-level accounting survives a Runner crash. The Publisher (which tracks completion) runs alongside the Connector in the Runner process. If the Runner dies, accounting is lost. Documents on Kafka will still be processed, but no component knows when “the run” is done.
- Order preservation after redelivery. A redelivered document may be processed after documents that were originally behind it in the queue. For most search ingestion workloads this is acceptable — the final state of the index is the same regardless of processing order.
Summary Table
| Scenario | Scope | Run Continues? | Data Lost? |
|---|
| Invalid config | Run | No (never starts) | N/A |
| preExecute throws | Connector/Run | No | N/A |
| execute throws | Connector/Run | No | Unpublished docs never enter system |
| postExecute throws | Connector/Run | No | No (docs already indexed) |
| Stage start() throws | Run | No (never starts) | N/A |
| processDocument throws | Document | Yes | That document fails |
| Worker crash (local) | Run | No | In-flight docs lost |
| Worker crash (distributed) | Document | Yes (redelivered) | No |
| Poison pill | Document | Yes (quarantined) | That document fails |
| Indexer can’t connect | Run | No (never starts) | N/A |
| Batch fails (batch-level) | Batch | Yes | Those docs fail |
| Batch fails (per-doc) | Document | Yes | Those docs fail |
| Indexer crash (local) | Run | Eventually times out | In-flight batch lost |
| Indexer crash (distributed) | Batch | Yes (redelivered) | No |
| Runner crash (local) | Run | No | Everything in-flight lost |
| Runner crash (distributed) | Run | Partially | Accounting lost, data on Kafka survives |
| Event send fails | Run | Hangs until timeout | No data lost, but run never completes |
| Worker stuck | Run | Depends on config | Depends on exitOnTimeout |
3.4 - Kafka Integration
Topic naming, KafkaDocument metadata, serialization, consumer groups, offset strategies, and configuration.
Overview
Kafka is Lucille’s distributed messaging backbone. When running in KAFKA_LOCAL or KAFKA_DISTRIBUTED mode, all inter-component communication flows through Kafka topics. This enables horizontal scaling: multiple Worker processes can consume from the same source topic, and multiple Indexer processes can consume from the same dest topic.
Topic Naming Conventions
Lucille uses four topics per pipeline, named by convention:
public static String getSourceTopicName(String pipelineName, Config config) {
// Override: kafka.sourceTopic
// Default: {pipelineName}_source
return pipelineName + "_source";
}
public static String getDestTopicName(String pipelineName) {
return pipelineName + "_dest";
}
public static String getFailTopicName(String pipelineName) {
return pipelineName + "_fail";
}
public static String getEventTopicName(Config config, String pipelineName, String runId) {
// Override: kafka.eventTopic
// Default: {pipelineName}_event_{runId}
return pipelineName + "_event_" + runId;
}
| Topic | Purpose | Producers | Consumers |
|---|
{pipeline}_source | Documents waiting to be processed | Publisher | Workers |
{pipeline}_dest | Processed documents waiting to be indexed | Workers | Indexers |
{pipeline}_fail | Poison-pill documents (dead letter queue) | Workers | External monitoring |
{pipeline}_event_{runId} | Lifecycle events back to Publisher | Workers, Indexers | Publisher |
The event topic is per-run (includes the runId) because each run needs its own isolated event stream. The source, dest, and fail topics are per-pipeline and persist across runs.
The source topic name is validated to contain only safe characters ([A-Za-z\d._-]+) since it may be used as a regex pattern for consumer subscription.
KafkaDocument extends JsonDocument to carry partition/offset/key metadata alongside document fields:
public class KafkaDocument extends JsonDocument {
private String topic;
private int partition;
private long offset;
private String key;
public void setKafkaMetadata(ConsumerRecord<String, ?> record) {
this.topic = record.topic();
this.partition = record.partition();
this.offset = record.offset();
this.key = record.key();
}
}
This metadata travels with the document through the pipeline. It’s essential for the Hybrid mode where the Indexer needs to report back which offsets have been successfully processed.
Plain Document objects are written to Kafka. When deserialized, they come back as KafkaDocument instances with the Kafka metadata attached from the ConsumerRecord.
Serializer/Deserializer
Documents are serialized as JSON using Jackson:
public class KafkaDocumentSerializer implements Serializer<Document> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public byte[] serialize(String topic, Document doc) {
if (doc == null) return null;
return MAPPER.writeValueAsBytes(doc);
}
}
public class KafkaDocumentDeserializer implements Deserializer<Document> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public Document deserialize(String topic, byte[] data) {
if (data == null) return null;
return new KafkaDocument((ObjectNode) MAPPER.readTree(data));
}
}
The deserializer always produces a KafkaDocument (even though the return type is Document). The Kafka metadata is set separately after deserialization via setKafkaMetadata().
Custom serializers/deserializers can be specified via config:
kafka.documentSerializer = "com.example.MySerializer"
kafka.documentDeserializer = "com.example.MyDeserializer"
Document ID as Kafka Message Key
Documents are produced with their ID as the Kafka key:
// In KafkaPublisherMessenger:
kafkaProducer.send(new ProducerRecord(sourceTopicName, document.getId(), document));
// In KafkaWorkerMessenger:
kafkaDocumentProducer.send(new ProducerRecord<>(destTopicName, document.getId(), document));
This provides ordering guarantees: all messages with the same key go to the same partition, ensuring that a document and its children are processed in order within a single partition. It also means that if the same document ID is published multiple times, all versions land on the same partition.
Consumer Group Management
Workers and Indexers join consumer groups to enable parallel consumption:
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, config.getString("kafka.consumerGroupId"));
All Workers for a pipeline share the same consumer group. Kafka distributes partitions among group members, so adding more Workers increases parallelism (up to the number of partitions).
Each consumer gets a unique client ID to avoid Kafka warnings:
String kafkaClientId = "com.kmwllc.lucille-worker-" + pipelineName + "-" + RandomStringUtils.randomAlphanumeric(8);
Key consumer settings:
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
consumerProps.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 1); // One doc at a time
consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // Manual commits
The maxPollIntervalSecs Setting
consumerProps.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG,
1000 * config.getInt("kafka.maxPollIntervalSecs"));
This is the maximum time between poll() calls before Kafka considers the consumer dead and triggers a rebalance. It must be set higher than the longest expected document processing time. If a document takes longer to process than this interval, the consumer will be kicked from the group and the document will be reprocessed by another consumer.
Topic Creation
The event topic is created explicitly with exactly one partition:
public static boolean createEventTopic(Config config, String pipelineName, String runId) {
String eventTopicName = KafkaUtils.getEventTopicName(config, pipelineName, runId);
// Single partition is critical for ordering
NewTopic eventTopic = new NewTopic(eventTopicName, 1, (short) 1);
try (Admin kafkaAdminClient = Admin.create(props)) {
CreateTopicsResult result = kafkaAdminClient.createTopics(List.of(eventTopic));
result.all().get();
} catch (ExecutionException e) {
if (e.getCause() instanceof TopicExistsException) {
return false; // Already exists, that's fine
}
throw e;
}
return true;
}
Why single partition for events? Multiple partitions could cause events to arrive out of order. If a child’s FINISH event arrives before its CREATE event (because they’re on different partitions), the Publisher’s accounting logic would be corrupted. A single partition guarantees FIFO ordering.
The source and dest topics are NOT explicitly created by Lucille — they’re expected to exist already or be auto-created by Kafka’s broker configuration.
Per-Run Event Topics in Batch Mode
In batch mode, each run gets its own event topic (e.g., my_pipeline_event_c1d9413a-8191-4f4a-92bb-0fc42b5499e3). Lucille creates this topic via the Kafka Admin API at the start of each run. This means:
- Kafka Admin API access is required. Lucille creates the event topic explicitly via the Admin API to guarantee it has exactly 1 partition (required for FIFO event ordering). Kafka’s
auto.create.topics.enable is NOT sufficient — auto-created topics use the broker’s default partition count, which would create a multi-partition topic and break the Publisher’s accounting logic. If your Kafka cluster requires separate admin credentials, provide them via kafka.adminPropertyFile. - Each batch ingest produces a new topic. Over time, event topics accumulate. Consider a retention policy or periodic cleanup of old event topics.
- Each Publisher requires its own dedicated event topic. There is no support for multiple Publishers sharing a single event topic and filtering events by run_id. The per-run topic IS the isolation mechanism.
Do NOT set kafka.eventTopic in batch mode. Setting a fixed event topic name would cause all runs to share one topic. If two runs overlap (e.g., launched via the RunnerManager API), their events would be interleaved on the same topic. Each Publisher would see events from the other run, corrupting its accounting and potentially declaring completion prematurely.
kafka.eventTopic is safe only in streaming mode, where there is no Publisher performing completion accounting.
This topic-creation requirement does not apply in streaming mode — when events are disabled (kafka.events: false) no event topic is needed, and when a fixed kafka.eventTopic is set the topic can be pre-created once and reused indefinitely.
The Event Topic: Lifecycle Events
Events flow from Workers and Indexers back to the Publisher:
- Worker → Event Topic:
CREATE (child document generated), FAIL (processing error), DROP (document dropped by stage) - Indexer → Event Topic:
FINISH (successfully indexed), FAIL (indexing error)
Events are serialized as JSON strings (not using the document serializer):
// Producer uses StringSerializer for events
producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// Sending an event
kafkaEventProducer.send(
new ProducerRecord<>(confirmationTopicName, event.getDocumentId(), event.toString()));
The event consumer uses auto-commit for throughput:
consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
This is acceptable because event loss is not catastrophic — the worst case is the Publisher waits longer or times out. Duplicate events are handled gracefully by the Publisher’s Bag-based accounting.
Events can be disabled entirely:
kafka.events = false
When disabled, createEventProducer() returns null and all sendEvent() calls become no-ops. This is useful for Workers/Indexers that run independently without a Publisher waiting for completion.
Event Topic Naming in Streaming Mode
In streaming mode (no Runner, no Publisher), documents arrive from an external system without a run_id field. Since the default event topic name is {pipeline}_event_{runId}, a null run_id produces a topic named {pipeline}_event_null. This is functional but awkward.
To avoid this, use kafka.eventTopic to set a fixed topic name:
kafka {
events: true
eventTopic: "lucille_events" # fixed name, independent of run_id
}
When kafka.eventTopic is set, all events go to that single topic regardless of the document’s run_id. This is safe in streaming mode because there is no Publisher performing per-run completion accounting. In batch mode with a Runner, do NOT set kafka.eventTopic — the per-run topic isolation is essential for correct completion detection.
| Mode | Recommended Setting |
|---|
| Batch (with Runner) | Omit kafka.eventTopic — let Lucille create per-run topics automatically |
| Streaming, no event tracking needed | kafka.events: false |
| Streaming, external event consumer | kafka.eventTopic: "my_fixed_topic" |
See Streaming Mode Configuration for the full streaming setup.
The Fail Topic (Dead Letter Queue)
Documents that exceed retry limits are sent to the fail topic:
// In KafkaWorkerMessenger:
public void sendFailed(Document document) throws Exception {
ProducerRecord<String, Document> producerRecord =
new ProducerRecord<>(KafkaUtils.getFailTopicName(pipelineName), document.getId(), document);
kafkaDocumentProducer.send(producerRecord).get();
kafkaDocumentProducer.flush();
}
The fail topic ({pipeline}_fail) acts as a dead letter queue. Documents here can be inspected, fixed, and replayed. The Worker sends a document to the fail topic when its retry count (tracked in ZooKeeper) exceeds worker.maxRetries.
Kafka Configuration Options
All Kafka settings live under the kafka config prefix:
| Config Key | Purpose | Required |
|---|
kafka.bootstrapServers | Kafka broker addresses | Yes (unless using property files) |
kafka.securityProtocol | Security protocol (PLAINTEXT, SSL, SASL_SSL, etc.) | No |
kafka.consumerGroupId | Consumer group for Workers/Indexers | Yes |
kafka.maxPollIntervalSecs | Max time between polls before rebalance | Yes |
kafka.maxRequestSize | Max message size in bytes | Yes |
kafka.metadataMaxAgeMs | Metadata cache TTL | No (default: 30000) |
kafka.sourceTopic | Override source topic name | No |
kafka.eventTopic | Override event topic name | No |
kafka.events | Enable/disable event production | No (default: true) |
kafka.documentSerializer | Custom serializer class | No |
kafka.documentDeserializer | Custom deserializer class | No |
kafka.consumerPropertyFile | Path to external consumer properties | No |
kafka.producerPropertyFile | Path to external producer properties | No |
kafka.adminPropertyFile | Path to external admin properties | No |
Partitions and Parallelism
The relationship between Kafka partitions and Lucille parallelism:
- Source topic partitions determine max Worker parallelism. With 8 partitions, at most 8 Workers can consume concurrently (within the same consumer group).
- Dest topic partitions determine max Indexer parallelism. Same principle.
- Event topic always has 1 partition (ordering requirement).
To scale Workers: increase source topic partitions and add more Worker processes/threads.
Lucille polls one record at a time (MAX_POLL_RECORDS_CONFIG = 1) to ensure fine-grained offset control and prevent one slow document from blocking a batch.
Important: Source Topic Partition Count and Worker Threads
Lucille does not automatically create the source or dest topics. It only explicitly creates the event topic (with 1 partition for ordering). The source and dest topics are either:
- Pre-created by an administrator with the desired partition count, or
- Auto-created by Kafka when the Publisher first writes to them (if
auto.create.topics.enable=true on the broker)
The gotcha: If Kafka auto-creates the source topic, the partition count is determined by the broker’s num.partitions setting (default: 1). This means that even if you configure worker.threads: 8, only 1 thread will receive documents — the other 7 will join the consumer group but sit idle because there’s only 1 partition to assign.
Lucille does not validate at startup that the source topic has enough partitions for the configured number of worker threads. Excess consumers are simply idle — they don’t error out.
How to avoid this:
- Pre-create the source topic with the desired partition count before running Lucille:
kafka-topics.sh --create --topic my-pipeline_source \
--partitions 8 --replication-factor 1 \
--bootstrap-server kafka:9092
- Or configure the Kafka broker’s
num.partitions to a higher default (applies to all auto-created topics). - Rule of thumb: Set the source topic partition count to at least the maximum total number of Worker threads you expect to run across all processes.
External Property Files for Advanced Configuration
For complex Kafka setups (SASL, SSL, custom partitioners), external property files can be specified:
kafka.consumerPropertyFile = "/path/to/consumer.properties"
kafka.producerPropertyFile = "/path/to/producer.properties"
kafka.adminPropertyFile = "/path/to/admin.properties"
When a property file is specified, it completely replaces the programmatic configuration (except for CLIENT_ID_CONFIG which is always set). The file is loaded via FileContentFetcher which supports local files and cloud storage (S3, Azure, GCP).
private static Properties loadExternalProps(String filename, Config config) {
try (Reader propertiesReader = FileContentFetcher.getOneTimeReader(filename, StandardCharsets.UTF_8.name(), config)) {
Properties consumerProps = new Properties();
consumerProps.load(propertiesReader);
return consumerProps;
}
}
Offset Commit Strategies
Different components use different commit strategies:
| Component | Strategy | Rationale |
|---|
| Worker (source) | commitSync() after processing | Minimize reprocessing after crash |
| Indexer (dest) | commitSync() immediately after poll | Acceptable because indexing is idempotent |
| Publisher (events) | Auto-commit | Throughput; duplicate events are harmless |
| Hybrid Worker | Deferred commit via offset queue | Only commit after Indexer confirms |
The Worker’s synchronous commit ensures that if a Worker crashes, the document it was processing will be redelivered to another Worker. Without this, the document could be lost (committed but not processed).
Producer Behavior
All producers use synchronous sends (.get() after send()):
kafkaDocumentProducer.send(new ProducerRecord<>(...)).get();
kafkaDocumentProducer.flush();
This ensures the message is acknowledged by the broker before proceeding. Combined with MAX_POLL_RECORDS_CONFIG = 1, this creates a strict one-at-a-time processing model that prioritizes correctness over throughput.
Producer settings:
producerProps.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG, config.getInt("kafka.maxRequestSize"));
producerProps.put(ProducerConfig.BUFFER_MEMORY_CONFIG, config.getInt("kafka.maxRequestSize"));
Both maxRequestSize and bufferMemory are set to the same value, ensuring the producer can always send a single maximum-sized message.
3.5 - Metrics and Observability
Codahale metrics, the WorkerPool watcher thread, heartbeats, stuck-worker detection, and MDC usage.
Overview
Lucille uses the Codahale (Dropwizard) Metrics library for runtime observability. Every component — Publisher, Worker, Pipeline, Stage, and Indexer — reports metrics through a shared registry. These metrics are logged periodically during execution and summarized at the end of each run.
The Shared MetricRegistry
All components share a single registry:
MetricRegistry metrics = SharedMetricRegistries.getOrCreate(LogUtils.METRICS_REG);
Where LogUtils.METRICS_REG is the constant "default". Using SharedMetricRegistries means any component in the JVM can access the same registry without passing references around.
The Naming Convention
Metrics are namespaced using a metricsPrefix that encodes the run context:
String metricsPrefix = runId + "." + connector.getName() + "." + connector.getPipelineName();
This ensures metrics are collected separately for each connector/pipeline pair within a run. Individual components append their own suffix:
| Component | Metric Name | Type |
|---|
| Publisher | {prefix}.timeBetweenPublishCalls | Timer |
| Worker | {prefix}.worker.docProcessingTme | Timer |
| Indexer | {prefix}.indexer.docsIndexed | Meter |
| Indexer | {prefix}.indexer.batchTimeOverSize | Histogram |
Stages use their own naming scheme within the Pipeline (see below).
Publisher Metrics
The Publisher tracks the rate and cadence of document publishing:
this.timer = SharedMetricRegistries.getOrCreate(LogUtils.METRICS_REG)
.timer(metricsPrefix + ".timeBetweenPublishCalls");
In publish(), the timer measures the gap between consecutive calls:
if (timerContext.get() != null) {
timerContext.get().stop(); // Stop timing the gap since last publish
}
try {
publishInternal(document);
} finally {
timerContext.set(timer.time()); // Start timing gap to next publish
}
This gives two insights:
- Mean rate — how fast the connector produces documents (docs/sec)
- Mean duration — average time between publish calls (ms/doc), indicating connector latency
The timer is ThreadLocal so multiple publishing threads don’t interfere with each other.
Periodic logging in waitForCompletion():
log.info(String.format(
"%d docs published. One minute rate: %.2f docs/sec. Mean connector latency: %.2f ms/doc. Waiting on %d docs.",
numReceived.get(), timer.getOneMinuteRate(), timer.getSnapshot().getMean() / 1000000, numPending()));
Worker Metrics
The Worker measures document processing time (pipeline latency):
Timer timer = metrics.timer(metricsPrefix + Worker.METRICS_SUFFIX);
// METRICS_SUFFIX = ".worker.docProcessingTme"
// In the processing loop:
Timer.Context context = timer.time();
Iterator<Document> results = pipeline.processDocument(doc);
// ... emit results ...
context.stop();
This timer captures the full pipeline execution time per document, including all stages.
Stage Metrics
Each Stage tracks its own processing time and counts. Metrics are initialized when the stage is added to a pipeline via stage.initialize(position, metricsPrefix):
The Stage base class provides:
- A Timer for per-document processing time
- An error Counter for documents that fail in this stage
- A child Counter for child documents generated by this stage
The logMetrics() method on Pipeline iterates all stages and logs their individual metrics.
Indexer Metrics
The Indexer tracks throughput and backend latency:
this.meter = metrics.meter(metricsPrefix + ".indexer.docsIndexed");
this.histogram = metrics.histogram(metricsPrefix + ".indexer.batchTimeOverSize");
After each batch is sent to the search engine:
stopWatch.reset();
stopWatch.start();
Set<Pair<Document, String>> failedDocPairs = sendToIndex(batchedDocs);
stopWatch.stop();
histogram.update(stopWatch.getNanoTime() / batchedDocs.size()); // Per-doc latency
meter.mark(batchedDocs.size()); // Throughput
The histogram records time per document (total batch time / batch size), giving a normalized view of backend performance regardless of batch size.
Periodic logging:
log.info(String.format(
"%d docs indexed. One minute rate: %.2f docs/sec. Mean backend latency: %.2f ms/doc.",
meter.getCount(), meter.getOneMinuteRate(), histogram.getSnapshot().getMean() / 1000000));
The WorkerPool Watcher Thread
The WorkerPool starts a scheduled watcher that runs every 500ms:
private ScheduledExecutorService startWatcher(List<Worker> workers, int maxProcessingSecs) {
TimerTask watcher = new TimerTask() {
private final Timer timer = metrics.timer(metricsPrefix + Worker.METRICS_SUFFIX);
private Instant lastLogInstant = null;
public void run() {
// Periodic stats logging
if (Duration.between(lastLogInstant, Instant.now()).getSeconds() >= logSeconds) {
log.info(String.format(
"%d docs processed. One minute rate: %.2f docs/sec. Mean pipeline latency: %.2f ms/doc.",
timer.getCount(), timer.getOneMinuteRate(), timer.getSnapshot().getMean() / 1000000));
// Heartbeat
if (enableHeartbeat) {
heartbeatLog.info("Issuing heartbeat");
}
}
// Stuck worker detection
for (Worker worker : workers) {
if (Duration.between(worker.getPreviousPollInstant().get(), Instant.now()).getSeconds() > maxProcessingSecs) {
log.error("Worker has not polled in " + maxProcessingSecs + " seconds.");
if (exitOnTimeout) {
System.exit(1);
}
}
}
}
};
}
The watcher serves three purposes:
1. Periodic Statistics Logging
Every logSeconds (default: 30, configurable via log.seconds), it logs pipeline throughput and latency. This gives operators real-time visibility into processing speed.
2. Stuck Worker Detection
Each Worker updates an AtomicReference<Instant> every time it polls for a new document:
// In Worker.run():
pollInstant.set(Instant.now());
doc = messenger.pollDocToProcess();
The watcher checks if any worker hasn’t polled within maxProcessingSecs (default: 600 seconds / 10 minutes, configurable via worker.maxProcessingSecs). A worker that hasn’t polled is likely stuck processing a single document.
3. The exitOnTimeout Mechanism
When worker.exitOnTimeout is true and a stuck worker is detected, the JVM exits immediately with System.exit(1). This is designed for containerized deployments where a hard restart (via Kubernetes pod restart) is preferable to a hung process.
The Heartbeat Mechanism
public static final String HEARTBEAT_LOG_NAME = "com.kmwllc.lucille.core.Heartbeat";
private static final Logger heartbeatLog = LoggerFactory.getLogger(HEARTBEAT_LOG_NAME);
if (enableHeartbeat) {
heartbeatLog.info("Issuing heartbeat");
if (heartbeatLog.isDebugEnabled()) {
heartbeatLog.debug("Thread Dump:\n{}",
Arrays.toString(ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)));
}
}
When worker.enableHeartbeat is true, the watcher writes to a dedicated heartbeat logger. This can be configured (via logback) to write to a specific file that a Kubernetes liveness probe checks. If the file stops being updated, the probe fails and the pod is restarted.
At DEBUG level, it also dumps all thread stacks — useful for diagnosing what a stuck worker is doing.
End-of-Run Metrics Reporting
After all connectors complete, the Runner logs all collected metrics via Slf4jReporter:
Slf4jReporter.forRegistry(SharedMetricRegistries.getOrCreate(LogUtils.METRICS_REG))
.outputTo(log)
.withLoggingLevel(getMetricsLoggingLevel(config))
.build()
.report();
The logging level is configurable via runner.metricsLoggingLevel (default: DEBUG). This dumps every timer, meter, histogram, and counter in the registry.
The log.seconds Configuration
Controls how frequently periodic stats are logged:
this.logSeconds = ConfigUtils.getOrDefault(config, "log.seconds", LogUtils.DEFAULT_LOG_SECONDS);
// DEFAULT_LOG_SECONDS = 30
Used by:
- Publisher (in
waitForCompletion) - WorkerPool watcher
- Indexer (in
sendToIndexWithAccounting)
Setting this lower gives more frequent visibility; setting it higher reduces log noise.
MDC (Mapped Diagnostic Context) Usage
Lucille uses SLF4J’s MDC to attach contextual information to every log line:
run_id
Set at the start of each thread’s work:
MDC.put("run_id", runId); // In ConnectorThread
MDC.put(RUNID_FIELD, localRunId); // In Worker
MDC.pushByKey(RUNID_FIELD, localRunId); // In Indexer (stack-based for multi-run)
This allows log aggregation tools to filter all log lines for a specific run.
doc_id
Set when processing a specific document:
MDC.put(Document.ID_FIELD, document.getId()); // In Publisher.publish()
try (MDC.MDCCloseable docIdMDC = MDC.putCloseable(ID_FIELD, doc.getId())) {
// In Worker and Indexer — auto-removed when block exits
docLogger.info("Worker is processing document {}.", doc.getId());
}
The DocLogger (logger name com.kmwllc.lucille.core.DocLogger) is a dedicated logger for document lifecycle events. Combined with MDC, you can trace a single document’s journey through the entire system.
Indexer MDC Stack
The Indexer uses pushByKey/popByKey for run_id because in Kafka distributed mode, documents from different runs might be interleaved:
if (d.getRunId() != null) {
MDC.pushByKey(RUNID_FIELD, d.getRunId());
}
// ... send event ...
if (d.getRunId() != null) {
MDC.popByKey(RUNID_FIELD);
}
Summary of Configurable Observability Settings
| Config Key | Default | Effect |
|---|
log.seconds | 30 | Frequency of periodic stats logging |
worker.enableHeartbeat | false | Enable heartbeat logging for liveness probes |
worker.maxProcessingSecs | 600 | Seconds before a worker is considered stuck |
worker.exitOnTimeout | false | Exit JVM when a stuck worker is detected |
runner.metricsLoggingLevel | DEBUG | Log level for end-of-run metrics dump |