2121import java .util .concurrent .Future ;
2222import java .util .concurrent .ScheduledExecutorService ;
2323import java .util .concurrent .ScheduledFuture ;
24+ import java .util .concurrent .Semaphore ;
2425import java .util .concurrent .TimeUnit ;
2526import java .util .concurrent .locks .Condition ;
2627import 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 ) {
0 commit comments