sofetch-0.1.0.0: src/Fetch.hs
{-# LANGUAGE TypeFamilies #-}
-- | Automatic batching and deduplication of concurrent data fetches.
--
-- = Background
--
-- Any service that assembles responses from multiple data sources runs into
-- the same problem: you write sequential-looking code, but the access
-- pattern it produces is terrible. Fetching a user, then their posts, then
-- the author of each post, produces a cascade of round trips: the classic
-- N+1 query problem, generalised across arbitrary backends.
--
-- Facebook's [Haxl](https://github.com/facebook/Haxl) library (Marlow et al.,
-- /\"There is no Fork: an Abstraction for Efficient, Concurrent, and Concise
-- Data Access\"/, ICFP 2014) solved this by exploiting @Applicative@ to
-- detect independent data fetches and batch them into a single round.
-- Code that /looks/ sequential gets automatically optimised into concurrent,
-- batched requests, with request deduplication and caching for free.
--
-- This library keeps Haxl's core idea while simplifying the machinery
-- required to use it:
--
-- * __No GADTs in user code.__ Haxl encodes the request\/response type
-- pairing with a GADT indexed by the result type. Here, an associated
-- type family ('Result') on an ordinary typeclass ('FetchKey') does the
-- same job. Your key types derive 'Eq', 'Hashable', and 'Show' with
-- stock @deriving@.
--
-- * __Data sources run in your monad.__ 'DataSource' is parameterised by
-- a monad @m@, not a concrete environment type. If your data source
-- needs a connection pool, @m@ should be a monad with access to one
-- (e.g. via 'MonadReader'). If the instance doesn't exist, code that
-- tries to @fetch@ a key won't compile. No runtime "missing config"
-- errors.
--
-- * __Monad transformer, not a concrete monad.__ Haxl's @GenHaxl u w@ is
-- a fixed monad. 'Fetch' is a transformer over your source monad @m@.
-- Two natural transformations (@m x -> IO x@ and @IO x -> m x@) provided
-- at the run site bridge the source monad with IO for internal
-- concurrency and caching.
--
-- * __Swappable implementations via 'MonadFetch'.__ Production code,
-- traced instrumentation, and pure mock testing all share the same
-- interface. Application functions are polymorphic over the implementation.
--
-- = How batching works
--
-- 'Fetch' has an 'Applicative' instance that /merges/ the pending fetches
-- from both sides of @\<*\>@ into one round, and a 'Monad' instance where
-- @>>=@ is a round boundary (the right side can't run until the left side's
-- results are available).
--
-- With @ApplicativeDo@ enabled, GHC desugars @do@-blocks into 'Applicative'
-- combinators wherever the data dependencies allow it. Two @fetch@ calls
-- whose results are independent of each other will be combined into a
-- single batch, even though they appear on separate lines:
--
-- @
-- {-# LANGUAGE ApplicativeDo #-}
--
-- getUserFeed :: (MonadFetch m n, DataSource m UserId, DataSource m PostsByAuthor)
-- => UserId -> n Feed
-- getUserFeed uid = do
-- user <- fetch uid -- ─┐
-- posts <- fetch (PostsByAuthor uid) -- ─┤ same round
-- pure (Feed user posts)
-- @
--
-- If a later fetch /depends/ on an earlier result, @>>=@ forces a round
-- boundary and the fetches run in sequence:
--
-- @
-- getUserThenManager :: (MonadFetch m n, DataSource m UserId)
-- => UserId -> n (User, User)
-- getUserThenManager uid = do
-- user <- fetch uid -- round 1
-- manager <- fetch (managerId user) -- round 2 (depends on user)
-- pure (user, manager)
-- @
--
-- Within each round, keys destined for the same data source are grouped and
-- passed to 'batchFetch' together. Keys for /different/ sources run
-- concurrently by default (see 'FetchStrategy'). Duplicate keys are
-- deduplicated across the entire computation.
--
-- = Tutorial
--
-- == Step 1: Define your key types
--
-- Each type of data you want to fetch gets its own key type with a
-- 'FetchKey' instance that declares the result type:
--
-- @
-- newtype UserId = UserId Int
-- deriving (Eq, Hashable, Show)
--
-- instance FetchKey UserId where
-- type Result UserId = User
--
-- newtype PostsByAuthor = PostsByAuthor Int
-- deriving (Eq, Hashable, Show)
--
-- instance FetchKey PostsByAuthor where
-- type Result PostsByAuthor = [Post]
-- @
--
-- Each key type maps to exactly one result type. Separate types per query
-- give you stock @deriving@, first-class 'Data.HashMap.Strict.HashMap' keys, and
-- precise constraints: a function's type signature advertises exactly
-- which data sources it touches.
--
-- == Step 2: Define your data sources
--
-- A 'DataSource' instance tells the engine how to batch-fetch a list of
-- keys. The monad @m@ provides any resources the source needs:
--
-- @
-- data AppEnv = AppEnv
-- { appPool :: ConnectionPool
-- , appRedis :: RedisConn
-- }
--
-- -- AppM is a ReaderT-like monad carrying the environment.
-- newtype AppM a = AppM (ReaderT AppEnv IO a)
--
-- instance DataSource AppM UserId where
-- batchFetch ids = do
-- pool <- asks appPool
-- liftIO $ withResource pool $ \\conn -> do
-- rows <- query conn \"SELECT id, name FROM users WHERE id = ANY(?)\" (Only ids)
-- pure (HM.fromList [(UserId i, User i n) | (i, n) <- rows])
--
-- instance DataSource AppM PostsByAuthor where
-- batchFetch ks = do
-- pool <- asks appPool
-- liftIO $ withResource pool $ \\conn -> do
-- let authorIds = [aid | PostsByAuthor aid <- ks]
-- rows <- query conn \"SELECT author_id, id, body FROM posts WHERE author_id = ANY(?)\" (Only authorIds)
-- let grouped = HM.fromListWith (<>) [(PostsByAuthor aid, [Post pid body]) | (aid, pid, body) <- rows]
-- pure grouped
-- @
--
-- The return type is @'Data.HashMap.Strict.HashMap' k ('Result' k)@: you must return
-- a result for every key you were given. The engine handles concurrency,
-- caching, and error wrapping around your function.
--
-- If the @DataSource AppM SomeKey@ instance doesn't exist, any code that
-- tries to @fetch@ a @SomeKey@ will fail to compile. There are no runtime
-- \"missing config\" errors.
--
-- == Step 3: Write data-access code
--
-- Program against the 'MonadFetch' constraint. Don't commit to a specific
-- implementation. This is what makes the same code runnable in production
-- and in tests:
--
-- @
-- {-# LANGUAGE ApplicativeDo #-}
--
-- getUserFeed :: (MonadFetch m n, DataSource m UserId, DataSource m PostsByAuthor)
-- => UserId -> n Feed
-- getUserFeed uid = do
-- user <- fetch uid
-- posts <- fetch (PostsByAuthor uid)
-- pure (Feed user posts)
-- @
--
-- For fetching across collections, use the provided combinators to preserve
-- the container shape without manual destructure\/reconstruct cycles:
--
-- @
-- enrichComments :: (MonadFetch m n, DataSource m CommentAuthor)
-- => [Comment] -> n [(Comment, User)]
-- enrichComments = fetchThrough commentAuthor
-- @
--
-- == Step 4: Run it
--
-- In production, use 'runFetch' with two natural transformations: one
-- to lower @m@ to @IO@, and one to lift @IO@ into @m@:
--
-- @
-- handleRequest :: AppEnv -> UserId -> IO Feed
-- handleRequest env uid =
-- runAppM env $ runFetch (runAppM env) liftIO (getUserFeed uid)
-- @
--
-- For monads that deliberately avoid 'MonadIO' (e.g. a @Transaction@
-- type), export a convenience runner that hides the unsafe nats:
--
-- @
-- fetchInTransaction :: Fetch Transaction a -> Transaction a
-- fetchInTransaction = runFetch unsafeRunTransaction unsafeLiftIO
-- @
--
-- == Step 5: Test it
--
-- Use 'MockFetch' to run the same code against canned data, with no IO,
-- no database, and no cache:
--
-- @
-- testGetUserFeed :: IO ()
-- testGetUserFeed = do
-- let mocks = mockData \@UserId [(UserId 1, testUser)]
-- <> mockData \@PostsByAuthor [(PostsByAuthor 1, [testPost])]
-- feed <- runMockFetch \@AppM mocks (getUserFeed (UserId 1))
-- assertEqual (feedUser feed) testUser
-- @
--
-- Because @getUserFeed@ is polymorphic in @n@, no code changes are needed
-- to swap between 'Fetch' (production) and 'MockFetch' (tests).
--
-- = Error handling
--
-- If 'batchFetch' throws for a subset of keys, the engine fills unfilled
-- entries with the exception. Callers using 'fetch' see the exception
-- re-thrown; callers using 'tryFetch' receive @Left SomeException@.
-- Failures for one key do not affect other keys in the same batch.
--
-- All monad transformers ('Fetch', 'TracedFetch', 'Mutate',
-- 'MockFetch', 'MockMutate') provide @MonadThrow@ and @MonadCatch@
-- instances from the @exceptions@ package. The 'MonadCatch' instance
-- on 'Fetch' propagates the handler through 'Blocked' continuations,
-- so a @catch@ wrapping a multi-round computation catches exceptions
-- thrown in any round, not just the initial probe.
--
-- @
-- import "Control.Monad.Catch" ('Control.Monad.Catch.catch', 'Control.Monad.Catch.throwM')
--
-- safeFetch :: ('MonadFetch' m n, 'Control.Monad.Catch.MonadCatch' n, DataSource m k, Typeable (Result k))
-- => k -> Result k -> n (Result k)
-- safeFetch k fallback =
-- 'Control.Monad.Catch.catch' (fetch k) (\\(_ :: SomeException) -> pure fallback)
-- @
--
-- @MonadMask@ is intentionally not provided: async exception masking
-- across batch round boundaries is not well-defined.
--
-- = Further reading
--
-- * 'FetchStrategy': control whether a source runs concurrently,
-- sequentially, or with eager start.
-- * 'CachePolicy': opt out of caching for mutation-like sources.
-- * 'TracedFetch': round-by-round observability hooks.
-- * 'runLoopWith': build custom instrumented runners (e.g. for
-- OpenTelemetry) by wrapping around each batch round.
-- * 'MemoStore': cache derived computations (not just raw fetches).
-- * The @docs/DESIGN.md@ in the repository covers the full set of design
-- decisions and tradeoffs relative to Haxl.
module Fetch
( -- * Defining data sources
-- | Start here. A 'FetchKey' pairs a key type with its result type;
-- a 'DataSource' teaches the engine how to batch-fetch those keys.
FetchKey(..)
, DataSource(..)
, FetchStrategy(..)
, CachePolicy(..)
-- * Fetching data
-- | The interface your application code programs against.
-- Use 'fetch' to request a single key, 'tryFetch' for explicit error
-- handling, and the combinators below for collections.
, MonadFetch(..)
, fetchAll
, fetchWith
, fetchThrough
, fetchMap
, fetchMaybe
, fetchMapWith
-- * Running
-- | Execute a 'MonadFetch' computation via 'FetchConfig'.
, Fetch
, FetchConfig(..)
, fetchConfig
, fetchConfigIO
, liftSource
, runFetch
, runFetch'
-- * Testing
-- | Swap 'Fetch' for 'MockFetch' to run the same polymorphic code
-- against canned data: no IO, no database, no cache.
, MockFetch
, runMockFetch
, ResultMap
, mockData
, emptyMockData
-- * Mutations
-- | Mutations model write operations: creating a row, publishing a
-- message, calling a side-effecting RPC. Unlike fetches, mutations are
-- never batched, deduplicated, or cached: each 'mutate' call executes
-- exactly once, in order.
--
-- 'Mutate' layers on top of 'Fetch'. A computation alternates between
-- __fetch phases__ (where reads batch normally via 'Applicative') and
-- __mutation steps__ (where writes run sequentially). After each
-- mutation, 'reconcileCache' lets you evict stale entries or warm
-- fresh data so that subsequent fetches see the updated state.
--
-- __Caveat:__ by mixing reads and writes in the same computation, you
-- take on the responsibility of keeping the fetch cache coherent.
-- The engine cannot know which cached entries a mutation invalidates;
-- that is domain knowledge only you have. If you forget to evict or
-- re-warm a stale entry in 'reconcileCache', subsequent fetches will
-- silently return the old value. For many applications the simpler
-- approach is to keep mutations in plain @IO@ and use 'Fetch' only
-- for the read path; 'Mutate' is there for cases where interleaved
-- read-after-write within a single computation is genuinely needed.
-- ** Defining mutations
, MutationKey(..)
, MutationSource(..)
-- ** Running mutations
, MonadMutate(..)
, Mutate
, runMutate
, liftFetch
-- ** Testing mutations
, MockMutate
, runMockMutate
, MutationHandlers
, mockMutation
, emptyMutationHandlers
, RecordedMutation(..)
-- * Cache management
-- | Most users never touch the cache directly; the engine manages it.
-- These are useful for pre-warming from an external store, selective
-- eviction after mutations, or sharing a cache across sequential phases.
, CacheRef
, newCacheRef
, CacheLookup(..)
, cacheLookup
, cacheInsert
, cacheInsertError
, cacheEvict
, cacheEvictSource
, cacheEvictWhere
, cacheWarm
, cacheContents
-- * Tracing and observability
-- | 'TracedFetch' is a turnkey wrapper with per-round callbacks.
-- For richer instrumentation (e.g. OpenTelemetry spans), build a
-- custom runner using the extension API below.
, TracedFetch
, TraceConfig(..)
, defaultTraceConfig
, FetchStats(..)
, runTracedFetch
-- * Memoization
-- | Cache derived computations (not just raw fetches) within a request.
, MemoKey(..)
, MemoStore
, newMemoStore
, memo
, memoOn
-- * Errors
, FetchError(..)
-- * Extension API
-- | Building blocks for custom runners and instrumentation.
-- Application code does not need anything from this section.
--
-- The simplest way to add instrumentation is 'runLoopWith', which
-- lets you wrap each batch round with before\/after logic (e.g.
-- opening and closing a tracing span):
--
-- @
-- import Fetch.Batched ('FetchEnv'(..), 'runLoopWith')
-- import Fetch.Engine ('RoundStats'(..))
--
-- myInstrumentedRunner :: Monad m
-- => (forall x. m x -> IO x)
-- -> (forall x. IO x -> m x)
-- -> Fetch m a -> m a
-- myInstrumentedRunner lower lift action = do
-- cRef <- lift 'newCacheRef'
-- let e = 'FetchEnv' cRef lower lift
-- 'runLoopWith' e (\\n batches exec -> do
-- -- before round
-- stats <- exec
-- -- after round, stats :: 'RoundStats'
-- pure ()
-- ) action
-- @
--
-- For full control (e.g. running entirely in @IO@ with a single
-- @lift@ at the boundary), use 'Fetch'\'s constructor, 'FetchEnv',
-- and 'executeBatches' directly.
-- ** Loop helpers
, FetchEnv(..)
, runLoop
, runLoopWith
, RoundStats(..)
, emptyRoundStats
-- ** Batch inspection
, MonadFetchBatch(..)
, Status(..)
, Batches(..)
, batchSize
, batchSourceCount
-- ** Engine
, executeBatches
-- * Instance helpers
-- | Combinators for implementing 'DataSource' from simpler primitives.
-- See also 'fetchOne' (a default method on 'DataSource') and the
-- "Fetch.Deriving" module for DerivingVia patterns.
, optionalBatchFetch
, traverseBatchFetch
-- * Re-exports
, Typeable
, Hashable
, NonEmpty(..)
, Proxy(..)
) where
import Fetch.Class
import Fetch.Batched
( Fetch, FetchConfig(..), fetchConfig, fetchConfigIO, FetchEnv(..), liftSource
, runFetch, runFetch'
, runLoop, runLoopWith
)
import Fetch.Cache
( CacheRef, newCacheRef
, CacheLookup(..), cacheLookup
, cacheInsert, cacheInsertError
, cacheEvict, cacheEvictSource, cacheEvictWhere
, cacheWarm, cacheContents
)
import Fetch.Combinators
import Fetch.Deriving (optionalBatchFetch, traverseBatchFetch)
import Fetch.Engine (RoundStats(..), emptyRoundStats, executeBatches)
import Fetch.Mutate
( MutationSource(..), MonadMutate(..)
, Mutate, runMutate, liftFetch
)
import Fetch.Mock
( MockFetch, runMockFetch, ResultMap, mockData, emptyMockData
, MockMutate, runMockMutate, MutationHandlers, mockMutation
, emptyMutationHandlers, RecordedMutation(..)
)
import Fetch.Traced (TracedFetch, TraceConfig(..), defaultTraceConfig, FetchStats(..), runTracedFetch)
import Fetch.Memo (MemoKey(..), MemoStore, newMemoStore, memo, memoOn)
import Fetch.IVar (FetchError(..))