Skip to content

google-cloud-pub-sub-grpc: subscriber correctness follow-up to #1494 #1620

Description

@hanishi

google-cloud-pub-sub-grpc: subscriber correctness follow-up to #1494

What's going on

PR #1494 added google-application-default credentials and StreamingPull auto-reconnect to the connector. After running the resulting subscriber under realistic load we hit three structural correctness gaps:

  1. (introduced in Google Common, Google Cloud Pub/Sub gRPC: Add google-application-default credentials and StreamingPull auto-reconnect #1494) autoExtendAckDeadlines doesn't actually cover messages buffered behind a slow downstream stage, so Pub/Sub redelivers them.
  2. (pre-existing since 2019, newly observable) GooglePubSub.subscribe(...) propagates maxOutstandingMessages, maxOutstandingBytes, and clientId from the initial StreamingPullRequest into every keepalive request, which the server rejects with INVALID_ARGUMENT. The leak has been in subscribe since commit 3f1a4f7f28 (Jan 2019, akka.stream.alpakka era), but no caller had reason to set those fields until Google Common, Google Cloud Pub/Sub gRPC: Add google-application-default credentials and StreamingPull auto-reconnect #1494's RestartSettings overload made long-lived streaming-pull subscribers viable. It's essentially a latent bug surfaced by the new use cases Google Common, Google Cloud Pub/Sub gRPC: Add google-application-default credentials and StreamingPull auto-reconnect #1494 enables.
  3. (introduced in Google Common, Google Cloud Pub/Sub gRPC: Add google-application-default credentials and StreamingPull auto-reconnect #1494) flowControlGate is structurally identical to bug 1: it counts permits only when it pushes downstream, so its limit can't bound what the server delivers.

All three are reproducible with stub SubscriberClients, no live Pub/Sub needed. See the tests at the bottom.


Bug 1: autoExtendAckDeadlines doesn't cover messages waiting in the buffer

What we expected

When you wrap a Pub/Sub source with autoExtendAckDeadlines, you'd expect every message that has reached your client to get its deadline extended in the background. That way Pub/Sub won't redeliver while you're still working on it.

What actually happens

Tracking only starts when a message reaches the inside of the operator. The current code uses a plain .map:

Flow[ReceivedMessage]
  .via(killSwitch.flow)
  .map { msg =>
    tracked.put(msg.ackId, System.nanoTime())
    msg
  }

A .map only runs its function when downstream asks for the next element. So in a normal pipeline like this:

GooglePubSub.subscribe(request, 1.second)
  .via(GooglePubSub.autoExtendAckDeadlines(sub, 8.seconds, 30))
  .mapAsync(parallelism = 10)(processMessage)   // ~5 to 7 seconds per message

if all 10 mapAsync slots are busy, nothing flows through .map. Meanwhile Pub/Sub has already pushed dozens more messages over the gRPC stream. Those messages sit in the gRPC adapter's buffer with their server-side deadline ticking, but our code never tracks them. The background ticker fires every 8 seconds, looks at tracked, sees only the 10 messages currently held by mapAsync, and extends just those. The rest hit 60 seconds, expire, get redelivered. We end up processing duplicates.

Why "just put a .buffer in front of it" doesn't help

A .buffer upstream of autoExtendAckDeadlines lets messages queue up, but .map still won't fire on them until downstream pulls. A .buffer downstream pushes the bottleneck back by N elements, but once that buffer fills, the bug returns.

Why eager-pull is the architecturally right answer

What goes wrong

Pub/Sub starts a per-message ack deadline timer at server dispatch. The client must declare possession of each message (track its ackId) before that deadline expires, or the server redelivers. The current .map-based tracker only declares possession when downstream pulls, and downstream pulls are paced by the slowest stage in the pipeline (typically mapAsync(parallelism)).

When that pace is slower than the deadline, messages get redelivered. The threshold:

(buffered_count × per_message_seconds / parallelism) > deadline_seconds

Concretely: 60 messages buffered, 6s per message, mapAsync(10), 60s deadline → messages at the back of the queue wait ~36s for tracking, some slip past 60s, get redelivered.

Where that buffering lives matters for the fix:

  • mapConcat(_.receivedMessages.toVector) holds each StreamingPullResponse's batch (typically 50 to 100 messages) and emits them one per downstream pull.
  • gRPC-Java + Netty hold messages below the Pekko stream layer entirely: HTTP/2 receive window (~1 MB default), MessageDeframer's assembled-message queue. Pekko gRPC's own GraphStage is demand-driven (it asks gRPC for one message per onPull), so there's no Pekko-layer buffer to shrink. The buffer that lets the server outpace the consumer sits underneath Pekko Streams, where user code can't reach it.

Why this is the deep cause

The bug exists because two distinct timescales get conflated:

  • Tracking ("I have this message"): must complete in microseconds, because it has to beat the ack deadline (tens of seconds, fixed by the server).
  • Processing ("I am done with this message"): can take whatever it takes; the deadline is extendable via ModifyAckDeadline for as long as the user wants.

A demand-driven .map puts both on the same clock: tracking only fires when downstream pulls, which is paced by processing. That conflation is the bug. The framework's default pull semantics happen to be incompatible with a protocol that has a server-controlled, deadlined unacked window. Same shape recurs in any at-least-once protocol with these properties (Kafka long-poll with manual commit, SQS visibility timeout, RabbitMQ prefetch). gRPC is incidental.

Why eager-pull is uniquely the right shape

Eager-pull splits the two clocks. A GraphStage that pulls from upstream into a bounded internal buffer whenever it has room runs the tracking work in onPush on receipt, then enqueues for downstream. Tracking now happens at adapter-delivery speed (microseconds); processing still flows through the buffer at its own pace.

What this buys that nothing else does:

  • Tracking decouples from processing. Slow downstream no longer starves fast-required tracking.
  • Backpressure reaches the right buffer. When the eager-pull buffer fills, the stage stops pulling. That signal back-propagates through Pekko gRPC's GraphStage (stops calling call.request(1)), through gRPC-Java (no demand), into Netty (HTTP/2 receive window stops refreshing), and finally to the server (stops dispatching). Eager-pull is the only Pekko-layer construct that can tell the server "stop" without also breaking tracking. .buffer(N), .async, larger parallelism, or a custom Sink for tracking either fail to apply backpressure all the way down or break tracking, downstream flow, or the abstraction.
  • Bug 2 complements this at the protocol layer. Server-side maxOutstandingMessages bounds the lower buffers via the protocol; eager-pull bounds the stream-layer buffer via backpressure. Together: the server has at most N messages in flight to the client, and the client tracks every one of them on receipt.

Google's Java client uses the same shape (MessageDispatcher.processReceivedMessages registers in pendingMessages before the user callback) for the same reason. Independent corroboration of the constraint; the constraint itself comes from the protocol.

Suggested fix

Replace the .map with a small GraphStage that pulls from upstream eagerly into a bounded buffer, calling the tracking callback the moment it grabs each message. Google's official Java client (google-cloud-pubsub) does the same thing in MessageDispatcher.processReceivedMessages: it registers messages in pendingMessages before handing them to user code.

Sketch:

private[grpc] final class EagerPullTrackingStage[T](maxBuffer: Int, onTrack: T => Unit)
    extends GraphStage[FlowShape[T, T]] {
  // preStart: pull(in)
  // onPush:   onTrack(msg); buffer.offer(msg); pushIfPossible(); pullIfPossible()
  // onPull:   pushIfPossible(); pullIfPossible()
  // pullIfPossible: only pulls if buffer has room
}

The buffer needs an upper bound or it will OOM. With a sensible default (1000, matching Google's client) it's fine for almost everyone. New overloads can expose maxBuffer for tuning. The cleanest pairing is with server-side flow control via maxOutstandingMessages, which is what bug 2 is about.


Bug 2: subscribe() makes maxOutstandingMessages unusable

What we expected

Pub/Sub's streaming pull protocol lets you cap delivery server-side via maxOutstandingMessages and maxOutstandingBytes on the initial StreamingPullRequest. We expected to set those on the request we pass to GooglePubSub.subscribe() and have everything just work.

What actually happens

subscribe() builds the keepalive request like this:

val subsequentRequest = request
  .withSubscription("")
  .withStreamAckDeadlineSeconds(0)

It clears two fields, but the Pub/Sub API has five fields that must only appear on the initial request:

Field Cleared currently?
subscription yes
streamAckDeadlineSeconds yes
clientId no
maxOutstandingMessages no
maxOutstandingBytes no

The keepalive tick re-sends our original request with three of those fields still set. About a second after the stream starts, the server replies:

INVALID_ARGUMENT: You must only set max_outstanding_messages and
max_outstanding_bytes on the initial request.

The whole stream fails. There's no way to recover from inside the public API. The only workaround is to bypass subscribe() entirely and call subscriberClient.streamingPull directly.

Suggested fix

Build the subsequent request from the proto's default instance:

val subsequentRequest = StreamingPullRequest.defaultInstance

That clears every initial-only field at once. It's also safe against future proto revisions: if Google adds another initial-only field next year, this code won't accidentally leak it.

The keepalive tick is purely a heartbeat in our implementation. It doesn't carry anything that needs to come from the original request.


Bug 3: flowControlGate doesn't actually flow-control

What we expected

flowControlGate(FlowControl(maxOutstandingMessages = 100)) looks like the protocol-level cap that Google's FlowController provides. We expected it to bound how many unacked messages the system holds at any given time, including ones still in flight from the server.

What actually happens

It only counts messages that have already been pushed past the gate to downstream. Looking at impl/FlowControlGateStage.scala:

override def onPull(): Unit = {
  if (flowControl.outstanding.get() < flowControl.maxOutstandingMessages) {
    pull(in)
  } else {
    downstreamWaiting = true
  }
}

override def onPush(): Unit = {
  val msg = grab(in)
  flowControl.acquire()
  push(out, msg)
}

Same shape as the old .map tracker from bug 1: a 1-in-1-out passthrough whose onPull only pulls upstream when downstream asks. So when mapAsync saturates, the gate stops pulling, but messages keep arriving from the server into the gRPC adapter buffer with no permit cost. The outstanding counter stays low even when the system is holding hundreds of unacked messages.

In practice this means three things go wrong:

  1. It doesn't mitigate bug 1. Wrapping the pipeline with flowControlGate(FlowControl(100)) doesn't reduce the redelivery rate, because the gate isn't actually bounding what the server sends.
  2. outstandingCount underreports. Telemetry built on this counter shows you a tiny fraction of what's really in flight.
  3. The name is misleading. Users reach for "flow control" expecting Google's semantics and get downstream credit accounting instead.

Suggested fix

Rewrite FlowControlGateStage with the same eager-pull pattern as bug 1: acquire the permit on receipt (in onPush), buffer the message internally, push to downstream when demand arrives. The buffer is naturally bounded by the FlowControl limit, so no separate maxBuffer parameter is needed.

override def onPush(): Unit = {
  val msg = grab(in)
  flowControl.acquire()       // count on receipt, not on push downstream
  buffer.offer(msg)
  pushIfPossible()
  pullIfPossible()
}

override def onPull(): Unit = {
  pushIfPossible()
  pullIfPossible()
}

private def pullIfPossible(): Unit =
  if (flowControl.outstanding.get() < flowControl.maxOutstandingMessages
      && !hasBeenPulled(in) && !isClosed(in)) pull(in)
  else if (buffer.isEmpty && isClosed(in)) completeStage()

After this change, outstandingCount reflects messages that have been received from the server (whether or not they have flowed past the gate yet), the gate stops pulling once the limit is hit, and combined with gRPC backpressure the server eventually stops sending too. That's what most users reaching for "flow control" actually want.

Note on the relationship with bug 2

After bug 2 lands, StreamingPullRequest.maxOutstandingMessages gives you protocol-level flow control on the wire, with the server respecting the cap directly. That is strictly better than flowControlGate for the common case of "don't let the server send me more than N at a time." flowControlGate then survives as an application-level primitive for cases that genuinely need it, like per-tenant fairness across multiple subscribers sharing one Pub/Sub subscription, where server-side flow control can't tell callers apart.


Why these three bugs go together

Bug 1 and bug 3 are the same defect in two different operators: a 1-in-1-out passthrough whose onPull only pulls upstream when downstream demands. Both need the same eager-pull fix. Bug 2 is what unlocks the natural way to bound the eager-pull buffer for either: server-side StreamingPullRequest.maxOutstandingMessages, which makes the gRPC adapter stop pulling at the protocol level once the cap is reached.

Without bug 2 fixed, you can't use the server-side cap, so the eager-pull buffers in bugs 1 and 3 have to be sized by hand. Without bug 1 or bug 3 fixed, even a correctly capped server-side flow doesn't help, because messages still pile up past the demand-bound stages the moment any downstream stage stalls.

Fixing all three gives you: the server stops sending past the cap (bug 2), the eager-pull tracker covers every in-flight message for deadline extension (bug 1), the eager-pull gate enforces the in-flight limit on receipt (bug 3), and the keepalive tick stays valid forever (bug 2). Each fix on its own helps a little. All three together make the subscriber actually work under load.


Reproduction

All three tests use stub SubscriberClients, no live Pub/Sub. They live in src/test/scala/org/apache/pekko/stream/connectors/googlecloud/pubsub/grpc/AutoExtendAckDeadlinesSpec.scala (despite the file name, it covers all three bugs since each is a small focused test that fits naturally beside the existing autoExtend tests).

The stubs are defined at the bottom of that same file. Each subclasses a TestSubscriberClientBase trait that overrides every SubscriberClient method with ???, so the concrete stubs only override the one or two methods the test actually exercises:

  • SucceedingClient: modifyAckDeadline returns Future.successful(Empty()). Used by bug 3.
  • CapturingClient: modifyAckDeadline records the incoming request into a ConcurrentLinkedQueue then returns success. Used by bug 1 to inspect which ackIds the ticker extended.
  • CapturingStreamingPullClient: overrides streamingPull to drain the input request Source into a queue and return a never-completing Source.maybe[StreamingPullResponse], so the response stream stays open while the test inspects what was sent on the client → server side. Used by bug 2.

Bug 1

"track all in-flight messages eagerly even when downstream is backpressured" in {
  val captured = new ConcurrentLinkedQueue[ModifyAckDeadlineRequest]()
  val testSubscriber = new GrpcSubscriber(new CapturingClient(captured))
  val gate = Promise[ReceivedMessage]()  // mapAsync hangs forever
  val killSwitch = KillSwitches.shared("test")

  Source(1 to 10).map(i => makeMsg(i.toString))
    .via(GooglePubSub.autoExtendAckDeadlines(subscription, 200.millis, 30))
    .via(killSwitch.flow)
    .mapAsync(1)(_ => gate.future)
    .withAttributes(PubSubAttributes.subscriber(testSubscriber))
    .runWith(Sink.ignore)

  Thread.sleep(800)
  killSwitch.shutdown()

  val firstReq = captured.poll()
  // Today: firstReq.ackIds.size == 1     (only the message held by mapAsync)
  // Want:  firstReq.ackIds.size == 10    (everything we received)
  firstReq.ackIds.toSet shouldBe (1 to 10).map(_.toString).toSet
}

Bug 2

"send only allowed fields on subsequent StreamingPullRequest messages" in {
  val captured = new ConcurrentLinkedQueue[StreamingPullRequest]()
  val testSubscriber = new GrpcSubscriber(new CapturingStreamingPullClient(captured)(system))

  val initial = StreamingPullRequest()
    .withSubscription(subscription)
    .withStreamAckDeadlineSeconds(60)
    .withClientId("test-client")
    .withMaxOutstandingMessages(100L)
    .withMaxOutstandingBytes(10485760L)

  val cancellable = GooglePubSub.subscribe(initial, 100.millis)
    .withAttributes(PubSubAttributes.subscriber(testSubscriber))
    .toMat(Sink.ignore)(Keep.left).run()

  Thread.sleep(500); cancellable.futureValue.cancel()

  val first = captured.poll()
  first.maxOutstandingMessages shouldBe 100L  // initial request kept verbatim

  val subsequent = Iterator.continually(Option(captured.poll())).takeWhile(_.isDefined).flatten.toList
  subsequent should not be empty
  // Today this fails: every subsequent request still carries clientId,
  // maxOutstandingMessages, maxOutstandingBytes from the initial request.
  subsequent.foreach { req =>
    req.subscription shouldBe ""
    req.streamAckDeadlineSeconds shouldBe 0
    req.clientId shouldBe ""
    req.maxOutstandingMessages shouldBe 0L
    req.maxOutstandingBytes shouldBe 0L
  }
}

Bug 3

"acquire permits on receipt, not on push to downstream (eager-pull)" in {
  val testSubscriber = new GrpcSubscriber(new SucceedingClient())
  val limit = 5
  val flowControl = FlowControl(maxOutstandingMessages = limit.toLong)
  val gate = Promise[ReceivedMessage]() // mapAsync(1) hangs forever
  val killSwitch = KillSwitches.shared("flowControlGateTest")

  Source(1 to 100).map(i => makeMsg(i.toString))
    .via(GooglePubSub.flowControlGate(flowControl))
    .via(killSwitch.flow)
    .mapAsync(1)(_ => gate.future)
    .withAttributes(PubSubAttributes.subscriber(testSubscriber))
    .runWith(Sink.ignore)

  Thread.sleep(300)

  // Today: outstandingCount == 1 (only the message held by mapAsync got a permit acquired).
  // Want:  outstandingCount == 5 (the gate eagerly pulls and counts up to the limit).
  flowControl.outstandingCount shouldBe limit.toLong

  killSwitch.shutdown()
}

Recommended high-level API: Subscriber

Once all three bugs are fixed at the operator level, the connector still has an ergonomic problem. To get a correct subscriber today the user has to compose four operators by hand:

GooglePubSub.subscribe(request, 1.second, restartSettings)
  .via(GooglePubSub.autoExtendAckDeadlines(...))
  .via(GooglePubSub.flowControlGate(flowControl))
  .mapAsync(10)(processMessage)
  .map(...)
  .runWith(GooglePubSub.acknowledge(parallelism = 1, flowControl))

There are several ways to compose these wrong: putting autoExtendAckDeadlines inside RestartSource instead of the 3-arg subscribe (loses tracking on each reconnect), forgetting to pair acknowledge(_, flowControl) with flowControlGate(flowControl) (permits never released), passing the wrong subscription string to autoExtendAckDeadlines and the request (extension goes to a different subscription), etc. The traps are silent.

A GooglePubSub.subscriber(...) resource bundles the configuration once and exposes a clean Source and Sink that compose correctly by construction:

val subscriber = GooglePubSub.subscriber(
  request          = StreamingPullRequest()
                       .withSubscription(subscriptionFqrs)
                       .withStreamAckDeadlineSeconds(60)
                       .withMaxOutstandingMessages(1000),
  pollInterval     = 1.second,
  ackDeadline      = AckDeadline.Fixed(extensionInterval = 8.seconds, deadlineSeconds = 30),
  restartSettings  = Some(RestartSettings(100.millis, 10.seconds, 0.2)),
  flowControl      = Some(FlowControl(maxOutstandingMessages = 1000)))

subscriber.source
  .mapAsync(10)(processMessage)
  .map(msg => AcknowledgeRequest(subscriptionFqrs, Seq(msg.ackId)))
  .runWith(subscriber.acknowledge(parallelism = 1))

Or even simpler, with the run convenience:

subscriber.run(parallelism = 10)(processMessage)

What Subscriber guarantees:

  • Restart wraps only the inner subscribe, not the deadline tracker, so tracking survives reconnect by construction. Users can't accidentally put autoExtendAckDeadlines inside their own RestartSource and lose state on every reconnect.
  • In-flight messages are preserved across reconnect. RestartSource re-materializes only subscribe; the eager-pull tracker, flow-control gate, and downstream operators stay alive. Messages buffered inside those operators at the moment a gRPC stream dies stay in their buffers, keep getting their deadlines extended by the long-lived ticker, and continue flowing downstream when the new stream comes up. This is actually better than Google's MessageDispatcher, which tracks ackIds for extension but doesn't buffer the message payload itself; here the payload survives in the stream and processing resumes on the original instance.
  • subscriber.acknowledge(...) releases flow-control permits if flowControl is set, and records completion latencies if ackDeadline is Adaptive. No risk of mis-pairing the operators.
  • The deadline tracker's ticker starts at construction and stops at close(). The source's watchTermination triggers close() automatically on stream completion.
  • Two strategies for ack deadlines via AckDeadline.Fixed or AckDeadline.Adaptive. Other config knobs stay optional.

The low-level operators stay available for users who genuinely need different composition, like sharing one extender across multiple subscriptions or splitting the source for fan-out. For the 95% case, Subscriber is the entry point.

AckDeadlineExtender survives as the underlying primitive that Subscriber is built on; users who want fine control over its lifecycle can keep using it directly.

A Java DSL equivalent of Subscriber is straightforward to add but has been left for a follow-up issue. The Java DSL splits its GrpcSubscriber from the Scala one (each wraps a different generated client class), so a clean Java Subscriber either needs a JavaSubscriberClient-based extender or has to bridge to the Scala client. That's a small design call worth its own discussion. In the meantime, Java users get the bug 1, 2, and 3 fixes automatically through the existing Java DSL operators, and they can use the Java factories on AckDeadlineExtender.create(...) for restart-safe deadline tracking.

Visual summary: what was wrong and what this PR introduces

The structural pattern shared by bugs 1 and 3

═══════════════════════════════════ BEFORE: demand-bound 1-in-1-out ═════════════════════════════════════

  upstream                             this stage                          downstream
  (gRPC adapter)                       (.map tracker / flowControlGate)    (mapAsync, etc.)
  ────────────                         ──────────────                      ──────────────────
                                                                           
                                        onPull():                          
                                          if (room) pull(in)               ◄── pull
                                                                           
                                        onPush():                          
                                          grab(in)                         
                                          do work (track / acquire)        
                                          push(out, msg)                   ──► msg
                                                                           
                                        (work runs ONLY when downstream    
                                         pulls; nothing eager.)            

  When downstream stalls (e.g. mapAsync(parallelism=10) saturated):
    1. downstream stops pulling
    2. this stage's onPull() never fires        ───►  no upstream pull
    3. messages pile up in upstream buffers     ───►  but work never runs
    4. server-side deadline ticks anyway        ───►  REDELIVERY (bug 1)
                                                ───►  outstanding undercounted (bug 3)


═══════════════════════════════════ AFTER: eager-pull with bounded buffer ═══════════════════════════════

  upstream                             this stage                          downstream
  (gRPC adapter)                       (EagerPullTrackingStage /           (mapAsync, etc.)
                                        rewritten FlowControlGateStage)
  ────────────                         ──────────────                      ──────────────────
                                                                           
                                        preStart():                        
                                          pull(in)              ◄────── starts pulling on its own
                                                                           
  push msg ─────────►  drain  ─────►   onPush():                          
                                          grab(in)                         
                                          do work IMMEDIATELY              ◄── tracked / permit acquired
                                            on receipt                         on receipt
                                          buffer.offer(msg)                
                                          pushIfPossible()      ──────►   ──► msg (if downstream demand)
                                          pullIfPossible()                 
                                                                           
                                        onPull():                          
                                          pushIfPossible()                 ──► msg (drain buffer)
                                          pullIfPossible()                 
                                                                           
                                        pullIfPossible():                  
                                          if (buffer < cap                  
                                              && upstream open) pull(in)   

  When downstream stalls:
    1. downstream stops pulling
    2. buffer fills up to its cap        ───►  backpressure flows upstream
    3. but every message we ever          ───►  tracked / permit acquired
       received has already had              correctly (no redelivery,
       its work done on receipt              outstanding reflects reality)


═══════════════════════════════════ Where the buffering actually lives ═══════════════════════════════════

  The "upstream buffers" in the diagram above aren't a single thing, and most of
  them are not in Pekko Streams at all. Concretely:

      Server
        │ HTTP/2
        ▼
   ┌──────────────────────────────────────────────────────────┐
   │ Netty + gRPC-Java                                        │  ◄── BELOW Pekko.
   │   HTTP/2 receive window (~1 MB by default)               │      Holds bytes /
   │   MessageDeframer (assembled msgs, not yet delivered)    │      assembled msgs.
   │   AsyncCallback queue (gRPC thread → GraphStage thread)  │      Not reachable
   └──────────────────────────────────────────────────────────┘      from user code.
        │   ▲
        │   │ call.request(1) per onPull
        ▼   │
   ┌──────────────────────────────────────────────────────────┐
   │ PekkoNettyGrpcClientGraphStage                           │  ◄── DEMAND-DRIVEN.
   │   onPull → call.request(1)                               │      At most 1
   │   onMessage callback → emit(out, msg); requested -= 1    │      element held.
   └──────────────────────────────────────────────────────────┘
        │  one StreamingPullResponse (a BATCH of N msgs)
        ▼
   ┌──────────────────────────────────────────────────────────┐
   │ mapConcat(_.receivedMessages.toVector)                   │  ◄── DOMINANT
   │   holds the batch (typically 50 to 100 msgs)             │      Pekko-visible
   │   emits one per downstream pull                          │      buffer.
   └──────────────────────────────────────────────────────────┘
        │  one ReceivedMessage per pull
        ▼
   ┌──────────────────────────────────────────────────────────┐
   │ .map { tracked.put(ackId, ...); msg }     ◄── BUG 1      │  ◄── TRACKING.
   │   demand-driven 1-in-1-out                               │      Fires only
   │   onPush only fires when downstream pulls                │      on downstream
   └──────────────────────────────────────────────────────────┘      demand.
        │
        ▼
   ┌──────────────────────────────────────────────────────────┐
   │ mapAsync(parallelism = 10)                               │  ◄── PACES THE
   │   slow processing (5 to 7s per message)                  │      ENTIRE STACK
   │   pulls only when a slot frees                           │      ABOVE.
   └──────────────────────────────────────────────────────────┘

  Why this matters for the fix:

  1. There is no Pekko-layer buffer to "make smaller" between Pekko gRPC and
     mapConcat. Tweaking .buffer(N) or async boundary defaults won't help.

  2. The buffer responsible for letting the server outpace the consumer lives
     BELOW Pekko entirely (Netty + gRPC-Java). The only way to bound it from
     stream code is by withholding upstream demand: when the eager-pull stage
     stops calling pull(in), Pekko gRPC stops calling call.request(1), gRPC-Java
     has no demand to satisfy, Netty's HTTP/2 window stops refreshing, and the
     server stops dispatching.

  3. Eager-pull is the only Pekko-layer construct that reaches all the way
     down to HTTP/2 without breaking tracking. That's why it's the right shape;
     other patterns (.buffer, .async, custom Sink) either fail to backpressure
     the lower buffers or break some other property.

  4. Bug 2's fix is the protocol-layer complement. Server-side
     maxOutstandingMessages caps the gRPC-Java + Netty buffers via the protocol
     itself, so backpressure has less work to do.


═══════════════════════════════════ Bug 2 (the keepalive request leak) ══════════════════════════════════

  Initial request  ───►   server  ──► OK, stream opens
                                                              
  Pre-fix subsequentRequest = request.withSubscription("").withStreamAckDeadlineSeconds(0)
                                          │
                                          │ still carries:
                                          ├─ clientId = "client-A"
                                          ├─ maxOutstandingMessages = 100   ◄── server forbids these
                                          └─ maxOutstandingBytes    = 10MB     on subsequent requests
                                          
  Tick #2 (1s later) ───►  server  ──► ❌ INVALID_ARGUMENT
                                          stream fails


  Post-fix subsequentRequest = StreamingPullRequest.defaultInstance
                                          │
                                          ├─ subscription          = ""
                                          ├─ streamAckDeadlineSecs = 0
                                          ├─ clientId              = ""
                                          ├─ maxOutstandingMessages= 0L
                                          └─ maxOutstandingBytes   = 0L
                                          (every initial-only field cleared,
                                           forward-safe against future protos)
                                          
  Every tick ─────────►   server  ──► ✓ accepted, stream stays open

Composition before vs after

═══════════════════════════════════ BEFORE: hand-composed, easy to get wrong ═════════════════════════════

  GooglePubSub.subscribe(req, 1.s, restartSettings)   ◄─── req carries maxOutstandingMessages
                                                            (bug 2 → INVALID_ARGUMENT)
    .via(GooglePubSub.autoExtendAckDeadlines(sub, ...)) ◄─── .map tracker (bug 1)
    .via(GooglePubSub.flowControlGate(fc))              ◄─── push-time permit (bug 3)
    .mapAsync(10)(processMessage)
    .map(_ => AcknowledgeRequest(...))
    .runWith(GooglePubSub.acknowledge(1, fc))         ◄─── must remember to pair fc here too

  Silent traps:
    - autoExtend inside RestartSource → tracking lost on every reconnect
    - acknowledge without fc → permits never released → gate locks forever
    - subscription mismatch between req and autoExtend → extends a different sub


═══════════════════════════════════ AFTER: one resource, correct by construction ═════════════════════════

  val subscriber = GooglePubSub.subscriber(
    request          = req w/ maxOutstandingMessages = N,    ◄── bug 2 fix automatic
    pollInterval     = 1.s,
    ackDeadline      = AckDeadline.Fixed(8.s, 30),           ◄── extender owns ticker
    restartSettings  = Some(...),                            ◄── wraps INNER subscribe only
    flowControl      = Some(FlowControl(N)))                 ◄── eager-pull gate

  subscriber.source                                          ◄── composes:
    .mapAsync(10)(processMessage)                                subscribe → autoExtend(extender)
    .map(_ => AcknowledgeRequest(...))                           → flowControlGate → watchTermination
    .runWith(subscriber.acknowledge(1))                       ◄── releases fc permits + records
                                                                   completion latencies automatically

  By construction:
    + restart only re-materializes inner subscribe
    + extender + flowControl + buffers stay alive across reconnects
    + messages buffered at moment of disconnect KEEP BEING EXTENDED
    + no permit leaks possible (acknowledge knows about fc)


═══════════════════════════════════ Restart-safety detail ═════════════════════════════════════════════════

  Inner gRPC stream fails:
                              stays alive
                          ────────────────────►
  RestartSource ── re-mat ── EagerPullTracker ── EagerPullFlowGate ── mapAsync ── ack
       │                          ▲                    ▲
       │                          │                    │
       └─ subscribe restarts      buffer holds         outstanding still
          new gRPC stream         pre-disconnect       reflects in-flight
          comes up                messages             across the gap

  Extender (caller-owned):
       ────────────────────────────────────────────────►
       map<ackId,t>     ticker fires every extensionInterval
       persists         continues extending pre-disconnect ackIds
       across           during the backoff window
       reconnects

  This matches Google's MessageDispatcher lifecycle (built once per
  StreamingSubscriberConnection, reused across every gRPC stream restart).
  And it goes further: Pekko Streams' buffering means the message PAYLOAD
  also survives, not just the ackId, so processing resumes on the original
  message instance.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions