2026-07-17 21:43:03 +09:00
|
|
|
-- | Short Postgres transactions for mutation authority; store I/O stays outside them.
|
|
|
|
|
module Nuthatch.Protocol
|
|
|
|
|
( AdmitOutcome (..)
|
2026-07-19 19:13:17 +09:00
|
|
|
, AdmissionIdentity (..)
|
|
|
|
|
, MutationFingerprint (..)
|
|
|
|
|
, AdmissionRequest (..)
|
|
|
|
|
, AdmissionLimits (..)
|
|
|
|
|
, DispatchTiming (..)
|
|
|
|
|
, PriorObject (..)
|
|
|
|
|
, DesiredObject (..)
|
2026-07-18 21:25:50 +09:00
|
|
|
, ExistingDisposition (..)
|
2026-07-17 21:43:03 +09:00
|
|
|
, MutationRecord (..)
|
2026-07-19 19:13:17 +09:00
|
|
|
, MutationPhase (..)
|
|
|
|
|
, TerminalResult (..)
|
|
|
|
|
, mutationState
|
|
|
|
|
, externalState
|
|
|
|
|
, encodeMutationPhase
|
|
|
|
|
, decodeMutationPhase
|
|
|
|
|
, mrState
|
|
|
|
|
, mrExternal
|
|
|
|
|
, mrUserId
|
|
|
|
|
, mrKind
|
|
|
|
|
, mrOperationId
|
|
|
|
|
, mrObjectKey
|
|
|
|
|
, mrDeclaredSize
|
|
|
|
|
, mrContentSha256
|
|
|
|
|
, mrPriorSize
|
|
|
|
|
, mrPriorSha256
|
|
|
|
|
, desiredObject
|
2026-07-17 21:43:03 +09:00
|
|
|
, ObjectObservation (..)
|
|
|
|
|
, admitMutation
|
|
|
|
|
, markApplying
|
|
|
|
|
, markExternalPending
|
|
|
|
|
, markExternalSettled
|
|
|
|
|
, markSettledFromIdle
|
|
|
|
|
, finalizeSuccess
|
|
|
|
|
, finalizeFailure
|
|
|
|
|
, claimActiveBatch
|
|
|
|
|
, lookupMutation
|
2026-07-18 23:52:45 +09:00
|
|
|
, mutationEpochExists
|
2026-07-17 21:43:03 +09:00
|
|
|
, lookupObjectMeta
|
2026-07-18 00:54:47 +09:00
|
|
|
, sameFingerprint
|
2026-07-18 21:25:50 +09:00
|
|
|
, existingDisposition
|
2026-07-18 00:54:47 +09:00
|
|
|
, growthBytes
|
|
|
|
|
, quotaAllows
|
|
|
|
|
, terminalExpiryEligible
|
2026-07-18 21:25:50 +09:00
|
|
|
, terminalExpired
|
2026-07-18 00:54:47 +09:00
|
|
|
, recoveryEligible
|
2026-07-17 21:43:03 +09:00
|
|
|
, desiredMatches
|
|
|
|
|
, priorMatches
|
2026-07-18 15:22:57 +09:00
|
|
|
, finalizeFromObservation
|
2026-07-17 21:43:03 +09:00
|
|
|
) where
|
|
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
import Control.Exception (throwIO)
|
|
|
|
|
import Data.Maybe (listToMaybe)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Data.Text (Text)
|
2026-07-19 19:13:17 +09:00
|
|
|
import qualified Data.Text as T
|
2026-07-17 21:43:03 +09:00
|
|
|
import Database.PostgreSQL.Simple (Connection, Only (..), execute, query)
|
2026-07-19 19:13:17 +09:00
|
|
|
import qualified Database.PostgreSQL.Simple.FromField as PG
|
|
|
|
|
import qualified Database.PostgreSQL.Simple.FromRow as PGRow
|
|
|
|
|
import qualified Database.PostgreSQL.Simple.ToField as PGToField
|
|
|
|
|
import qualified Database.PostgreSQL.Simple.ToRow as PGToRow
|
2026-07-17 21:43:03 +09:00
|
|
|
import Nuthatch.Types
|
|
|
|
|
|
|
|
|
|
data MutationRecord = MutationRecord
|
2026-07-19 19:13:17 +09:00
|
|
|
{ mrAdmissionIdentity :: AdmissionIdentity
|
|
|
|
|
, mrPoolId :: PoolId
|
|
|
|
|
, mrRelativePath :: RelativePath
|
|
|
|
|
, mrFingerprint :: MutationFingerprint
|
|
|
|
|
, mrPriorObject :: PriorObject
|
|
|
|
|
, mrGrowth :: Integer
|
|
|
|
|
, mrPhase :: MutationPhase
|
2026-07-17 21:43:03 +09:00
|
|
|
}
|
2026-07-19 19:13:17 +09:00
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data MutationPhase
|
|
|
|
|
= PhasePrepared
|
|
|
|
|
| PhaseApplying ExternalState
|
|
|
|
|
| PhaseTerminal TerminalResult
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data TerminalResult
|
|
|
|
|
= TerminalSucceeded
|
|
|
|
|
| TerminalFailed
|
|
|
|
|
| TerminalRejected
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
mutationState :: MutationPhase -> MutationState
|
|
|
|
|
mutationState = \case
|
|
|
|
|
PhasePrepared -> Prepared
|
|
|
|
|
PhaseApplying _ -> Applying
|
|
|
|
|
PhaseTerminal TerminalSucceeded -> Succeeded
|
|
|
|
|
PhaseTerminal TerminalFailed -> Failed
|
|
|
|
|
PhaseTerminal TerminalRejected -> Rejected
|
|
|
|
|
|
|
|
|
|
externalState :: MutationPhase -> ExternalState
|
|
|
|
|
externalState = \case
|
|
|
|
|
PhaseApplying external -> external
|
|
|
|
|
_ -> ExtIdle
|
|
|
|
|
|
|
|
|
|
encodeMutationPhase :: MutationPhase -> (MutationState, ExternalState)
|
|
|
|
|
encodeMutationPhase phase = (mutationState phase, externalState phase)
|
|
|
|
|
|
|
|
|
|
decodeMutationPhase :: MutationState -> ExternalState -> Maybe MutationPhase
|
|
|
|
|
decodeMutationPhase state external =
|
|
|
|
|
case (state, external) of
|
|
|
|
|
(Prepared, ExtIdle) -> Just PhasePrepared
|
|
|
|
|
(Applying, value) -> Just (PhaseApplying value)
|
|
|
|
|
(Succeeded, ExtIdle) ->
|
|
|
|
|
Just (PhaseTerminal TerminalSucceeded)
|
|
|
|
|
(Failed, ExtIdle) ->
|
|
|
|
|
Just (PhaseTerminal TerminalFailed)
|
|
|
|
|
(Rejected, ExtIdle) ->
|
|
|
|
|
Just (PhaseTerminal TerminalRejected)
|
|
|
|
|
_ -> Nothing
|
|
|
|
|
|
|
|
|
|
mrState :: MutationRecord -> MutationState
|
|
|
|
|
mrState = mutationState . mrPhase
|
|
|
|
|
|
|
|
|
|
mrExternal :: MutationRecord -> ExternalState
|
|
|
|
|
mrExternal = externalState . mrPhase
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
-- | Unavailable must not drive terminal finalize.
|
|
|
|
|
data ObjectObservation
|
|
|
|
|
= ObjectPresent Integer (Maybe ContentSha256)
|
|
|
|
|
| ObjectAbsent
|
|
|
|
|
| ObjectUnavailable Text
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data AdmitOutcome
|
|
|
|
|
= AdmitPrepared MutationRecord
|
|
|
|
|
| AdmitReplay MutationResult MutationRecord
|
|
|
|
|
| AdmitRejected Text MutationRecord
|
|
|
|
|
| AdmitConflict
|
|
|
|
|
deriving stock (Show)
|
|
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
data AdmissionIdentity = AdmissionIdentity
|
|
|
|
|
{ admissionUserId :: UserId
|
|
|
|
|
, admissionKind :: MutationKind
|
|
|
|
|
, admissionOperationId :: OperationId
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data MutationFingerprint = MutationFingerprint
|
|
|
|
|
{ fingerprintKind :: MutationKind
|
|
|
|
|
, fingerprintObjectKey :: ObjectKey
|
|
|
|
|
, fingerprintDeclaredSize :: Integer
|
|
|
|
|
, fingerprintContentSha256 :: Maybe ContentSha256
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data AdmissionRequest = AdmissionRequest
|
|
|
|
|
{ admissionIdentity :: AdmissionIdentity
|
|
|
|
|
, admissionPoolId :: PoolId
|
|
|
|
|
, admissionRelativePath :: RelativePath
|
|
|
|
|
, admissionFingerprint :: MutationFingerprint
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data AdmissionLimits = AdmissionLimits
|
|
|
|
|
{ admissionMaxObjectBytes :: Integer
|
|
|
|
|
, admissionIdempotencyWindowSec :: Integer
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data DispatchTiming = DispatchTiming
|
|
|
|
|
{ dispatchSettlementBoundSec :: Integer
|
|
|
|
|
, dispatchWorkerGraceSec :: Integer
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data PriorObject = PriorObject
|
|
|
|
|
{ priorObjectSize :: Integer
|
|
|
|
|
, priorObjectSha256 :: Maybe ContentSha256
|
|
|
|
|
}
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
data DesiredObject
|
|
|
|
|
= DesiredPresent Integer (Maybe ContentSha256)
|
|
|
|
|
| DesiredAbsent
|
|
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
|
|
mrUserId :: MutationRecord -> UserId
|
|
|
|
|
mrUserId = admissionUserId . mrAdmissionIdentity
|
|
|
|
|
|
|
|
|
|
mrKind :: MutationRecord -> MutationKind
|
|
|
|
|
mrKind = admissionKind . mrAdmissionIdentity
|
|
|
|
|
|
|
|
|
|
mrOperationId :: MutationRecord -> OperationId
|
|
|
|
|
mrOperationId = admissionOperationId . mrAdmissionIdentity
|
|
|
|
|
|
|
|
|
|
mrObjectKey :: MutationRecord -> ObjectKey
|
|
|
|
|
mrObjectKey = fingerprintObjectKey . mrFingerprint
|
|
|
|
|
|
|
|
|
|
mrDeclaredSize :: MutationRecord -> Integer
|
|
|
|
|
mrDeclaredSize = fingerprintDeclaredSize . mrFingerprint
|
|
|
|
|
|
|
|
|
|
mrContentSha256 :: MutationRecord -> Maybe ContentSha256
|
|
|
|
|
mrContentSha256 = fingerprintContentSha256 . mrFingerprint
|
|
|
|
|
|
|
|
|
|
mrPriorSize :: MutationRecord -> Integer
|
|
|
|
|
mrPriorSize = priorObjectSize . mrPriorObject
|
|
|
|
|
|
|
|
|
|
mrPriorSha256 :: MutationRecord -> Maybe ContentSha256
|
|
|
|
|
mrPriorSha256 = priorObjectSha256 . mrPriorObject
|
|
|
|
|
|
|
|
|
|
desiredObject :: MutationRecord -> DesiredObject
|
|
|
|
|
desiredObject record =
|
|
|
|
|
case mrKind record of
|
|
|
|
|
Put -> DesiredPresent
|
|
|
|
|
(mrDeclaredSize record)
|
|
|
|
|
(mrContentSha256 record)
|
|
|
|
|
Delete -> DesiredAbsent
|
|
|
|
|
|
2026-07-18 21:25:50 +09:00
|
|
|
data ExistingDisposition
|
|
|
|
|
= NewMutationEpoch
|
|
|
|
|
| ExistingReplay MutationResult MutationRecord
|
|
|
|
|
| ExistingConflict
|
|
|
|
|
deriving stock (Show)
|
|
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
data MutationRow = MutationRow
|
|
|
|
|
{ rowUserId :: Text
|
|
|
|
|
, rowKind :: DbMutationKind
|
|
|
|
|
, rowOperationId :: Text
|
|
|
|
|
, rowPoolId :: Text
|
|
|
|
|
, rowObjectKey :: Text
|
|
|
|
|
, rowRelativePath :: Text
|
|
|
|
|
, rowDeclaredSize :: Integer
|
|
|
|
|
, rowContentSha256 :: Maybe Text
|
|
|
|
|
, rowPriorSize :: Integer
|
|
|
|
|
, rowPriorSha256 :: Maybe Text
|
|
|
|
|
, rowGrowth :: Integer
|
|
|
|
|
, rowState :: DbMutationState
|
|
|
|
|
, rowExternal :: DbExternalState
|
|
|
|
|
}
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
data SettlementRow =
|
|
|
|
|
SettlementRow DbMutationState DbExternalState Integer Integer
|
|
|
|
|
|
|
|
|
|
newtype DbMutationKind = DbMutationKind MutationKind
|
|
|
|
|
|
|
|
|
|
newtype DbMutationState = DbMutationState MutationState
|
|
|
|
|
|
|
|
|
|
newtype DbExternalState = DbExternalState ExternalState
|
|
|
|
|
|
|
|
|
|
newtype MutationInsertRow = MutationInsertRow MutationRecord
|
|
|
|
|
|
|
|
|
|
instance PG.FromField DbMutationKind where
|
|
|
|
|
fromField field value = do
|
|
|
|
|
label <- PG.fromField field value
|
|
|
|
|
maybe
|
|
|
|
|
(PG.returnError PG.ConversionFailed field "invalid mutation kind")
|
|
|
|
|
(pure . DbMutationKind)
|
|
|
|
|
(parseKind label)
|
|
|
|
|
|
|
|
|
|
instance PG.FromField DbMutationState where
|
|
|
|
|
fromField field value = do
|
|
|
|
|
label <- PG.fromField field value
|
|
|
|
|
maybe
|
|
|
|
|
(PG.returnError PG.ConversionFailed field "invalid mutation state")
|
|
|
|
|
(pure . DbMutationState)
|
|
|
|
|
(parseState label)
|
|
|
|
|
|
|
|
|
|
instance PG.FromField DbExternalState where
|
|
|
|
|
fromField field value = do
|
|
|
|
|
label <- PG.fromField field value
|
|
|
|
|
maybe
|
|
|
|
|
(PG.returnError PG.ConversionFailed field "invalid external state")
|
|
|
|
|
(pure . DbExternalState)
|
|
|
|
|
(parseExternal label)
|
|
|
|
|
|
|
|
|
|
instance PGRow.FromRow MutationRow where
|
|
|
|
|
fromRow =
|
|
|
|
|
MutationRow
|
|
|
|
|
<$> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
|
|
|
|
|
instance PGRow.FromRow SettlementRow where
|
|
|
|
|
fromRow =
|
|
|
|
|
SettlementRow
|
|
|
|
|
<$> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
<*> PGRow.field
|
|
|
|
|
|
|
|
|
|
instance PGToRow.ToRow MutationInsertRow where
|
|
|
|
|
toRow (MutationInsertRow record) =
|
|
|
|
|
let (state, external) = encodeMutationPhase (mrPhase record)
|
|
|
|
|
in
|
|
|
|
|
[ PGToField.toField (unUserId (mrUserId record))
|
|
|
|
|
, PGToField.toField (kindLabel (mrKind record))
|
|
|
|
|
, PGToField.toField (unOperationId (mrOperationId record))
|
|
|
|
|
, PGToField.toField (unPoolId (mrPoolId record))
|
|
|
|
|
, PGToField.toField (unObjectKey (mrObjectKey record))
|
|
|
|
|
, PGToField.toField (unRelativePath (mrRelativePath record))
|
|
|
|
|
, PGToField.toField (mrDeclaredSize record)
|
|
|
|
|
, PGToField.toField
|
|
|
|
|
(unContentSha256 <$> mrContentSha256 record)
|
|
|
|
|
, PGToField.toField (mrPriorSize record)
|
|
|
|
|
, PGToField.toField
|
|
|
|
|
(unContentSha256 <$> mrPriorSha256 record)
|
|
|
|
|
, PGToField.toField (mrGrowth record)
|
|
|
|
|
, PGToField.toField (stateLabel state)
|
|
|
|
|
, PGToField.toField (externalLabel external)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
rowToRecord :: MutationRow -> Either Text MutationRecord
|
|
|
|
|
rowToRecord MutationRow{..} = do
|
|
|
|
|
let DbMutationKind kind = rowKind
|
|
|
|
|
DbMutationState state = rowState
|
|
|
|
|
DbExternalState ext = rowExternal
|
|
|
|
|
phase <- maybe (Left "invalid mutation phase") Right (decodeMutationPhase state ext)
|
2026-07-17 21:43:03 +09:00
|
|
|
pure MutationRecord
|
2026-07-19 19:13:17 +09:00
|
|
|
{ mrAdmissionIdentity =
|
|
|
|
|
AdmissionIdentity (UserId rowUserId) kind (OperationId rowOperationId)
|
|
|
|
|
, mrPoolId = PoolId rowPoolId
|
|
|
|
|
, mrRelativePath = RelativePath rowRelativePath
|
|
|
|
|
, mrFingerprint =
|
|
|
|
|
MutationFingerprint
|
|
|
|
|
kind
|
|
|
|
|
(ObjectKey rowObjectKey)
|
|
|
|
|
rowDeclaredSize
|
|
|
|
|
(ContentSha256 <$> rowContentSha256)
|
|
|
|
|
, mrPriorObject =
|
|
|
|
|
PriorObject rowPriorSize (ContentSha256 <$> rowPriorSha256)
|
|
|
|
|
, mrGrowth = rowGrowth
|
|
|
|
|
, mrPhase = phase
|
2026-07-17 21:43:03 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
decodeRows :: [MutationRow] -> IO [MutationRecord]
|
|
|
|
|
decodeRows rows =
|
|
|
|
|
either (throwIO . userError . T.unpack) pure (traverse rowToRecord rows)
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
lookupMutation :: Connection -> UserId -> MutationKind -> OperationId -> IO (Maybe MutationRecord)
|
|
|
|
|
lookupMutation conn uid kind op = do
|
|
|
|
|
rows <- query conn
|
|
|
|
|
"SELECT user_id, kind, operation_id, pool_id, object_key, relative_path, \
|
|
|
|
|
\declared_size, content_sha256, prior_size, prior_sha256, growth_bytes, \
|
|
|
|
|
\state, external_state FROM mutations \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
(unUserId uid, kindLabel kind, unOperationId op)
|
2026-07-19 19:13:17 +09:00
|
|
|
listToMaybe <$> decodeRows rows
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 23:52:45 +09:00
|
|
|
mutationEpochExists
|
|
|
|
|
:: Connection
|
|
|
|
|
-> UserId
|
|
|
|
|
-> MutationKind
|
|
|
|
|
-> OperationId
|
|
|
|
|
-> Integer
|
|
|
|
|
-> IO Bool
|
|
|
|
|
mutationEpochExists conn uid kind op idempotencyWindow = do
|
|
|
|
|
let identity =
|
|
|
|
|
unUserId uid <> "\NUL" <> kindLabel kind <> "\NUL" <> unOperationId op
|
|
|
|
|
_ <- query conn
|
|
|
|
|
"SELECT pg_advisory_xact_lock(hashtextextended(?, 0))"
|
|
|
|
|
(Only identity) :: IO [Only ()]
|
|
|
|
|
rows <- query conn
|
|
|
|
|
"SELECT 1 FROM mutations \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? \
|
|
|
|
|
\AND (state IN ('prepared', 'applying') \
|
|
|
|
|
\ OR external_state <> 'idle' \
|
|
|
|
|
\ OR updated_at >= now() - (? * interval '1 second'))"
|
|
|
|
|
(unUserId uid, kindLabel kind, unOperationId op, idempotencyWindow)
|
|
|
|
|
:: IO [Only Int]
|
|
|
|
|
pure (not (null rows))
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
lookupObjectMeta :: Connection -> ObjectKey -> IO (Maybe (Integer, ContentSha256))
|
|
|
|
|
lookupObjectMeta conn (ObjectKey key) = do
|
|
|
|
|
rows <- query conn
|
|
|
|
|
"SELECT size_bytes, content_sha256 FROM objects_meta WHERE object_key = ?"
|
|
|
|
|
(Only key)
|
|
|
|
|
pure $ case rows of
|
|
|
|
|
[(sz, sha)] -> Just (sz, ContentSha256 sha)
|
|
|
|
|
_ -> Nothing
|
|
|
|
|
|
2026-07-19 19:13:17 +09:00
|
|
|
sameFingerprint :: MutationRecord -> MutationFingerprint -> Bool
|
|
|
|
|
sameFingerprint rec fingerprint =
|
|
|
|
|
mrFingerprint rec == fingerprint
|
2026-07-17 21:43:03 +09:00
|
|
|
|
2026-07-18 21:25:50 +09:00
|
|
|
existingDisposition
|
|
|
|
|
:: Maybe MutationRecord
|
2026-07-19 19:13:17 +09:00
|
|
|
-> MutationFingerprint
|
2026-07-18 21:25:50 +09:00
|
|
|
-> ExistingDisposition
|
2026-07-19 19:13:17 +09:00
|
|
|
existingDisposition existing fingerprint =
|
2026-07-18 21:25:50 +09:00
|
|
|
case existing of
|
|
|
|
|
Nothing -> NewMutationEpoch
|
|
|
|
|
Just rec
|
2026-07-19 19:13:17 +09:00
|
|
|
| sameFingerprint rec fingerprint ->
|
2026-07-18 21:25:50 +09:00
|
|
|
ExistingReplay (stateToResult (mrState rec)) rec
|
|
|
|
|
| otherwise -> ExistingConflict
|
|
|
|
|
|
2026-07-18 00:54:47 +09:00
|
|
|
growthBytes :: Integer -> Integer -> Integer
|
|
|
|
|
growthBytes priorSize desiredSize = max 0 (desiredSize - priorSize)
|
|
|
|
|
|
|
|
|
|
quotaAllows :: Integer -> Integer -> Integer -> Integer -> Bool
|
|
|
|
|
quotaAllows limitBytes usedBytes reservedBytes growth =
|
|
|
|
|
usedBytes + reservedBytes + growth <= limitBytes
|
|
|
|
|
|
|
|
|
|
terminalExpiryEligible :: MutationState -> ExternalState -> Bool
|
|
|
|
|
terminalExpiryEligible state external =
|
|
|
|
|
state `elem` [Succeeded, Failed, Rejected] && external == ExtIdle
|
|
|
|
|
|
2026-07-18 21:25:50 +09:00
|
|
|
terminalExpired :: MutationState -> ExternalState -> Integer -> Integer -> Bool
|
|
|
|
|
terminalExpired state external elapsedSeconds windowSeconds =
|
|
|
|
|
terminalExpiryEligible state external && elapsedSeconds > windowSeconds
|
|
|
|
|
|
2026-07-18 00:54:47 +09:00
|
|
|
recoveryEligible :: MutationState -> Integer -> Integer -> Bool
|
|
|
|
|
recoveryEligible state elapsedSeconds graceSeconds =
|
|
|
|
|
state `elem` [Prepared, Applying] && elapsedSeconds > graceSeconds
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
admitMutation
|
|
|
|
|
:: Connection
|
2026-07-19 19:13:17 +09:00
|
|
|
-> AdmissionRequest
|
|
|
|
|
-> AdmissionLimits
|
2026-07-17 21:43:03 +09:00
|
|
|
-> IO AdmitOutcome
|
2026-07-19 19:13:17 +09:00
|
|
|
admitMutation conn AdmissionRequest{..} limits =
|
|
|
|
|
if fingerprintKind admissionFingerprint
|
|
|
|
|
/= admissionKind admissionIdentity
|
|
|
|
|
then pure AdmitConflict
|
|
|
|
|
else admitConsistentMutation
|
|
|
|
|
conn
|
|
|
|
|
admissionIdentity
|
|
|
|
|
admissionPoolId
|
|
|
|
|
admissionRelativePath
|
|
|
|
|
admissionFingerprint
|
|
|
|
|
limits
|
|
|
|
|
|
|
|
|
|
admitConsistentMutation
|
|
|
|
|
:: Connection
|
|
|
|
|
-> AdmissionIdentity
|
|
|
|
|
-> PoolId
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> MutationFingerprint
|
|
|
|
|
-> AdmissionLimits
|
|
|
|
|
-> IO AdmitOutcome
|
|
|
|
|
admitConsistentMutation
|
|
|
|
|
conn
|
|
|
|
|
(AdmissionIdentity uid kind op)
|
|
|
|
|
pool
|
|
|
|
|
rel
|
|
|
|
|
fingerprint@(MutationFingerprint _ key size msha)
|
|
|
|
|
AdmissionLimits{..} = do
|
|
|
|
|
let maxObject = admissionMaxObjectBytes
|
|
|
|
|
idempotencyWindow = admissionIdempotencyWindowSec
|
2026-07-18 15:22:57 +09:00
|
|
|
let identity =
|
|
|
|
|
unUserId uid <> "\NUL" <> kindLabel kind <> "\NUL" <> unOperationId op
|
|
|
|
|
_ <- query conn
|
|
|
|
|
"SELECT pg_advisory_xact_lock(hashtextextended(?, 0))"
|
|
|
|
|
(Only identity) :: IO [Only ()]
|
2026-07-18 21:25:50 +09:00
|
|
|
_ <- execute conn
|
|
|
|
|
"DELETE FROM mutations \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? \
|
|
|
|
|
\AND state IN ('succeeded', 'failed', 'rejected') \
|
|
|
|
|
\AND external_state = 'idle' \
|
|
|
|
|
\AND updated_at < now() - (? * interval '1 second')"
|
|
|
|
|
(unUserId uid, kindLabel kind, unOperationId op, idempotencyWindow)
|
2026-07-17 21:43:03 +09:00
|
|
|
existing <- lookupMutation conn uid kind op
|
2026-07-19 19:13:17 +09:00
|
|
|
case existingDisposition existing fingerprint of
|
2026-07-18 21:25:50 +09:00
|
|
|
ExistingReplay result rec -> pure (AdmitReplay result rec)
|
|
|
|
|
ExistingConflict -> pure AdmitConflict
|
|
|
|
|
NewMutationEpoch -> do
|
2026-07-17 21:43:03 +09:00
|
|
|
priorMeta <- lookupObjectMeta conn key
|
|
|
|
|
let priorSize = maybe 0 fst priorMeta
|
|
|
|
|
priorSha = snd <$> priorMeta
|
|
|
|
|
desiredSize = case kind of
|
|
|
|
|
Put -> size
|
|
|
|
|
Delete -> 0
|
2026-07-18 00:54:47 +09:00
|
|
|
growth = growthBytes priorSize desiredSize
|
2026-07-19 19:13:17 +09:00
|
|
|
baseRec phase =
|
2026-07-17 21:43:03 +09:00
|
|
|
MutationRecord
|
2026-07-19 19:13:17 +09:00
|
|
|
{ mrAdmissionIdentity = AdmissionIdentity uid kind op
|
2026-07-17 21:43:03 +09:00
|
|
|
, mrPoolId = pool
|
|
|
|
|
, mrRelativePath = rel
|
2026-07-19 19:13:17 +09:00
|
|
|
, mrFingerprint = fingerprint
|
|
|
|
|
, mrPriorObject = PriorObject priorSize priorSha
|
2026-07-17 21:43:03 +09:00
|
|
|
, mrGrowth = 0
|
2026-07-19 19:13:17 +09:00
|
|
|
, mrPhase = phase
|
2026-07-17 21:43:03 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 15:22:57 +09:00
|
|
|
poolRows <- query conn
|
2026-07-17 21:43:03 +09:00
|
|
|
"SELECT 1 FROM pools WHERE pool_id = ? FOR UPDATE"
|
|
|
|
|
(Only (unPoolId pool)) :: IO [Only Int]
|
|
|
|
|
|
2026-07-18 15:22:57 +09:00
|
|
|
if null poolRows
|
|
|
|
|
then pure AdmitConflict
|
|
|
|
|
else if kind == Put && (size <= 0 || size > maxObject)
|
2026-07-17 21:43:03 +09:00
|
|
|
then do
|
|
|
|
|
rec <- insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha
|
|
|
|
|
pure (AdmitRejected "invalid object size" rec)
|
|
|
|
|
else do
|
|
|
|
|
locked <- query conn
|
|
|
|
|
"SELECT 1 FROM key_locks WHERE object_key = ? FOR UPDATE"
|
|
|
|
|
(Only (unObjectKey key)) :: IO [Only Int]
|
|
|
|
|
if not (null locked)
|
|
|
|
|
then do
|
|
|
|
|
rec <- insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha
|
|
|
|
|
pure (AdmitRejected "key locked by another mutation" rec)
|
|
|
|
|
else do
|
|
|
|
|
okQuota <- query conn
|
|
|
|
|
"SELECT 1 FROM pools \
|
|
|
|
|
\WHERE pool_id = ? AND used_bytes + reserved_bytes + ? <= limit_bytes"
|
|
|
|
|
(unPoolId pool, growth) :: IO [Only Int]
|
|
|
|
|
if null okQuota
|
|
|
|
|
then do
|
|
|
|
|
rec <- insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha
|
|
|
|
|
pure (AdmitRejected "quota exceeded" rec)
|
|
|
|
|
else do
|
2026-07-19 19:13:17 +09:00
|
|
|
let preparedRecord = MutationRecord
|
|
|
|
|
{ mrAdmissionIdentity = AdmissionIdentity uid kind op
|
|
|
|
|
, mrPoolId = pool
|
|
|
|
|
, mrRelativePath = rel
|
|
|
|
|
, mrFingerprint = fingerprint
|
|
|
|
|
, mrPriorObject = PriorObject priorSize priorSha
|
|
|
|
|
, mrGrowth = growth
|
|
|
|
|
, mrPhase = PhasePrepared
|
|
|
|
|
}
|
2026-07-17 21:43:03 +09:00
|
|
|
n <- execute conn
|
|
|
|
|
"INSERT INTO mutations \
|
|
|
|
|
\(user_id, kind, operation_id, pool_id, object_key, relative_path, \
|
|
|
|
|
\ declared_size, content_sha256, prior_size, prior_sha256, growth_bytes, \
|
|
|
|
|
\ state, external_state) \
|
2026-07-19 19:13:17 +09:00
|
|
|
\VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
|
|
|
(MutationInsertRow preparedRecord)
|
2026-07-17 21:43:03 +09:00
|
|
|
if n /= 1
|
|
|
|
|
then pure AdmitConflict
|
|
|
|
|
else do
|
|
|
|
|
lockN <- execute conn
|
|
|
|
|
"INSERT INTO key_locks (object_key, user_id, kind, operation_id) \
|
|
|
|
|
\VALUES (?,?,?,?) ON CONFLICT DO NOTHING"
|
|
|
|
|
(unObjectKey key, unUserId uid, kindLabel kind, unOperationId op)
|
|
|
|
|
if lockN /= 1
|
|
|
|
|
then do
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE mutations SET state = 'rejected', growth_bytes = 0, \
|
|
|
|
|
\updated_at = now() \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
(unUserId uid, kindLabel kind, unOperationId op)
|
|
|
|
|
pure $ AdmitRejected "key locked by another mutation"
|
2026-07-19 19:13:17 +09:00
|
|
|
(baseRec (PhaseTerminal TerminalRejected))
|
2026-07-17 21:43:03 +09:00
|
|
|
else do
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE pools SET reserved_bytes = reserved_bytes + ? WHERE pool_id = ?"
|
|
|
|
|
(growth, unPoolId pool)
|
2026-07-19 19:13:17 +09:00
|
|
|
pure (AdmitPrepared preparedRecord)
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
insertTerminalRejected
|
|
|
|
|
:: Connection
|
|
|
|
|
-> UserId
|
|
|
|
|
-> MutationKind
|
|
|
|
|
-> OperationId
|
|
|
|
|
-> PoolId
|
|
|
|
|
-> ObjectKey
|
|
|
|
|
-> RelativePath
|
|
|
|
|
-> Integer
|
|
|
|
|
-> Maybe ContentSha256
|
|
|
|
|
-> Integer
|
|
|
|
|
-> Maybe ContentSha256
|
|
|
|
|
-> IO MutationRecord
|
|
|
|
|
insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha = do
|
2026-07-19 19:13:17 +09:00
|
|
|
let record = MutationRecord
|
|
|
|
|
{ mrAdmissionIdentity = AdmissionIdentity uid kind op
|
|
|
|
|
, mrPoolId = pool
|
|
|
|
|
, mrRelativePath = rel
|
|
|
|
|
, mrFingerprint =
|
|
|
|
|
MutationFingerprint kind key size msha
|
|
|
|
|
, mrPriorObject = PriorObject priorSize priorSha
|
|
|
|
|
, mrGrowth = 0
|
|
|
|
|
, mrPhase = PhaseTerminal TerminalRejected
|
|
|
|
|
}
|
2026-07-17 21:43:03 +09:00
|
|
|
_ <- execute conn
|
|
|
|
|
"INSERT INTO mutations \
|
|
|
|
|
\(user_id, kind, operation_id, pool_id, object_key, relative_path, \
|
|
|
|
|
\ declared_size, content_sha256, prior_size, prior_sha256, growth_bytes, \
|
|
|
|
|
\ state, external_state) \
|
2026-07-19 19:13:17 +09:00
|
|
|
\VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) \
|
2026-07-17 21:43:03 +09:00
|
|
|
\ON CONFLICT DO NOTHING"
|
2026-07-19 19:13:17 +09:00
|
|
|
(MutationInsertRow record)
|
|
|
|
|
pure record
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
stateToResult :: MutationState -> MutationResult
|
|
|
|
|
stateToResult Prepared = MutationPending
|
|
|
|
|
stateToResult Applying = MutationPending
|
|
|
|
|
stateToResult Succeeded = MutationSucceeded
|
|
|
|
|
stateToResult Failed = MutationFailed
|
|
|
|
|
stateToResult Rejected = MutationRejected
|
|
|
|
|
|
|
|
|
|
markApplying :: Connection -> MutationRecord -> IO Bool
|
|
|
|
|
markApplying conn rec = do
|
|
|
|
|
n <- execute conn
|
|
|
|
|
"UPDATE mutations SET state = 'applying', updated_at = now() \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? AND state = 'prepared'"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
|
|
|
|
pure (n == 1)
|
|
|
|
|
|
2026-07-18 22:49:19 +09:00
|
|
|
markExternalPending
|
|
|
|
|
:: Connection
|
|
|
|
|
-> MutationRecord
|
2026-07-19 19:13:17 +09:00
|
|
|
-> DispatchTiming
|
2026-07-18 22:49:19 +09:00
|
|
|
-> IO Bool
|
2026-07-19 19:13:17 +09:00
|
|
|
markExternalPending conn rec DispatchTiming{..} = do
|
2026-07-17 21:43:03 +09:00
|
|
|
n <- execute conn
|
2026-07-18 22:49:19 +09:00
|
|
|
"UPDATE mutations SET external_state = 'pending', \
|
|
|
|
|
\dispatch_started_at = now(), \
|
|
|
|
|
\request_deadline_at = now() + (? * interval '1 second'), \
|
|
|
|
|
\worker_lease_until = now() + (? * interval '1 second'), \
|
|
|
|
|
\updated_at = now() \
|
2026-07-17 21:43:03 +09:00
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? \
|
|
|
|
|
\AND state = 'applying' AND external_state = 'idle'"
|
2026-07-19 19:13:17 +09:00
|
|
|
( dispatchSettlementBoundSec
|
|
|
|
|
, dispatchWorkerGraceSec
|
2026-07-18 22:49:19 +09:00
|
|
|
, unUserId (mrUserId rec)
|
|
|
|
|
, kindLabel (mrKind rec)
|
|
|
|
|
, unOperationId (mrOperationId rec)
|
|
|
|
|
)
|
2026-07-17 21:43:03 +09:00
|
|
|
pure (n == 1)
|
|
|
|
|
|
|
|
|
|
markExternalSettled :: Connection -> MutationRecord -> IO Bool
|
|
|
|
|
markExternalSettled conn rec = do
|
|
|
|
|
n <- execute conn
|
2026-07-18 22:49:19 +09:00
|
|
|
"UPDATE mutations SET external_state = 'settled', settled_at = now(), \
|
|
|
|
|
\updated_at = now() \
|
2026-07-17 21:43:03 +09:00
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? \
|
|
|
|
|
\AND state = 'applying' AND external_state = 'pending'"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
|
|
|
|
pure (n == 1)
|
|
|
|
|
|
|
|
|
|
markSettledFromIdle :: Connection -> MutationRecord -> IO Bool
|
|
|
|
|
markSettledFromIdle conn rec = do
|
|
|
|
|
n <- execute conn
|
2026-07-18 22:49:19 +09:00
|
|
|
"UPDATE mutations SET external_state = 'settled', settled_at = now(), \
|
|
|
|
|
\updated_at = now() \
|
2026-07-17 21:43:03 +09:00
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? \
|
|
|
|
|
\AND state = 'applying' AND external_state = 'idle'"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
|
|
|
|
pure (n == 1)
|
|
|
|
|
|
|
|
|
|
finalizeSuccess :: Connection -> MutationRecord -> Integer -> Maybe ContentSha256 -> IO Bool
|
|
|
|
|
finalizeSuccess conn rec finalSize finalSha = do
|
|
|
|
|
rows <- query conn
|
|
|
|
|
"SELECT state, external_state, growth_bytes, prior_size FROM mutations \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? FOR UPDATE"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
2026-07-19 19:13:17 +09:00
|
|
|
:: IO [SettlementRow]
|
2026-07-17 21:43:03 +09:00
|
|
|
case rows of
|
2026-07-19 19:13:17 +09:00
|
|
|
[SettlementRow
|
|
|
|
|
(DbMutationState state)
|
|
|
|
|
(DbExternalState external)
|
|
|
|
|
growth
|
|
|
|
|
prior]
|
|
|
|
|
| decodeMutationPhase state external
|
|
|
|
|
== Just (PhaseApplying ExtSettled) -> do
|
2026-07-17 21:43:03 +09:00
|
|
|
let pool = mrPoolId rec
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE pools SET used_bytes = used_bytes - ? + ?, \
|
|
|
|
|
\reserved_bytes = reserved_bytes - ? WHERE pool_id = ?"
|
|
|
|
|
(prior, finalSize, growth, unPoolId pool)
|
|
|
|
|
case mrKind rec of
|
|
|
|
|
Put -> do
|
|
|
|
|
let sha = maybe "" unContentSha256 finalSha
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"INSERT INTO objects_meta (object_key, pool_id, size_bytes, content_sha256, updated_at) \
|
|
|
|
|
\VALUES (?,?,?,?, now()) \
|
|
|
|
|
\ON CONFLICT (object_key) DO UPDATE SET \
|
|
|
|
|
\ size_bytes = EXCLUDED.size_bytes, \
|
|
|
|
|
\ content_sha256 = EXCLUDED.content_sha256, \
|
|
|
|
|
\ updated_at = now()"
|
|
|
|
|
(unObjectKey (mrObjectKey rec), unPoolId pool, finalSize, sha)
|
|
|
|
|
pure ()
|
|
|
|
|
Delete -> do
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"DELETE FROM objects_meta WHERE object_key = ?"
|
|
|
|
|
(Only (unObjectKey (mrObjectKey rec)))
|
|
|
|
|
pure ()
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE mutations SET state = 'succeeded', external_state = 'idle', updated_at = now() \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"DELETE FROM key_locks WHERE object_key = ? AND user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
( unObjectKey (mrObjectKey rec)
|
|
|
|
|
, unUserId (mrUserId rec)
|
|
|
|
|
, kindLabel (mrKind rec)
|
|
|
|
|
, unOperationId (mrOperationId rec)
|
|
|
|
|
)
|
|
|
|
|
pure True
|
|
|
|
|
_ -> pure False
|
|
|
|
|
|
|
|
|
|
finalizeFailure :: Connection -> MutationRecord -> IO Bool
|
|
|
|
|
finalizeFailure conn rec = do
|
|
|
|
|
rows <- query conn
|
2026-07-19 19:13:17 +09:00
|
|
|
"SELECT state, external_state, growth_bytes, prior_size FROM mutations \
|
2026-07-17 21:43:03 +09:00
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ? FOR UPDATE"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
2026-07-19 19:13:17 +09:00
|
|
|
:: IO [SettlementRow]
|
2026-07-17 21:43:03 +09:00
|
|
|
case rows of
|
2026-07-19 19:13:17 +09:00
|
|
|
[SettlementRow
|
|
|
|
|
(DbMutationState state)
|
|
|
|
|
(DbExternalState external)
|
|
|
|
|
growth
|
|
|
|
|
_]
|
|
|
|
|
| decodeMutationPhase state external
|
|
|
|
|
== Just (PhaseApplying ExtSettled) -> do
|
2026-07-17 21:43:03 +09:00
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE pools SET reserved_bytes = reserved_bytes - ? WHERE pool_id = ?"
|
|
|
|
|
(growth, unPoolId (mrPoolId rec))
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"UPDATE mutations SET state = 'failed', external_state = 'idle', updated_at = now() \
|
|
|
|
|
\WHERE user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
(unUserId (mrUserId rec), kindLabel (mrKind rec), unOperationId (mrOperationId rec))
|
|
|
|
|
_ <- execute conn
|
|
|
|
|
"DELETE FROM key_locks WHERE object_key = ? AND user_id = ? AND kind = ? AND operation_id = ?"
|
|
|
|
|
( unObjectKey (mrObjectKey rec)
|
|
|
|
|
, unUserId (mrUserId rec)
|
|
|
|
|
, kindLabel (mrKind rec)
|
|
|
|
|
, unOperationId (mrOperationId rec)
|
|
|
|
|
)
|
|
|
|
|
pure True
|
|
|
|
|
_ -> pure False
|
|
|
|
|
|
|
|
|
|
-- | updated_at advances on state transitions only, not during a long put body.
|
|
|
|
|
claimActiveBatch :: Connection -> Int -> Integer -> IO [MutationRecord]
|
|
|
|
|
claimActiveBatch conn limit graceSec = do
|
|
|
|
|
rows <- query conn
|
|
|
|
|
"SELECT user_id, kind, operation_id, pool_id, object_key, relative_path, \
|
|
|
|
|
\declared_size, content_sha256, prior_size, prior_sha256, growth_bytes, \
|
|
|
|
|
\state, external_state FROM mutations \
|
|
|
|
|
\WHERE state IN ('prepared', 'applying') \
|
2026-07-18 22:49:19 +09:00
|
|
|
\AND ( \
|
|
|
|
|
\ (external_state = 'idle' \
|
|
|
|
|
\ AND updated_at < now() - (? * interval '1 second')) \
|
|
|
|
|
\ OR (external_state = 'settled' \
|
|
|
|
|
\ AND updated_at < now() - (? * interval '1 second')) \
|
|
|
|
|
\ OR (external_state = 'pending' \
|
|
|
|
|
\ AND worker_lease_until < now() \
|
|
|
|
|
\ AND request_deadline_at < now()) \
|
|
|
|
|
\) \
|
2026-07-17 21:43:03 +09:00
|
|
|
\ORDER BY updated_at ASC \
|
|
|
|
|
\FOR UPDATE SKIP LOCKED \
|
|
|
|
|
\LIMIT ?"
|
2026-07-18 22:49:19 +09:00
|
|
|
(graceSec, graceSec, limit)
|
2026-07-19 19:13:17 +09:00
|
|
|
decodeRows rows
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
desiredMatches :: MutationRecord -> ObjectObservation -> Bool
|
2026-07-19 19:13:17 +09:00
|
|
|
desiredMatches rec observation =
|
|
|
|
|
case (desiredObject rec, observation) of
|
|
|
|
|
(_, ObjectUnavailable _) -> False
|
|
|
|
|
(DesiredAbsent, ObjectAbsent) -> True
|
|
|
|
|
(DesiredPresent size expectedSha, ObjectPresent observedSize observedSha) ->
|
|
|
|
|
size == observedSize
|
|
|
|
|
&& case (expectedSha, observedSha) of
|
|
|
|
|
(Just want, Just got) -> want == got
|
|
|
|
|
(Just _, Nothing) -> False
|
|
|
|
|
(Nothing, _) -> True
|
|
|
|
|
_ -> False
|
2026-07-17 21:43:03 +09:00
|
|
|
|
|
|
|
|
priorMatches :: MutationRecord -> ObjectObservation -> Bool
|
|
|
|
|
priorMatches rec obs =
|
2026-07-19 19:13:17 +09:00
|
|
|
let PriorObject priorSize priorSha = mrPriorObject rec
|
|
|
|
|
priorEmpty = priorSize == 0 && priorSha == Nothing
|
2026-07-17 21:43:03 +09:00
|
|
|
in case obs of
|
|
|
|
|
ObjectUnavailable _ -> False
|
|
|
|
|
ObjectAbsent -> priorEmpty
|
|
|
|
|
ObjectPresent sz msha ->
|
2026-07-19 19:13:17 +09:00
|
|
|
sz == priorSize
|
|
|
|
|
&& ( case (priorSha, msha) of
|
2026-07-17 21:43:03 +09:00
|
|
|
(Just want, Just got) -> want == got
|
|
|
|
|
(Nothing, Nothing) -> True
|
|
|
|
|
(Just _, Nothing) -> False
|
2026-07-19 19:13:17 +09:00
|
|
|
(Nothing, Just _) -> priorSize == sz
|
2026-07-17 21:43:03 +09:00
|
|
|
)
|
2026-07-18 15:22:57 +09:00
|
|
|
|
|
|
|
|
finalizeFromObservation
|
|
|
|
|
:: Connection
|
|
|
|
|
-> MutationRecord
|
|
|
|
|
-> ObjectObservation
|
|
|
|
|
-> IO (Maybe MutationResult)
|
|
|
|
|
finalizeFromObservation conn rec observed =
|
|
|
|
|
case observed of
|
|
|
|
|
ObjectUnavailable _ -> pure Nothing
|
|
|
|
|
_ | desiredMatches rec observed -> do
|
|
|
|
|
let (finalSize, finalSha) =
|
|
|
|
|
case mrKind rec of
|
|
|
|
|
Put ->
|
|
|
|
|
( mrDeclaredSize rec
|
|
|
|
|
, mrContentSha256 rec
|
|
|
|
|
)
|
|
|
|
|
Delete -> (0, Nothing)
|
|
|
|
|
ok <- finalizeSuccess conn rec finalSize finalSha
|
|
|
|
|
pure $ if ok then Just MutationSucceeded else Nothing
|
|
|
|
|
_ | priorMatches rec observed -> do
|
|
|
|
|
ok <- finalizeFailure conn rec
|
|
|
|
|
pure $ if ok then Just MutationFailed else Nothing
|
|
|
|
|
_ -> pure Nothing
|