Skip to content

Commit 8e4780b

Browse files
committed
fix(batch): resolve all retriable tasks before closing the stream
1 parent af624d7 commit 8e4780b

2 files changed

Lines changed: 93 additions & 20 deletions

File tree

src/main/java/io/weaviate/client6/v1/api/collections/batch/BatchContext.java

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.concurrent.Future;
2222
import java.util.concurrent.ScheduledExecutorService;
2323
import java.util.concurrent.ScheduledFuture;
24+
import java.util.concurrent.Semaphore;
2425
import java.util.concurrent.TimeUnit;
2526
import java.util.concurrent.locks.Condition;
2627
import java.util.concurrent.locks.Lock;
@@ -200,11 +201,27 @@ public final class BatchContext<PropertiesT> implements Closeable {
200201
*/
201202
@GuardedBy("lock")
202203
private State state;
204+
203205
/** lock synchronizes access to {@link #state}. */
204206
private final Lock lock = new ReentrantLock();
207+
205208
/** stateChanged notifies threads about a state transition. */
206209
private final Condition stateChanged = lock.newCondition();
207210

211+
/**
212+
* Releasing a permit notifies the "sender" about an incoming
213+
* {@link Event.Results} batch. Acquire a permit to await the next batch.
214+
*
215+
* <p>
216+
* A semaphore provides signal semantics, similar to a {@link Condition},
217+
* but without being associated with a predicate. This comes handy when
218+
* {@link wip} is being drained after the context is closed, and the "sender"
219+
* needs to be notified about incoming {@link Event.Results}; a separate
220+
* condition is not necessary, as we can simply probe the {@link queue} to
221+
* find out if any new items have been added to it.
222+
*/
223+
private final Semaphore awaitResults = new Semaphore(0);
224+
208225
/**
209226
* Client-side part of the current stream, created on {@link #start}.
210227
* Other threads MAY use stream but MUST NOT update this field on their own.
@@ -626,16 +643,21 @@ private void trySend() {
626643
awaitState(State::canPrepareNext, "can prepare next");
627644

628645
while (!Thread.currentThread().isInterrupted()) {
646+
// TODO(dyma): this check is redundant, send will only send while batch is full;
629647
if (batch.isFull()) {
630648
send();
631649
}
632650

633651
TaskHandle task = queue.take();
634652

635653
if (task == TaskHandle.POISON) {
654+
assert closed : "queue poisoned before the context is closed";
655+
636656
log.debug("Took poison");
637657

638-
drain();
658+
drainWip();
659+
assert wip.isEmpty() : "wip is not empty after drainWip";
660+
639661
closeStream();
640662

641663
// The SSB protocol requires the client to continue reading the stream
@@ -645,27 +667,20 @@ private void trySend() {
645667
// It is possible that the server will be restarted or the stream will be
646668
// hung up before client receives all Results, in which case we might need
647669
// to re-submit the items remaining in the WIP buffer.
670+
//
671+
// N.B.: By its nature, drainWip ensures that we've received all results.
672+
// Awaiting recv is a show of good faith and ensures correct shutdown sequence.
648673
recv.get();
649674

650-
assert closed : "queue poisoned when context not closed";
651-
if (wip.isEmpty()) {
652-
log.info("All tasks completed, no more data to send");
653-
return;
654-
}
655-
656-
// Server closed the stream before reporting all expected Results.
657-
// Return the poison to the queue and go another round -- if the
658-
// client tried to reconnect the batch may've been filled again.
659-
assert queue.isEmpty() : "queue must be empty after poison";
660-
queue.add(task);
661-
continue;
675+
log.info("All tasks completed, no more data to send");
676+
return;
662677
}
663678

664679
Data data = task.data();
665680
batch.add(data);
666681

667-
TaskHandle existing = wip.put(task.id(), task);
668-
assert existing == null : "duplicate tasks in progress, id=" + existing.id();
682+
// Retred tasks already exist in the WIP list, replacing them is redundant.
683+
wip.putIfAbsent(task.id(), task);
669684
}
670685
} catch (InterruptedException ignored) {
671686
Thread.currentThread().interrupt();
@@ -677,6 +692,10 @@ private void trySend() {
677692
/**
678693
* Send the current portion of batch items. After this method returns, the batch
679694
* is guaranteed to have space for at least one the next item (not full).
695+
*
696+
* <p>
697+
* Calling this on a non-full batch is a no-op; the side-effect of the condition
698+
* in the while-loop is that nothing is sent <i>unless</i> the batch is full.
680699
*/
681700
private void send() throws InterruptedException {
682701
log.atInfo()
@@ -692,6 +711,50 @@ private void send() throws InterruptedException {
692711
assert !batch.isFull() : "batch is full after send";
693712
}
694713

714+
/**
715+
* Send all remainign items in the batch. Then continue processing any
716+
* retried tasks until {@link #wip} is empty.
717+
*/
718+
private void drainWip() throws InterruptedException {
719+
drain();
720+
assert batch.isEmpty() : "batch not empty after drain";
721+
722+
// At this point we are certain that the queue will only be populated
723+
// by failed items from previous batches scheduled for retry. Unlike
724+
// user-supplied items, these will arrive in batches, as the server
725+
// returns results for the previously sent items, i.e. via Event.Results.
726+
//
727+
// A single Results message might not have enough failed items to fill up
728+
// the entire batch. To avoid sending half-empty batches, we will continue
729+
// accumulating items until the batch is full or the WIP list is empty.
730+
while (!wip.isEmpty()) {
731+
log.atTrace()
732+
.addKeyValue("batch_size_total_items", batch::size)
733+
.addKeyValue("wip_tasks", wip::size)
734+
.log("Await Results");
735+
awaitResults.acquire();
736+
737+
TaskHandle task;
738+
while ((task = queue.poll()) != null) {
739+
Data data = task.data();
740+
batch.add(data);
741+
}
742+
743+
assert batch.size() <= wip.size() : "batch has more items than wip";
744+
745+
if (batch.size() == wip.size()) {
746+
// This means the batch already contains all items in WIP,
747+
// and no more tasks will be added to the queue until the
748+
// current ones are send.
749+
drain();
750+
} else {
751+
// Only sends if the batch is full. If the batch is not full,
752+
// the we can keep accumulating items from the failed tasks.
753+
send();
754+
}
755+
}
756+
}
757+
695758
/**
696759
* Send all remaining items in the batch. After this method returns, the batch
697760
* is guaranteed to be empty.
@@ -701,7 +764,7 @@ private void drain() throws InterruptedException {
701764
.addKeyValue("batch_size_total_items", batch::size)
702765
.addKeyValue("message_size_max_items", batch::maxSize)
703766
.addKeyValue("message_size_max_bytes", batch::maxSizeBytes)
704-
.log("Drain remaining items");
767+
.log("Flush remaining items");
705768

706769
// To correctly drain the batch, we flush repeatedly
707770
// until the batch becomes empty, as clearing a batch
@@ -904,7 +967,13 @@ private void onResults(Event.Results results) {
904967
.forEach(taskHandle -> taskHandle.setError(
905968
new ServerException(results.errors().get(taskHandle.id()))));
906969

907-
// TODO(dyma): notify receivedResults
970+
log.atDebug()
971+
.addKeyValue("count_success", results.errors().size())
972+
.addKeyValue("count_errors", results.successful().size())
973+
.log("Received results");
974+
975+
awaitResults.release();
976+
assert awaitResults.availablePermits() == 1;
908977
}
909978

910979
private void onBackoff(Event.Backoff backoff) {

src/test/java/io/weaviate/client6/v1/api/collections/batch/BatchContextTest.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import java.io.IOException;
44
import java.util.ArrayList;
5-
import java.util.Arrays;
65
import java.util.Collections;
76
import java.util.List;
87
import java.util.Map;
@@ -393,6 +392,7 @@ public void test_reconnect_onStreamHangup() throws Exception {
393392
recvDataAndAck();
394393

395394
List<String> submitted = tasks.stream().map(TaskHandle::id).toList();
395+
log.info("Will send results for {} items before EOF", submitted.size());
396396
out.beforeEof(new Event.Results(submitted, Collections.emptyMap()));
397397
}
398398

@@ -541,7 +541,10 @@ CompletableFuture<Void> hangup() {
541541

542542
/** Emit events before closing the server half of the stream. */
543543
void beforeEof(Event... events) {
544-
this.pendingEvents.addAll(Arrays.asList(events));
544+
for (var e : events) {
545+
emitEventAsync(e);
546+
}
547+
// this.pendingEvents.addAll(Arrays.asList(events));
545548
}
546549

547550
/**
@@ -554,7 +557,8 @@ CompletableFuture<Void> eof(boolean ok) {
554557
if (ok) {
555558
// These are guaranteed to finish before onCompleted,
556559
// as eventThread is just 1 thread.
557-
pendingEvents.forEach(this::emitEventAsync);
560+
log.info("before_eof: emit {} events", pendingEvents.size());
561+
// pendingEvents.forEach(this::emitEventAsync);
558562
}
559563
return CompletableFuture.runAsync(stream::onCompleted, eventThread);
560564
}

0 commit comments

Comments
 (0)