keiro-pgmq-0.2.0.0: src/Keiro/PGMQ/Codec.hs
{- | How a job payload is turned into PGMQ JSON and back.
PGMQ stores message bodies as JSON ('Data.Aeson.Value'). A 'JobCodec' is the
adapter between a domain payload type @p@ and that JSON. Two are provided:
* 'aesonJobCodec' - raw @aeson@ encode/decode. A drop-in match for apps that
already use @ToJSON@/@FromJSON@ payloads, so migrating to @keiro-pgmq@ is
mechanical.
* 'keiroJobCodec' - the versioned upgrade. It bridges keiro's
'Keiro.Codec.Codec' by wrapping payloads in a @{ "v": <version>, "t":
<event-type>, "data": <payload> }@ envelope and replaying the codec's
upcaster chain on decode, giving job payloads the same schema-evolution
story event streams have.
When raising a 'keiroJobCodec' schema version, deploy upgraded workers before
upgraded producers. A worker that sees an envelope from a future schema version
returns 'JobPayloadFromFuture', and the job runner retries it after the queue's
default retry delay so a rolling deploy can complete. Those retries still
consume delivery attempts, so size @maxRetries * defaultRetryDelay@ to cover
the deploy window.
Do not switch a non-empty queue directly from 'aesonJobCodec' to
'keiroJobCodec': the wire shape changes from the bare payload to the
@{"v","t","data"}@ envelope. Drain the queue first or use a transitional codec that
accepts both shapes; otherwise old in-flight messages are malformed and will be
dead-lettered.
-}
module Keiro.PGMQ.Codec (
JobCodec (..),
JobDecodeError (..),
mkJobCodec,
aesonJobCodec,
keiroJobCodec,
) where
import "aeson" Data.Aeson (FromJSON, ToJSON, Value, object, parseJSON, toJSON, withObject, (.:), (.:?), (.=))
import "aeson" Data.Aeson.Types (parseEither)
import "base" Data.Bifunctor (first)
import "base" Data.List.NonEmpty qualified as NonEmpty
import "keiro-core" Keiro.Codec (Codec, EventType (..))
import "keiro-core" Keiro.Codec qualified as Codec
import "text" Data.Text (Text)
import "text" Data.Text qualified as Text
-- | Why a job payload could not be decoded.
data JobDecodeError
= -- | The payload is malformed for this codec: poison, dead-letter it.
JobPayloadMalformed !Text
| {- | The payload was written by a newer schema version than this worker knows.
Carries payload version, then this codec's version. This is transient
during a rolling deploy: retry it, do not dead-letter it.
-}
JobPayloadFromFuture !Int !Int
deriving stock (Eq, Show)
-- | How a job payload is turned into PGMQ JSON and back.
data JobCodec p = JobCodec
{ encodeJob :: p -> Value
, decodeJob :: Value -> Either JobDecodeError p
}
-- | Build a 'JobCodec' from an encoder and a legacy text-returning decoder.
mkJobCodec :: (p -> Value) -> (Value -> Either Text p) -> JobCodec p
mkJobCodec encode decode =
JobCodec
{ encodeJob = encode
, decodeJob = first JobPayloadMalformed . decode
}
{- | The default codec: raw @aeson@. Use this when the payload type already has
@ToJSON@/@FromJSON@ instances and you do not need versioned schema evolution.
-}
aesonJobCodec :: (ToJSON p, FromJSON p) => JobCodec p
aesonJobCodec = mkJobCodec toJSON (first Text.pack . parseEither parseJSON)
{- | Versioned bridge to keiro's 'Keiro.Codec.Codec'. On encode it produces
@{ "v": <schemaVersion>, "t": <event-type>, "data": <encode codec p> }@; on
decode it reads the version and event-type tag, runs the codec's upcaster chain
up to the current version via 'Keiro.Codec.migrateToCurrent', then runs the
codec's current decoder. Legacy envelopes without @"t"@ still decode for
single-event codecs. Codec migration faults are surfaced as
'JobPayloadMalformed' (via 'show' of the 'Keiro.Codec.CodecError'), except for a
future envelope version, which surfaces as 'JobPayloadFromFuture'.
-}
keiroJobCodec :: Codec p -> JobCodec p
keiroJobCodec codec =
JobCodec
{ encodeJob = \p ->
let EventType tag = Codec.eventType codec p
in object
[ "v" .= Codec.schemaVersion codec
, "t" .= tag
, "data" .= Codec.encode codec p
]
, decodeJob = \value -> do
(version, tag, dataValue) <- parseEnvelope value
selectedType <- resolveEventType codec tag
let currentVersion = Codec.schemaVersion codec
if version > currentVersion
then Left (JobPayloadFromFuture version currentVersion)
else pure ()
migrated <-
first (JobPayloadMalformed . Text.pack . show) $
Codec.migrateToCurrent codec selectedType version dataValue
first JobPayloadMalformed (Codec.decode codec selectedType migrated)
}
-- | Parse the @{ "v", "t", "data" }@ envelope, surfacing aeson failures as malformed payloads.
parseEnvelope :: Value -> Either JobDecodeError (Int, Maybe EventType, Value)
parseEnvelope =
first (JobPayloadMalformed . Text.pack) . parseEither parser
where
parser = withObject "Keiro.PGMQ.Codec envelope" $ \o -> do
version <- o .: "v"
tag <- fmap EventType <$> o .:? "t"
dataValue <- o .: "data"
pure (version, tag, dataValue)
resolveEventType :: Codec p -> Maybe EventType -> Either JobDecodeError EventType
resolveEventType _ (Just tag) = Right tag
resolveEventType codec Nothing =
case NonEmpty.toList (Codec.eventTypes codec) of
[tag] -> Right tag
tags ->
Left
. JobPayloadMalformed
$ "missing event-type tag in multi-event keiro job envelope; expected one of "
<> Text.intercalate ", " (fmap eventTypeText tags)
where
eventTypeText (EventType tag) = tag