packages feed

mariadb-haskell-0.1.0.0: validation/hs/Validation/Stage5Review.hs

{-# LANGUAGE ScopedTypeVariables #-}
-- | Follow-up regressions encode independent server-format literals and use
-- disposable MariaDB instances for temporal values, origin changes and DDL.
module Validation.Stage5Review (cases) where

import Control.Exception hiding (assert)
import Control.Monad
import Data.Binary.Put
import Data.Bits
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import Data.IORef
import Data.List (intercalate)
import Data.Word
import Database.MySQL.Base
import Database.MySQL.BinLog
import qualified Database.MySQL.Connection as C
import qualified System.IO.Streams as S
import Validation.Common
import Validation.Stage5 (ackFixture, decode, fixtureFormat, packet,
    sourceGtids, tableMap, transaction, withIsolated)

review :: String -> IO () -> Case
review name = Case ("stage5.review-" ++ name) "stage5"

-- MariaDB stores fractional seconds in pairs of decimal digits. Odd precision
-- still uses the same byte scale as the following even precision (compat56.cc).
fractions :: [(Word8,[Word8],Word32)]
fractions = [(0,[],0),(1,[10],100000),(2,[12],120000),
    (3,[0x04,0xce],123000),(4,[0x04,0xd2],123400),
    (5,[0x01,0xe2,0x3a],123450),(6,[0x01,0xe2,0x40],123456)]

field :: BinLogMeta -> [Word8] -> IO BinLogValue
field meta bytes = getFromBinLogPacket (getBinLogField meta)
    (packet BINLOG_UNKNOWN_EVENT (putByteString (B.pack bytes)))

firstEvent :: L.ByteString -> IO (Maybe CDCEvent)
firstEvent body = do
    input <- S.fromList [(packet MARIADB_GTID_EVENT (pure ())) {blBody=body}]
    empty <- either fail pure (gtidSet [])
    (events,_) <- newCDCDecoder defaultDecodeLimits (Just empty) fixtureFormat "bin.1" input
    S.read events

gtidPrefix :: Word8 -> Put
gtidPrefix flags = putWord64le 1 >> putWord32le 0 >> putWord8 flags

expectUnsupported :: IO a -> IO ()
expectUnsupported action = do
    result <- try action
    case result of
        Left (CDCUnsupportedFeature _) -> pure ()
        Left other -> throwIO (other :: CDCError)
        Right _ -> fail "unsafe GTID extension was accepted"

cases :: [Case]
cases = concat
    [ [review ("timestamp-fsp-" ++ show precision) $ equal "timestamp microseconds"
            (BinLogTimeStamp2 0x01020304 micros) =<< field (BINLOG_TYPE_TIMESTAMP2 precision)
                ([1,2,3,4] ++ bytes),
       review ("datetime-fsp-" ++ show precision) $ equal "datetime microseconds"
            (BinLogDateTime2 2026 1 2 3 4 5 micros) =<< field (BINLOG_TYPE_DATETIME2 precision)
                ([0x99,0xb8,0xc4,0x31,0x05] ++ bytes),
       review ("time-positive-fsp-" ++ show precision) $ equal "positive time"
            (BinLogTime2 1 1 2 3 micros) =<< field (BINLOG_TYPE_TIME2 precision)
                ([0x80,0x10,0x83] ++ bytes),
       review ("time-negative-fsp-" ++ show precision) $ do
            -- A nonzero fraction borrows one unit from the integer part. The
            -- zero-fraction encoding below deliberately has no such borrow.
            let prefix = if micros == 0 then [0x7f,0xef,0x7d] else [0x7f,0xef,0x7c]
            equal "negative time" (BinLogTime2 0 1 2 3 micros) =<<
                field (BINLOG_TYPE_TIME2 precision) (prefix ++ negative),
       review ("time-negative-whole-fsp-" ++ show precision) $ equal "negative whole seconds"
            (BinLogTime2 0 1 2 3 0) =<< field (BINLOG_TYPE_TIME2 precision)
                ([0x7f,0xef,0x7d] ++ replicate (length bytes) 0)]
    | ((precision,bytes,micros),negative) <- zip fractions
        [[],[0xf6],[0xf4],[0xfb,0x32],[0xfb,0x2e],[0xfe,0x1d,0xc6],[0xfe,0x1d,0xc0]]] ++
    [ review "gtid-list-multiple-origins" $ do
        let history=packet MARIADB_GTID_LIST_EVENT $ do
                putWord32le 2
                putWord32le 0 >> putWord32le 11 >> putWord64le 7
                putWord32le 0 >> putWord32le 12 >> putWord64le 8
        events <- decode defaultDecodeLimits [history]
        case map cdcEventPayload events of
            [CDCGtidList value] -> do
                equal "all origin history retained" [Gtid 0 11 7,Gtid 0 12 8] (gtidListEntries value)
                equal "history flags" 0 (gtidListFlags value)
            _ -> fail "missing GTID history"
    , review "gtid-list-flags-preserved" $ do
        events <- decode defaultDecodeLimits [packet MARIADB_GTID_LIST_EVENT (putWord32le 0x20000000)]
        equal "ignored-GTID flag preserved" [CDCGtidList (GtidList 0x20000000 [])] (map cdcEventPayload events)
    , review "gtid-list-duplicate-pair-rejected" $ expectRejection $ decode defaultDecodeLimits
        [packet MARIADB_GTID_LIST_EVENT $ do
            putWord32le 2
            replicateM_ 2 (putWord32le 0 >> putWord32le 11 >> putWord64le 7)]
    , review "gtid-list-entry-budget" $ expectRejection $ decode defaultDecodeLimits
        [packet MARIADB_GTID_LIST_EVENT (putWord32le 4097)]
    , review "gtid-short-header" $ forM_ [13..18] $ \size ->
        expectRejection (firstEvent (L.take size (runPut (gtidPrefix 4 >> putWord64le 0))))
    , review "gtid-truncated-engine-extension" $ expectRejection $ firstEvent $ runPut $ do
        gtidPrefix 2 >> putWord64le 3 >> putWord8 1
    , review "gtid-truncated-thread-extension" $ expectRejection $ firstEvent $ runPut $ do
        gtidPrefix 2 >> putWord64le 3 >> putWord8 16 >> putWord8 1
    , review "gtid-known-extensions" $ do
        value <- firstEvent $ runPut $ do
            gtidPrefix 2 >> putWord64le 3 >> putWord8 17 >> putWord8 1 >> putWord32le 9
        assert (maybe False (const True) value) "valid commit/engine/thread extensions rejected"
    , review "gtid-two-phase-alter-rejected" $ forM_ [2,4,8] $ \flags ->
        expectUnsupported $ firstEvent $ runPut $ do
            gtidPrefix 1 >> putWord8 flags
            when (flags .&. 12 /= 0) (putWord64le 1)
            putByteString (B.replicate 5 0)
    , review "gtid-unknown-extension-rejected" $ expectUnsupported $ firstEvent $ runPut $ do
        gtidPrefix 1 >> putWord8 32 >> putByteString (B.replicate 5 0)
    , review "invalid-temporal-fraction-rejected" $ do
        expectRejection (field (BINLOG_TYPE_TIMESTAMP2 1) [1,2,3,4,11])
        expectRejection (field (BINLOG_TYPE_TIMESTAMP2 6) [1,2,3,4,255,255,255])
        expectRejection (field (BINLOG_TYPE_DATETIME2 4) [0x99,0xb8,0xc4,0x31,0x05,0x27,0x10])
        expectRejection (field (BINLOG_TYPE_TIME2 2) [0x80,0x10,0x83,255])
    , review "time-negative-fraction-only" $ equal "negative subsecond time"
        (BinLogTime2 0 0 0 0 1) =<< field (BINLOG_TYPE_TIME2 6) [0x7f,0xff,0xff,255,255,255]
    , review "large-query-control-prefix" $ forM_ ["BEGIN","COMMIT","ROLLBACK"] $ \prefix -> do
        let statement=B.replicate 16384 32 <> prefix <> " " <> B.replicate (1024*1024) 120
            query=packet BINLOG_QUERY_EVENT $ do
                putWord32le 0 >> putWord32le 0 >> putWord8 0
                putWord16le 0 >> putWord16le 0 >> putWord8 0 >> putByteString statement
        case transaction 1 7 of
            [begin,_,_,commit] -> do
                events <- decode defaultDecodeLimits [begin,query,commit]
                equal "prefix is not a transaction boundary" [False,False,True]
                    (map (maybe False (const True) . cdcEventCheckpoint) events)
                case map cdcEventPayload events of
                    [CDCBegin _ _,CDCQuery value,CDCCommit _] ->
                        equal "complete query is retained" (Query (L.fromStrict statement)) (qQuery value)
                    _ -> fail "large query was misclassified"
            _ -> fail "invalid transaction fixture"
    , review "sparse-write-value-budget" (sparseRows BINLOG_WRITE_ROWS_EVENTv1)
    , review "sparse-delete-value-budget" (sparseRows BINLOG_DELETE_ROWS_EVENTv1)
    , review "sparse-update-value-budget" (sparseRows BINLOG_UPDATE_ROWS_EVENTv1)
    , review "row-count-budget-preserved" $ expectRejection $ decode defaultDecodeLimits
        [tableMap 1 [3], packet BINLOG_WRITE_ROWS_EVENTv1 $ do
            putWord48le 1 >> putWord16le 0 >> putLenEncInt 1 >> putWord8 1
            replicateM_ 65537 (putWord8 1)]
    , review "decoded-value-budget-preserved" $ expectRejection $ decode defaultDecodeLimits
        [tableMap 1 (replicate 64 3),packet BINLOG_WRITE_ROWS_EVENTv1 $ do
            putWord48le 1 >> putWord16le 0 >> putLenEncInt 64 >> putWord64le maxBound
            replicateM_ 16385 (putWord64le maxBound)]
    , review "event-limit-max-bound" $ do
        (conn,_,_) <- ackFixture (transaction 1 7)
        stream <- openCDC conn 610126 (defaultCDCOptions (FromBinLog (BinLogTracker "bin.000001" 4)))
            {cdcAcknowledgements=AcknowledgeManually,
             cdcDecodeLimits=defaultDecodeLimits {maxExpandedEventBytes=maxBound}}
        let untilCheckpoint = do
                event <- S.read (cdcEvents stream) >>= maybe (fail "EOF before checkpoint") pure
                maybe untilCheckpoint (const (pure ())) (cdcEventCheckpoint event)
        untilCheckpoint `finally` cdcClose stream
    , review "wire-limit-stops-continuations" $ do
        (original,_,closed) <- ackFixture (transaction 1 7)
        count <- newIORef (0::Int)
        let originalInput=C.mysqlRead original
        input <- S.makeInputStream $ do
            value <- S.read originalInput
            modifyIORef' count (+1)
            readCount <- readIORef count
            -- Discovery has 6 packets, SET has one, then ROTATE and FDE.
            -- The first oversized physical fragment must fail without asking
            -- for another continuation. Its body is irrelevant to this check.
            pure (if readCount == 10 then Just (Packet 16777215 1 "") else value)
        let conn=original {C.mysqlRead=input}
        stream <- openCDC conn 610126 (defaultCDCOptions (FromBinLog (BinLogTracker "bin.000001" 4)))
            {cdcAcknowledgements=AcknowledgeManually,
             cdcDecodeLimits=defaultDecodeLimits {maxExpandedEventBytes=1024}}
        expectRejection (S.read (cdcEvents stream))
        equal "oversized fragment stopped before another read" 10 =<< readIORef count
        equal "oversized stream retired" Broken =<< connectionStatus conn
        equal "transport released once" 1 =<< closed
    , review "live-temporal-values" liveTemporal
    , review "live-origin-change-rotation" liveOrigins
    , review "live-two-phase-alter-rejected" liveTwoPhaseAlter
    ]

sparseRows :: BinLogEventType -> IO ()
sparseRows typ = do
    let update=typ == BINLOG_UPDATE_ROWS_EVENTv1
        image=packet typ $ do
            putWord48le 1 >> putWord16le 0 >> putLenEncInt 4096
            putWord8 1 >> putByteString (B.replicate 511 0)
            when update (putByteString (B.replicate 511 0) >> putWord8 128)
            replicateM_ 1000 $ do
                putWord8 0 >> putWord32le 7
                when update (putWord8 0 >> putWord32le 8)
    events <- decode defaultDecodeLimits [tableMap 1 (replicate 4096 3),image]
    case map cdcEventPayload events of
        [CDCWrite _ value] -> equal "all sparse inserts" (replicate 1000 [BinLogLong 7]) (writeRowData value)
        [CDCDelete _ value] -> equal "all sparse deletes" (replicate 1000 [BinLogLong 7]) (deleteRowData value)
        [CDCUpdate _ value] -> equal "all sparse updates" (replicate 1000 ([BinLogLong 7],[BinLogLong 8])) (updateRowData value)
        _ -> fail "missing sparse images"

-- A real rotation writes the full binlog history, including an older origin in
-- the same domain. That list is not a one-entry-per-domain connect state.
liveOrigins :: IO ()
liveOrigins = withIsolated "stage5.review-live-origin-change-rotation" $ \open -> withConnect open $ \writer -> do
    exec writer "SET GLOBAL rpl_semi_sync_master_enabled=0"
    start <- sourceGtids writer
    exec writer "INSERT INTO changes VALUES(1)"
    exec writer "SET SESSION server_id=610131"
    exec writer "INSERT INTO changes VALUES(2)"
    exec writer "FLUSH LOGS"
    exec writer "INSERT INTO changes VALUES(3)"
    withConnect open $ \source -> do
        stream <- openCDC source 610132 (defaultCDCOptions (FromGtid start)) {cdcNonBlocking=True}
        events <- S.toList (cdcEvents stream)
        equal "all origin changes delivered" [[BinLogLong 1],[BinLogLong 2],[BinLogLong 3]]
            (concat [writeRowData row | event <- events,CDCWrite _ row <- [cdcEventPayload event]])
        let histories=[gtidListEntries value | event <- events,CDCGtidList value <- [cdcEventPayload event]]
        assert (any (\entries -> any ((==610129) . gtidServerId) entries
                    && any ((==610131) . gtidServerId) entries) histories)
            "rotation did not retain both origin histories"

liveTemporal :: IO ()
liveTemporal = withIsolated "stage5.review-live-temporal-values" $ \open -> withConnect open $ \writer -> do
    exec writer "SET GLOBAL rpl_semi_sync_master_enabled=0"
    let columns = concat [["t" ++ show p ++ " TIME(" ++ show p ++ ")",
                          "d" ++ show p ++ " DATETIME(" ++ show p ++ ")",
                          "s" ++ show p ++ " TIMESTAMP(" ++ show p ++ ")"] | (p,_,_) <- fractions]
    exec writer ("CREATE TABLE temporal_values(id INT," ++ intercalate "," columns ++ ")")
    seconds <- fromInteger <$> scalarInt writer "SELECT UNIX_TIMESTAMP('2026-01-02 03:04:05')"
    start <- sourceGtids writer
    forM_ [(1,False,False),(2,True,False),(3,True,True)] $ \(ident,negative,whole) -> do
        let quote text="'" ++ text ++ "'"
            suffix p | p == 0 = ""
                     | otherwise = "." ++ if whole then replicate (fromIntegral p) '0' else take (fromIntegral p) "123456"
            values = concat [[quote ((if negative then "-" else "") ++ "01:02:03" ++ suffix p),
                              quote ("2026-01-02 03:04:05" ++ suffix p),
                              quote ("2026-01-02 03:04:05" ++ suffix p)] | (p,_,_) <- fractions]
        exec writer ("INSERT INTO temporal_values VALUES(" ++ intercalate "," (show (ident::Int):values) ++ ")")
    withConnect open $ \source -> do
        stream <- openCDC source 610132 (defaultCDCOptions (FromGtid start)) {cdcNonBlocking=True}
        events <- S.toList (cdcEvents stream)
        let expected = [BinLogLong ident : concat
                [[BinLogTime2 (if negative then 0 else 1) 1 2 3 micro,
                  BinLogDateTime2 2026 1 2 3 4 5 micro,BinLogTimeStamp2 seconds micro]
                | (_,_,fraction) <- fractions,let micro=if whole then 0 else fraction]
                | (ident,negative,whole) <- [(1,False,False),(2,True,False),(3,True,True)]]
        equal "real temporal rows at every precision" expected
            (concat [writeRowData row | event <- events,CDCWrite _ row <- [cdcEventPayload event]])

liveTwoPhaseAlter :: IO ()
liveTwoPhaseAlter = withIsolated "stage5.review-live-two-phase-alter-rejected" $ \open -> withConnect open $ \writer -> do
    exec writer "SET GLOBAL rpl_semi_sync_master_enabled=0"
    start <- sourceGtids writer
    exec writer "SET SESSION binlog_alter_two_phase=ON"
    exec writer "ALTER TABLE changes ADD n INT, ALGORITHM=COPY"
    withConnect open $ \source -> do
        stream <- openCDC source 610132 (defaultCDCOptions (FromGtid start)) {cdcNonBlocking=True}
        expectUnsupported (S.toList (cdcEvents stream))
        equal "unsupported DDL retires source" Broken =<< connectionStatus source