1515 */
1616package io .lenses .streamreactor .connect .http .sink ;
1717
18+ import cats .data .NonEmptySeq
19+ import cats .effect .Deferred
20+ import cats .effect .IO
21+ import cats .effect .Ref
22+ import cats .effect .std .Semaphore
23+ import cats .effect .unsafe .implicits .global
24+ import cats .implicits .catsSyntaxEitherId
25+ import com .typesafe .scalalogging .LazyLogging
26+ import io .lenses .streamreactor .common .batch .BatchPolicy
27+ import io .lenses .streamreactor .common .batch .Count
28+ import io .lenses .streamreactor .connect .cloud .common .model .Topic
29+ import io .lenses .streamreactor .connect .http .sink .client .HttpRequestSender
30+ import io .lenses .streamreactor .connect .http .sink .client .HttpResponseFailure
31+ import io .lenses .streamreactor .connect .http .sink .client .HttpResponseSuccess
32+ import io .lenses .streamreactor .connect .http .sink .config .ErrorNullPayloadHandler
33+ import io .lenses .streamreactor .connect .http .sink .reporter .model .HttpFailureConnectorSpecificRecordData
34+ import io .lenses .streamreactor .connect .http .sink .reporter .model .HttpSuccessConnectorSpecificRecordData
35+ import io .lenses .streamreactor .connect .http .sink .tpl .Headers
36+ import io .lenses .streamreactor .connect .http .sink .tpl .ProcessedTemplate
37+ import io .lenses .streamreactor .connect .http .sink .tpl .RenderedRecord
38+ import io .lenses .streamreactor .connect .http .sink .tpl .SimpleTemplate
39+ import io .lenses .streamreactor .connect .http .sink .tpl .TemplateType
40+ import io .lenses .streamreactor .connect .reporting .ReportingController
1841import org .http4s .Response
1942import org .http4s .Status
2043import org .http4s .WaitQueueTimeoutException
44+ import org .mockito .ArgumentMatchers .any
45+ import org .mockito .MockitoSugar
46+ import org .mockito .invocation .InvocationOnMock
2147import org .scalatest .EitherValues
2248import org .scalatest .funsuite .AnyFunSuiteLike
2349import org .scalatest .matchers .should .Matchers
2450
25- class HttpWriterManagerTest extends AnyFunSuiteLike with Matchers with EitherValues {
51+ import java .util .concurrent .ConcurrentLinkedQueue
52+ import scala .concurrent .duration .DurationInt
53+ import scala .jdk .CollectionConverters ._
54+
55+ class HttpWriterManagerTest extends AnyFunSuiteLike with Matchers with EitherValues with MockitoSugar with LazyLogging {
2656
2757 test(" isErrorOrStatus returns true if the statusCodes is matched" ) {
2858 val statusCodes = Set (408 , 429 )
@@ -46,4 +76,126 @@ class HttpWriterManagerTest extends AnyFunSuiteLike with Matchers with EitherVal
4676
4777 HttpWriterManager .isErrorOrRetriableStatus(Left (WaitQueueTimeoutException ), statusCodes) should be(false )
4878 }
79+
80+ // Covers the PR-review finding: `HttpSinkTask.stop()` used to close the reporting controllers
81+ // before waiting for the consumer fibers, so an in-flight `sendBatch` could still reach
82+ // `reportResult` and enqueue onto a controller that was already shutting down. `shutdown` now owns
83+ // the whole teardown order, so these tests exercise it directly instead of `HttpSinkTask`.
84+
85+ private val sinkName = " MySinkName"
86+ private val topic = Topic (" myTopic" )
87+ private val record = RenderedRecord (topic.withPartition(1 ).atOffset(1L ), 1L , " record" , Seq .empty, " " )
88+
89+ private def successTemplate (): TemplateType =
90+ SimpleTemplate (" http://bench.invalid" ,
91+ " content" ,
92+ Headers (Seq .empty, copyMessageHeaders = false ),
93+ ErrorNullPayloadHandler ,
94+ )
95+
96+ private def buildManager (
97+ sender : HttpRequestSender ,
98+ errorController : ReportingController [HttpFailureConnectorSpecificRecordData ],
99+ successController : ReportingController [HttpSuccessConnectorSpecificRecordData ],
100+ closeIO : IO [Unit ],
101+ deferred : Deferred [IO , Either [Throwable , Unit ]],
102+ ): IO [HttpWriterManager ] =
103+ for {
104+ writersRef <- Ref .of[IO , Map [Topic , HttpWriter ]](Map .empty)
105+ lock <- Semaphore [IO ](1 )
106+ } yield new HttpWriterManager (
107+ sinkName = sinkName,
108+ template = successTemplate(),
109+ httpRequestSender = sender,
110+ batchPolicy = BatchPolicy (logger, Count (1 )),
111+ close = closeIO,
112+ writersRef = writersRef,
113+ writerCreationLock = lock,
114+ deferred = deferred,
115+ errorThreshold = 5 ,
116+ uploadSyncPeriod = 0 ,
117+ tidyJson = false ,
118+ errorReportingController = errorController,
119+ successReportingController = successController,
120+ maxQueueSize = 10 ,
121+ maxQueueOfferTimeout = 1 .minute,
122+ )
123+
124+ test(" shutdown does not close the reporting controllers until it is actually run" ) {
125+ val errorController = mock[ReportingController [HttpFailureConnectorSpecificRecordData ]]
126+ val successController = mock[ReportingController [HttpSuccessConnectorSpecificRecordData ]]
127+
128+ val io = for {
129+ deferred <- Deferred [IO , Either [Throwable , Unit ]]
130+ manager <- buildManager(mock[HttpRequestSender ], errorController, successController, IO .unit, deferred)
131+ _ <- IO (verify(errorController, never).close())
132+ _ <- IO (verify(successController, never).close())
133+ _ <- deferred.complete(().asRight)
134+ _ <- manager.shutdown
135+ } yield {
136+ verify(errorController).close()
137+ verify(successController).close()
138+ }
139+
140+ io.unsafeRunSync()
141+ }
142+
143+ test(" shutdown closes the success controller and releases the client even when the error controller's close throws" ) {
144+ val errorController = mock[ReportingController [HttpFailureConnectorSpecificRecordData ]]
145+ when(errorController.close()).thenThrow(new RuntimeException (" boom" ))
146+ val successController = mock[ReportingController [HttpSuccessConnectorSpecificRecordData ]]
147+ val closed = new ConcurrentLinkedQueue [String ]()
148+
149+ val io = for {
150+ deferred <- Deferred [IO , Either [Throwable , Unit ]]
151+ manager <- buildManager(mock[HttpRequestSender ],
152+ errorController,
153+ successController,
154+ IO (closed.add(" close" )).void,
155+ deferred,
156+ )
157+ _ <- deferred.complete(().asRight)
158+ result <- manager.shutdown.attempt
159+ } yield {
160+ result.left.value.getMessage shouldBe " boom"
161+ verify(successController).close()
162+ closed.asScala.toList shouldBe List (" close" )
163+ }
164+
165+ io.unsafeRunSync()
166+ }
167+
168+ test(
169+ " shutdown waits for the consumer fiber's in-flight send to be cancelled before closing the reporters and the client" ,
170+ ) {
171+ val log = new ConcurrentLinkedQueue [String ]()
172+ val errorController = mock[ReportingController [HttpFailureConnectorSpecificRecordData ]]
173+ when(errorController.close()).thenAnswer { (_ : InvocationOnMock ) => log.add(" errorClose" ); () }
174+ val successController = mock[ReportingController [HttpSuccessConnectorSpecificRecordData ]]
175+ when(successController.close()).thenAnswer { (_ : InvocationOnMock ) => log.add(" successClose" ); () }
176+ val sender = mock[HttpRequestSender ]
177+
178+ val io = for {
179+ entered <- Deferred [IO , Unit ]
180+ deferred <- Deferred [IO , Either [Throwable , Unit ]]
181+ // The send never completes on its own: it is only ever unblocked by the cancellation that
182+ // `HttpWriterManager.startConsumer`'s `IO.race(consumer, deferred.get)` delivers once
183+ // `deferred` is completed, mirroring the real shutdown path exactly.
184+ _ <- IO {
185+ when(sender.sendHttpRequest(any[ProcessedTemplate ])).thenReturn(
186+ entered.complete(()) *> IO .never[Either [HttpResponseFailure , HttpResponseSuccess ]]
187+ .onCancel(IO (log.add(" consumerCancelled" )).void),
188+ )
189+ }
190+ manager <- buildManager(sender, errorController, successController, IO (log.add(" close" )).void, deferred)
191+ writer <- manager.getWriter(topic)
192+ _ <- writer.add(NonEmptySeq .of(record))
193+ // The batch is now stuck mid-send, holding its permit.
194+ _ <- entered.get
195+ _ <- deferred.complete(().asRight)
196+ _ <- manager.shutdown
197+ } yield log.asScala.toList shouldBe List (" consumerCancelled" , " errorClose" , " successClose" , " close" )
198+
199+ io.unsafeRunSync()
200+ }
49201}
0 commit comments