mariadb-haskell-0.1.0.0: src/Database/MySQL/Session.hs
-- | Bounded session observations carried by MariaDB OK packets. This module
-- owns retained bytes; callers never keep slices of a large receive buffer.
module Database.MySQL.Session where
import Control.Monad (foldM, unless, when)
import Data.Bits ((.&.))
import qualified Data.ByteString as B
import Data.ByteString (ByteString)
import qualified Data.Map.Strict as M
import Data.Word
import qualified Data.ByteString.Char8 as BC
-- | One server-reported change. Unknown kinds retain their length-delimited
-- payload rather than guessing a layout. Values can contain application data;
-- do not log session observations indiscriminately.
data SessionChange
= SystemVariableChanged !ByteString !ByteString -- ^ Variable name and value.
| SchemaChanged !ByteString -- ^ Empty bytes mean no selected database.
| SessionStateChanged !Bool -- ^ The server's generic state-change marker.
| TransactionCharacteristicsChanged !ByteString -- ^ Server-provided SQL text.
| TransactionStateChanged !ByteString -- ^ Eight-byte MariaDB transaction state.
| UnknownSessionChange !Word8 !ByteString -- ^ Unimplemented tracker kind and payload.
deriving (Eq, Show)
-- | Current observations, not a complete inventory of server settings. 'Nothing'
-- means unknown; an absent variable has not been observed or is no longer tracked.
-- A snapshot reflects only replies consumed so far, including during streaming.
-- 'sessionLastChanges' is the latest tracking envelope, not an event queue: use
-- 'sessionChangeNumber' to recognize replacement, and snapshot fields for final
-- state after multiple results. Transaction flags also work without tracking.
data SessionState = SessionState
{ sessionSchema :: !(Maybe ByteString) -- ^ Known schema; Just empty means none.
, sessionVariables :: !(M.Map ByteString ByteString) -- ^ Observed tracked variables.
, sessionAutocommit :: !(Maybe Bool) -- ^ SERVER_STATUS_AUTOCOMMIT, not transaction activity.
, sessionInTransaction :: !(Maybe Bool) -- ^ SERVER_STATUS_IN_TRANS.
, sessionTransactionCharacteristics :: !(Maybe ByteString) -- ^ When the server tracks them.
, sessionTransactionState :: !(Maybe ByteString) -- ^ When the server tracks it.
, sessionLastChanges :: ![SessionChange] -- ^ Latest bounded tracker envelope, in wire order.
, sessionChangeNumber :: !Word64 -- ^ Advances on a tracker envelope or invalidation.
} deriving (Eq, Show)
-- Internal accounting avoids rescanning every tracked variable per update.
data SessionCache = SessionCache
{ sessionObservation :: !SessionState
, sessionVariableBytes :: !Word64
}
emptySessionCache :: Word64 -> SessionCache
emptySessionCache serial = SessionCache
(SessionState Nothing M.empty Nothing Nothing Nothing Nothing [] serial) 0
-- The caller has already bounded the complete OK envelope. Additionally bound
-- item counts, even for zero-length payloads, and reject known malformed kinds.
decodeSessionChanges :: ByteString -> Either String [SessionChange]
decodeSessionChanges = items 0 []
where
items :: Int -> [SessionChange] -> ByteString -> Either String [SessionChange]
items count acc bytes
| B.null bytes = Right (reverse acc)
| count >= 65535 = Left "too many session tracking items"
| otherwise = do
(payload, rest) <- lengthString (B.tail bytes)
(next, changes) <- case B.head bytes of
0 -> variables (count + 1) acc payload
kind -> do
change <- case kind of
1 -> SchemaChanged <$> single payload
-- This tracker alone has a raw marker after the outer
-- length. There is no redundant inner string length.
2 -> case payload of
"0" -> pure (SessionStateChanged False)
"1" -> pure (SessionStateChanged True)
_ -> Left "invalid session state-change marker"
4 -> TransactionCharacteristicsChanged <$> single payload
5 -> do
value <- single payload
unless (B.length value == 8) (Left "invalid transaction state length")
pure (TransactionStateChanged value)
_ -> pure (UnknownSessionChange kind (B.copy payload))
pure (count + 1, change : acc)
items next changes rest
variables count acc bytes
| B.null bytes = Right (count, acc)
| count >= 65535 = Left "too many tracked variables"
| otherwise = do
(name, rest) <- lengthString bytes
(value, remaining) <- lengthString rest
when (B.null name) (Left "empty tracked variable name")
variables (count + 1) (SystemVariableChanged (B.copy name) (B.copy value) : acc) remaining
single payload = do
(value, rest) <- lengthString payload
unless (B.null rest) (Left "trailing session tracking payload")
pure (B.copy value)
-- Non-NULL length-encoded strings, with arithmetic in Word64 before conversion.
lengthString :: ByteString -> Either String (ByteString, ByteString)
lengthString bytes = do
when (B.null bytes) (Left "truncated length string")
let tag = B.head bytes
after = B.tail bytes
takeLength width = do
when (B.length after < width) (Left "truncated encoded length")
let size = B.foldr (\byte acc -> acc * 256 + fromIntegral byte)
(0 :: Word64) (B.take width after)
pure (size, B.drop width after)
(count, rest) <- case tag of
0xfc -> takeLength 2
0xfd -> takeLength 3
0xfe -> takeLength 8
_ | tag < 0xfb -> Right (fromIntegral tag, after)
| otherwise -> Left "invalid non-null string length"
when (count > fromIntegral (B.length rest)) (Left "truncated length string body")
pure (B.splitAt (fromIntegral count) rest)
applySessionStatus :: Word16 -> SessionCache -> SessionCache
applySessionStatus flags cache = cache {sessionObservation = state
{ sessionAutocommit = Just (flags .&. 2 /= 0)
, sessionInTransaction = Just (flags .&. 1 /= 0)
}}
where state = sessionObservation cache
-- Replace the latest envelope, then apply deltas with incremental byte charges.
-- Both accumulated variables and the retained latest envelope share the budget.
applySessionChanges :: Word64 -> [SessionChange] -> SessionCache -> Either String SessionCache
applySessionChanges = applySessionObservations TrackingEnvelope
-- Helper SELECT/configuration observations update facts without creating a
-- tracker event. Charge the envelope actually retained, including an older one.
applySessionFacts :: Word64 -> [SessionChange] -> SessionCache -> Either String SessionCache
applySessionFacts = applySessionObservations SnapshotFacts
data ObservationSource = TrackingEnvelope | SnapshotFacts
applySessionObservations :: ObservationSource -> Word64 -> [SessionChange] -> SessionCache -> Either String SessionCache
applySessionObservations source limit changes cache = do
-- MariaDB keys trackers by variable storage offset, so charset and
-- collation aliases may report only one name. Neither value determines
-- the other in general. Forget an unreported alias before applying the
-- batch, releasing its charge even when the replacement is near the limit.
-- Explicit deltas for both names remain valid in either wire order.
let charsetReported = hasVariableChange "character_set_connection" changes
collationReported = hasVariableChange "collation_connection" changes
coherent = case (charsetReported, collationReported) of
(True, False) -> forgetVariable "collation_connection" cache
(False, True) -> forgetVariable "character_set_connection" cache
_ -> cache
applied <- foldM apply coherent changes
-- A changed subscription invalidates values the server will no longer
-- report. Recount only on this cold configuration path, never per query.
let updated = case reverse [value | SystemVariableChanged "session_track_system_variables" value <- changes] of
selected : _ | selected /= "*" ->
let names = BC.split ',' selected
observed = sessionObservation applied
kept = M.filterWithKey (\name _ -> name `elem` names || name == "session_track_system_variables")
(sessionVariables observed)
in applied {sessionObservation = observed {sessionVariables = kept},
sessionVariableBytes = sum [bytes key + bytes value | (key,value) <- M.toList kept]}
_ -> applied
state = sessionObservation updated
prior = sessionObservation cache
envelope = case source of TrackingEnvelope -> changes; SnapshotFacts -> sessionLastChanges prior
serial = case source of TrackingEnvelope -> sessionChangeNumber prior + 1; SnapshotFacts -> sessionChangeNumber prior
retained = sessionVariableBytes updated + sum (map changeBytes envelope)
+ maybeBytes (sessionSchema state)
+ maybeBytes (sessionTransactionCharacteristics state)
+ maybeBytes (sessionTransactionState state)
when (retained > limit) (Left "retained session state exceeds metadata budget")
pure updated {sessionObservation = state
{sessionLastChanges = envelope, sessionChangeNumber = serial}}
where
apply current change = case change of
SystemVariableChanged name value -> do
let state = sessionObservation current
variables = sessionVariables state
old = maybe 0 (\prior -> bytes name + bytes prior) (M.lookup name variables)
charge = sessionVariableBytes current - old + bytes name + bytes value
when (charge > limit || M.size variables >= 65535 && M.notMember name variables)
(Left "tracked variables exceed session budget")
pure current {sessionVariableBytes = charge, sessionObservation = state
{sessionVariables = M.insert name value variables,
sessionSchema = if name == "session_track_schema" && value `elem` ["0","OFF"]
then Nothing else sessionSchema state,
sessionTransactionState = if name == "session_track_transaction_info" && value == "OFF"
then Nothing else sessionTransactionState state,
sessionTransactionCharacteristics = if name == "session_track_transaction_info" && value /= "CHARACTERISTICS"
then Nothing else sessionTransactionCharacteristics state}}
SchemaChanged value -> pure (modifyState (\s -> s {sessionSchema = Just value}) current)
TransactionCharacteristicsChanged value -> pure (modifyState
(\s -> s {sessionTransactionCharacteristics = Just value}) current)
TransactionStateChanged value -> pure (modifyState
(\s -> s {sessionTransactionState = Just value}) current)
_ -> pure current
forgetVariable name current = case M.lookup name (sessionVariables (sessionObservation current)) of
Nothing -> current
Just value -> current
{ sessionVariableBytes = sessionVariableBytes current - bytes name - bytes value
, sessionObservation = (sessionObservation current)
{sessionVariables = M.delete name (sessionVariables (sessionObservation current))}
}
modifyState change current = current {sessionObservation = change (sessionObservation current)}
maybeBytes = maybe 0 bytes
bytes = fromIntegral . B.length
changeBytes change = case change of
SystemVariableChanged name value -> bytes name + bytes value
SchemaChanged value -> bytes value
SessionStateChanged _ -> 1
TransactionCharacteristicsChanged value -> bytes value
TransactionStateChanged value -> bytes value
UnknownSessionChange _ value -> bytes value
-- Inspect only tracking envelopes, never ordinary rows/replies without changes.
hasVariableChange :: ByteString -> [SessionChange] -> Bool
hasVariableChange name = any matches
where
matches (SystemVariableChanged changed _) = name == changed
matches _ = False