mirror of
https://github.com/hanzo-fi/go-libs.git
synced 2026-10-30 20:53:44 +00:00
239 lines
6.1 KiB
Go
239 lines
6.1 KiB
Go
package httpaudit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/audit"
|
|
logging "github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
)
|
|
|
|
const (
|
|
defaultAsyncPublishingQueueCapacity = 1024
|
|
defaultAsyncPublishingWorkerCount = 1
|
|
)
|
|
|
|
var ErrAsyncPublisherClosed = errors.New("async audit publisher closed")
|
|
|
|
type auditEventPublisher interface {
|
|
Publish(ctx context.Context, payload audit.Payload)
|
|
}
|
|
|
|
type syncAuditEventPublisher struct {
|
|
publisher message.Publisher
|
|
topicName string
|
|
appName string
|
|
}
|
|
|
|
func (p syncAuditEventPublisher) Publish(ctx context.Context, payload audit.Payload) {
|
|
audit.PublishEvent(ctx, p.publisher, p.topicName, p.appName, payload)
|
|
}
|
|
|
|
type asyncPublishingConfig struct {
|
|
queueCapacity int
|
|
workerCount int
|
|
onDrop func(context.Context, audit.Payload)
|
|
onError func(context.Context, audit.Payload, error)
|
|
}
|
|
|
|
func defaultAsyncPublishingConfig() asyncPublishingConfig {
|
|
return asyncPublishingConfig{
|
|
queueCapacity: defaultAsyncPublishingQueueCapacity,
|
|
workerCount: defaultAsyncPublishingWorkerCount,
|
|
}
|
|
}
|
|
|
|
// AsyncPublishingOption configures HTTP audit async publishing.
|
|
type AsyncPublishingOption func(*asyncPublishingConfig)
|
|
|
|
// WithAsyncPublishingQueueCapacity sets the fixed queue capacity used by async audit publishing.
|
|
func WithAsyncPublishingQueueCapacity(capacity int) AsyncPublishingOption {
|
|
return func(c *asyncPublishingConfig) {
|
|
c.queueCapacity = capacity
|
|
}
|
|
}
|
|
|
|
// WithAsyncPublishingWorkerCount sets the fixed number of workers used by async audit publishing.
|
|
func WithAsyncPublishingWorkerCount(workerCount int) AsyncPublishingOption {
|
|
return func(c *asyncPublishingConfig) {
|
|
c.workerCount = workerCount
|
|
}
|
|
}
|
|
|
|
// WithAsyncPublishingDropCallback is called after an audit event is dropped because the queue is full.
|
|
func WithAsyncPublishingDropCallback(fn func(context.Context, audit.Payload)) AsyncPublishingOption {
|
|
return func(c *asyncPublishingConfig) {
|
|
c.onDrop = fn
|
|
}
|
|
}
|
|
|
|
// WithAsyncPublishingErrorCallback is called after a worker fails to publish an audit event.
|
|
func WithAsyncPublishingErrorCallback(fn func(context.Context, audit.Payload, error)) AsyncPublishingOption {
|
|
return func(c *asyncPublishingConfig) {
|
|
c.onError = fn
|
|
}
|
|
}
|
|
|
|
func newAsyncPublishingConfig(opts ...AsyncPublishingOption) asyncPublishingConfig {
|
|
cfg := defaultAsyncPublishingConfig()
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
if cfg.queueCapacity < 1 {
|
|
cfg.queueCapacity = defaultAsyncPublishingQueueCapacity
|
|
}
|
|
if cfg.workerCount < 1 {
|
|
cfg.workerCount = defaultAsyncPublishingWorkerCount
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
type asyncAuditEvent struct {
|
|
ctx context.Context
|
|
payload audit.Payload
|
|
message *message.Message
|
|
}
|
|
|
|
// AsyncPublisherStats reports async audit publishing counters.
|
|
type AsyncPublisherStats struct {
|
|
Enqueued uint64
|
|
Dropped uint64
|
|
Published uint64
|
|
PublishErrors uint64
|
|
}
|
|
|
|
// AsyncPublisher publishes audit events through a bounded worker pool.
|
|
type AsyncPublisher struct {
|
|
publisher message.Publisher
|
|
topicName string
|
|
appName string
|
|
cfg asyncPublishingConfig
|
|
|
|
queue chan asyncAuditEvent
|
|
|
|
mu sync.RWMutex
|
|
closed bool
|
|
wg sync.WaitGroup
|
|
|
|
enqueued atomic.Uint64
|
|
dropped atomic.Uint64
|
|
published atomic.Uint64
|
|
publishErrors atomic.Uint64
|
|
}
|
|
|
|
// NewAsyncPublisher creates a bounded async publisher for HTTP audit events.
|
|
func NewAsyncPublisher(publisher message.Publisher, topicName string, appName string, opts ...AsyncPublishingOption) *AsyncPublisher {
|
|
cfg := newAsyncPublishingConfig(opts...)
|
|
p := &AsyncPublisher{
|
|
publisher: publisher,
|
|
topicName: topicName,
|
|
appName: appName,
|
|
cfg: cfg,
|
|
queue: make(chan asyncAuditEvent, cfg.queueCapacity),
|
|
}
|
|
|
|
p.wg.Add(cfg.workerCount)
|
|
for range cfg.workerCount {
|
|
go p.worker()
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
// Publish enqueues an audit event without waiting for the underlying publisher.
|
|
// If the bounded queue is full, the event is dropped and counted.
|
|
func (p *AsyncPublisher) Publish(ctx context.Context, payload audit.Payload) {
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
detachedCtx = logging.ContextWithLogger(detachedCtx, logging.FromContext(ctx))
|
|
msg := audit.NewEventMessage(detachedCtx, p.appName, payload)
|
|
|
|
p.mu.RLock()
|
|
if p.closed {
|
|
onDrop := p.cfg.onDrop
|
|
p.mu.RUnlock()
|
|
|
|
p.dropped.Add(1)
|
|
logger := logging.FromContext(ctx)
|
|
logger.WithField("audit_payload_id", payload.ID).Errorf("failed to enqueue audit message: %v", ErrAsyncPublisherClosed)
|
|
if onDrop != nil {
|
|
onDrop(ctx, payload)
|
|
}
|
|
return
|
|
}
|
|
|
|
select {
|
|
case p.queue <- asyncAuditEvent{
|
|
ctx: detachedCtx,
|
|
payload: payload,
|
|
message: msg,
|
|
}:
|
|
p.mu.RUnlock()
|
|
p.enqueued.Add(1)
|
|
default:
|
|
onDrop := p.cfg.onDrop
|
|
p.mu.RUnlock()
|
|
|
|
p.dropped.Add(1)
|
|
logger := logging.FromContext(ctx)
|
|
logger.WithField("audit_payload_id", payload.ID).Errorf("audit publish queue full, dropping audit message")
|
|
if onDrop != nil {
|
|
onDrop(ctx, payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close stops accepting new events and waits for workers to drain queued events
|
|
// until ctx is canceled.
|
|
func (p *AsyncPublisher) Close(ctx context.Context) error {
|
|
p.mu.Lock()
|
|
if !p.closed {
|
|
p.closed = true
|
|
close(p.queue)
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
p.wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Stats returns a snapshot of async audit publishing counters.
|
|
func (p *AsyncPublisher) Stats() AsyncPublisherStats {
|
|
return AsyncPublisherStats{
|
|
Enqueued: p.enqueued.Load(),
|
|
Dropped: p.dropped.Load(),
|
|
Published: p.published.Load(),
|
|
PublishErrors: p.publishErrors.Load(),
|
|
}
|
|
}
|
|
|
|
func (p *AsyncPublisher) worker() {
|
|
defer p.wg.Done()
|
|
|
|
for event := range p.queue {
|
|
if err := p.publisher.Publish(p.topicName, event.message); err != nil {
|
|
p.publishErrors.Add(1)
|
|
logger := logging.FromContext(event.ctx)
|
|
logger.WithField("audit_payload_id", event.payload.ID).Errorf("failed to publish audit message asynchronously: %v", err)
|
|
if p.cfg.onError != nil {
|
|
p.cfg.onError(event.ctx, event.payload, err)
|
|
}
|
|
continue
|
|
}
|
|
p.published.Add(1)
|
|
}
|
|
}
|