packages feed

shibuya-core-0.11.0.0: src/Shibuya/Internal/Runner/Ingester.hs

-- | __Internal module.__ Exposed for the test suite and benchmarks only.
-- No PVP guarantees: anything here may change or disappear in any release.
-- Application authors should import "Shibuya" instead.
--
-- Ingester - reads from adapter stream and sends to inbox.
-- Provides backpressure via bounded inbox.
module Shibuya.Internal.Runner.Ingester
  ( runIngester,
    runIngesterWithMetrics,
    runIngesterWithMetricsUnpaused,
  )
where

import Control.Concurrent.Async qualified as Async
import Control.Concurrent.NQE.Process (Inbox, inboxToMailbox, send)
import Control.Concurrent.STM (atomically, newEmptyTMVarIO, orElse, putTMVar, readTMVar, retry, throwSTM)
import Control.Exception qualified as IOException
import Effectful (Eff, IOE, UnliftStrategy (..), liftIO, withEffToIO, (:>))
import Effectful.Dispatch.Static (unEff, unsafeEff)
import Effectful.Exception qualified as Exception
import OpenTelemetry.Context qualified as TraceContext
import OpenTelemetry.Context.ThreadLocal qualified as TraceContext
import OpenTelemetry.Trace.Core qualified as OTel
import Shibuya.Core.Ingested (Ingested)
import Shibuya.Core.Metrics (MetricsHandle, ProcessorId (..), incrementReceived)
import Shibuya.Internal.Runner.Pause (PauseHandle, waitWhilePaused)
import Shibuya.Telemetry.Effect (Tracing, getTracer, isTracingEnabled)
import Shibuya.Telemetry.Semantic (attrShibuyaProcessorId, ingestSpanName, internalSpanArgs)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
import UnliftIO qualified as UIO

-- | Run the ingester: reads from stream, sends to inbox.
-- Sends each element to the inbox with backpressure - if inbox is full, blocks.
-- Completes when the stream completes.
runIngester ::
  (IOE :> es) =>
  -- | Source stream from adapter
  Stream (Eff es) (Ingested es msg) ->
  -- | Target inbox (bounded for backpressure)
  Inbox (Ingested es msg) ->
  Eff es ()
runIngester source inbox = do
  let mailbox = inboxToMailbox inbox
  -- Send each stream element to the mailbox, then drain
  -- This provides backpressure: if mailbox is full (bounded), send blocks
  Stream.fold Fold.drain $
    Stream.mapM
      (\msg -> send msg mailbox >> pure msg)
      source

-- | Run the ingester with metrics tracking.
-- Increments the received count for each message sent to inbox.
runIngesterWithMetrics ::
  (IOE :> es, Tracing :> es) =>
  ProcessorId ->
  -- | Metrics handle (for updating received count)
  MetricsHandle ->
  PauseHandle ->
  -- | Source stream from adapter
  Stream (Eff es) (Ingested es msg) ->
  -- | Target inbox (bounded for backpressure)
  Inbox (Ingested es msg) ->
  Eff es ()
runIngesterWithMetrics procId metricsHandle pauseHandle source inbox =
  traceIngestion procId $
    -- The fold and admission check use IO directly, avoiding an Eff action
    -- allocation for each step. Safe use of unsafeEff: IOE is required, and
    -- only this sequential fold calls unEff, on the owning ingester thread.
    -- The fold neither forks nor exports this environment or callback.
    unsafeEff $ \environment -> do
      let mailbox = inboxToMailbox inbox
      Stream.fold Fold.drain $
        Stream.mapM
          ( \msg -> do
              incrementReceived metricsHandle
              send msg mailbox
              pure msg
          )
          (Stream.morphInner (\step -> waitWhilePaused pauseHandle >> unEff step environment) source)

-- | Metrics and tracing for an intake that has no live pause capability.
runIngesterWithMetricsUnpaused ::
  (IOE :> es, Tracing :> es) =>
  ProcessorId ->
  MetricsHandle ->
  Stream (Eff es) (Ingested es msg) ->
  Inbox (Ingested es msg) ->
  Eff es ()
runIngesterWithMetricsUnpaused procId metricsHandle source inbox =
  traceIngestion procId ingest
  where
    ingest = do
      let mailbox = inboxToMailbox inbox
      Stream.fold Fold.drain $
        Stream.mapM
          ( \msg -> do
              liftIO $ incrementReceived metricsHandle
              liftIO $ send msg mailbox
              pure msg
          )
          source

traceIngestion ::
  (IOE :> es, Tracing :> es) =>
  ProcessorId ->
  Eff es () ->
  Eff es ()
traceIngestion procId action = do
  enabled <- isTracingEnabled
  if not enabled
    then action
    else do
      tracer <- getTracer
      -- Only the owner unlifts source effects. The SDK worker is entirely IO,
      -- so a sequential unlift preserves the source's thread and local state.
      withEffToIO SeqUnlift $ \runInIO -> UIO.mask $ \restore -> do
        context <- TraceContext.getContext
        ready <- newEmptyTMVarIO
        finished <- newEmptyTMVarIO
        -- SDK callbacks catch cancellation. Isolate them from source ownership:
        -- after cancellation the worker can only observe the published outcome,
        -- never enter the source even if onStart consumes its cancellation.
        let trace = UIO.bracket (TraceContext.attachContext context) TraceContext.detachContext $ \_ ->
              OTel.inSpan' tracer ingestSpanName internalSpanArgs $ \sp -> do
                OTel.addAttribute sp attrShibuyaProcessorId procId.unProcessorId
                atomically $ putTMVar ready sp
                result <- atomically $ readTMVar finished
                either IOException.throwIO pure result
        UIO.withAsync trace $ \worker -> do
          result <- IOException.try @IOException.SomeException $ restore $ do
            sp <- atomically $ readTMVar ready `orElse` (Async.waitCatchSTM worker >>= either throwSTM (const retry))
            UIO.bracket (TraceContext.attachContext $ TraceContext.insertSpan sp context) TraceContext.detachContext $ \_ ->
              runInIO action
          atomically $ putTMVar finished result
          case result of
            -- withAsync still cancels and joins the worker on this path.
            Left failure | Exception.isAsyncException failure -> IOException.throwIO failure
            _ -> do
              traced <- restore $ UIO.waitCatch worker
              either IOException.throwIO (const $ either IOException.throwIO pure traced) result