|
@@ -246,11 +246,29 @@ func (d *Deps) Process(ctx context.Context, body []byte, sig string) Result {
|
|
|
start := time.Now()
|
|
start := time.Now()
|
|
|
var publishErr error
|
|
var publishErr error
|
|
|
if d.CircuitBreaker != nil {
|
|
if d.CircuitBreaker != nil {
|
|
|
|
|
+ // M11 fix: PublishAsync returns a future immediately. The CB wraps
|
|
|
|
|
+ // the *submission* (not the ack) so a JetStream stall still surfaces
|
|
|
|
|
+ // to the circuit breaker within the configured timeout. The actual
|
|
|
|
|
+ // ack is observed in a fire-and-forget goroutine.
|
|
|
|
|
+ var fut nats.PubAckFuture
|
|
|
publishErr = d.CircuitBreaker.Do(ctx, func() error {
|
|
publishErr = d.CircuitBreaker.Do(ctx, func() error {
|
|
|
- return d.JetStream.Publish(subject, payload)
|
|
|
|
|
|
|
+ f, err := d.JetStream.PublishAsync(subject, payload)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ fut = f
|
|
|
|
|
+ return nil
|
|
|
})
|
|
})
|
|
|
|
|
+ if publishErr == nil && fut != nil {
|
|
|
|
|
+ go observeAsyncAck(fut, d, a.SourceID, subject, start)
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
- publishErr = d.JetStream.Publish(subject, payload)
|
|
|
|
|
|
|
+ fut, err := d.JetStream.PublishAsync(subject, payload)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ publishErr = err
|
|
|
|
|
+ } else if fut != nil {
|
|
|
|
|
+ go observeAsyncAck(fut, d, a.SourceID, subject, start)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if publishErr != nil {
|
|
if publishErr != nil {
|
|
|
if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
|
|
if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
|
|
@@ -308,7 +326,10 @@ func (d *Deps) Now() time.Time {
|
|
|
// natsPublisher is the minimal NATS interface the pipeline needs.
|
|
// natsPublisher is the minimal NATS interface the pipeline needs.
|
|
|
type natsPublisher interface {
|
|
type natsPublisher interface {
|
|
|
Publish(subj string, data []byte) error
|
|
Publish(subj string, data []byte) error
|
|
|
- PublishAsync(subj string, data []byte) error
|
|
|
|
|
|
|
+ // PublishAsync submits to JetStream's internal queue and returns a
|
|
|
|
|
+ // future that resolves when the broker acks persistence. Callers
|
|
|
|
|
+ // observe the future asynchronously to avoid blocking the hot path.
|
|
|
|
|
+ PublishAsync(subj string, data []byte) (nats.PubAckFuture, error)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// jsPublisher adapts nats.JetStreamContext to natsPublisher.
|
|
// jsPublisher adapts nats.JetStreamContext to natsPublisher.
|
|
@@ -319,9 +340,8 @@ func (j *jsPublisher) Publish(subj string, data []byte) error {
|
|
|
return err
|
|
return err
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
|
|
|
|
|
- _, err := j.js.PublishAsync(subj, data)
|
|
|
|
|
- return err
|
|
|
|
|
|
|
+func (j *jsPublisher) PublishAsync(subj string, data []byte) (nats.PubAckFuture, error) {
|
|
|
|
|
+ return j.js.PublishAsync(subj, data)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// NewNatsPublisher constructs a natsPublisher from a JetStream context.
|
|
// NewNatsPublisher constructs a natsPublisher from a JetStream context.
|
|
@@ -329,6 +349,25 @@ func NewNatsPublisher(js nats.JetStreamContext) natsPublisher {
|
|
|
return &jsPublisher{js: js}
|
|
return &jsPublisher{js: js}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// observeAsyncAck blocks on the PubAckFuture and records publish latency
|
|
|
|
|
+// or a warn-level log on failure. Runs in its own goroutine so the hot path
|
|
|
|
|
+// returns immediately. Latency is measured from start to broker ack.
|
|
|
|
|
+func observeAsyncAck(fut nats.PubAckFuture, d *Deps, sourceID, subject string, sentAt time.Time) {
|
|
|
|
|
+ if fut == nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ select {
|
|
|
|
|
+ case <-fut.Ok():
|
|
|
|
|
+ if d != nil && d.Metrics != nil {
|
|
|
|
|
+ d.Metrics.PublishLatency.WithLabelValues(sourceID).Observe(time.Since(sentAt).Seconds())
|
|
|
|
|
+ }
|
|
|
|
|
+ case err := <-fut.Err():
|
|
|
|
|
+ if d != nil && d.Logger != nil {
|
|
|
|
|
+ d.Logger.Warn("async publish failed", "subject", subject, "source_id", sourceID, "err", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
|
|
// verifyHMAC parses `X-BA-Signature: t=<unix>,v1=<hex>` and checks
|
|
|
// HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
|
|
// HMAC-SHA256(secret, "<unix>.<body>") == hex. Replay window: 5 min.
|
|
|
// Exported so HTTP handlers can call it directly; gRPC passes sig="".
|
|
// Exported so HTTP handlers can call it directly; gRPC passes sig="".
|