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.
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 |