packages feed

memcache-0.4.0.0: Database/Memcache/Socket.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

{-|
Module      : Database.Memcache.Socket
Description : Meta Text protocol socket handling
Copyright   : (c) David Terei, 2016
License     : BSD
Maintainer  : code@davidterei.com
Stability   : stable
Portability : GHC

Low-level connections, replies, and request encoders for Memcached's Meta Text
protocol. Authentication uses the text auth-file command.
-}
module Database.Memcache.Socket (
        -- * Types
        ArithMode(..), Connection, Response(..), StoreMode(..), WireKey(..),
        crlf,

        -- * Operations
        newConnection, closeConnection,
        sendRaw,
        recvExact, recvLine, recvRawUntil, recvResponse,

        -- * Response readers
        responseStatus, responseCas, responseFlags, responseNumber,

        -- * Constructors
        encodeKey, getRequest, getManyRequest, gatRequest, touchRequest,
        storeRequest, arithmeticRequest, deleteRequest, flushRequest,
        versionRequest, modifyRequest, statsRequest, quitRequest, authRequest,
        noOpRequest
    ) where

import           Database.Memcache.Errors
import           Database.Memcache.Types

#if __GLASGOW_HASKELL__ < 710
import           Control.Applicative
#endif
import           Control.Monad             (unless)
import           Data.ByteString           (ByteString)
import qualified Data.ByteString           as B
import qualified Data.ByteString.Base64    as Base64
import qualified Data.ByteString.Char8     as C
import           Data.IORef
import           Data.List                 (find, isInfixOf)
import           Data.Maybe                (fromMaybe)
import           Data.Word                 (Word64)
import qualified Network.Socket            as N
import qualified Network.Socket.ByteString as N
import           Text.Read                 (readMaybe)
import           UnliftIO.Exception        (throwIO)

-- | A connected Memcached socket and its receive buffer.
data Connection = Connection N.Socket (IORef ByteString)

-- | A decoded Meta Text response.
data Response = Response
  -- | The response code, such as @VA@ or @HD@.
  { responseCode :: ByteString
  -- | Tokens following the response code.
  , responseTokens :: [ByteString]
  -- | The response value, when the response contains one.
  , responseValue :: ByteString
  } deriving (Eq, Show)

-- | Create a connection wrapper around a socket.
newConnection :: N.Socket -> IO Connection
newConnection s = Connection s <$> newIORef B.empty

-- | Close the underlying socket.
closeConnection :: Connection -> IO ()
closeConnection (Connection s _) = N.close s

-- | Send raw bytes on the underlying socket.
sendRaw :: Connection -> ByteString -> IO ()
sendRaw (Connection s _) = N.sendAll s

-- | Receive and decode one Meta Text response.
recvResponse :: Connection -> IO Response
recvResponse s = do
  line <- recvLine s
  case C.words line of
    [] -> throwIO $ ProtocolError UnknownPkt { protocolError = "" }
    code:tokens -> case tokens of
      n:_ | code == "VA" -> do
        value <- recvExact s =<< parseLength n
        trailer <- recvLine s
        unless (trailer == "\r") $ throwIO $ ProtocolError BadLength
          { protocolError = "value is not followed by an empty line" }
        return $ Response code tokens value
      _ -> return $ Response code tokens B.empty
  where
    parseLength x = case readMaybe (C.unpack x) of
      Just n | n >= 0 && n <= 128 * 1024 * 1024 -> return n
      _ -> throwIO $ ProtocolError BadLength { protocolError = "invalid value length" }

-- | Convert a response code and tokens to the corresponding status.
responseStatus :: Response -> IO Status
responseStatus (Response code tokens _) = case code of
  "VA"           -> return NoError
  "HD"           -> return NoError
  "EN"           -> return ErrKeyNotFound
  "NF"           -> return ErrKeyNotFound
  "EX"           -> return ErrKeyExists
  "NS"           -> return ErrItemNotStored
  "ST"           -> return NoError
  "STORED"       -> return NoError
  "VERSION"      -> return NoError
  "STAT"         -> return NoError
  "OK"           -> return NoError
  "END"          -> return NoError
  "MN"           -> return NoError
  "SERVER_ERROR" -> serverFailure
  "CLIENT_ERROR" -> clientFailure
  "ERROR"        -> throwIO $ ProtocolError UnknownOp { protocolError = "ERROR" }
  _              -> throwIO $ ProtocolError UnknownStatus { protocolError = C.unpack code }
  where
    message = C.unpack $ C.unwords tokens

    -- Meta Text reports these as free-form error text where the binary
    -- protocol had dedicated status codes; match them back so callers keep
    -- seeing 'OpError' rather than an opaque protocol failure.
    serverFailure
      | "object too large" `isInfixOf` message = throwStatus ErrValueTooLarge
      | "out of memory"    `isInfixOf` message = throwStatus ErrOutOfMemory
      | otherwise = throwIO $ ProtocolError ServerError { protocolError = message }

    clientFailure
      | "non-numeric" `isInfixOf` message = throwStatus ErrValueNonNumeric
      | otherwise = throwIO $ ProtocolError BadCommand { protocolError = message }

-- | Extract the CAS version from a response, or zero when absent or invalid.
responseCas :: Response -> Version
responseCas = token64 "c" . responseTokens

-- | Extract the flags from a response, or zero when absent or invalid.
responseFlags :: Response -> Flags
responseFlags = fromIntegral . token64 "f" . responseTokens

-- | Parse the numeric value in a response, or zero when it is absent or invalid.
responseNumber :: Response -> Word64
responseNumber = fromMaybe 0 . readMaybe . C.unpack . responseValue

token64 :: ByteString -> [ByteString] -> Word64
token64 prefix xs = fromMaybe 0 $ do
  token <- find (B.isPrefixOf prefix) xs
  readMaybe (C.unpack (B.drop (B.length prefix) token))

-- | Longest response line accepted before assuming the stream is desynchronised.
maxLineLength :: Int
maxLineLength = 8 * 1024

-- | Receive one CRLF-terminated line, without the line-feed byte.
recvLine :: Connection -> IO ByteString
recvLine s = do
  buffer <- readBuffer s
  case B.elemIndex 10 buffer of
    Just i -> writeBuffer s (B.drop (i + 1) buffer) >> return (B.take i buffer)
    Nothing
      | B.length buffer > maxLineLength ->
        throwIO $ ProtocolError BadLength { protocolError = "response line too long" }
      | otherwise -> readChunk 0 s >> recvLine s

-- | Receive exactly the requested number of bytes.
recvExact :: Connection -> Int -> IO ByteString
recvExact s n
  | n < 0 = throwIO $ ProtocolError BadLength { protocolError = "negative length" }
  | otherwise = B.concat <$> go n []
  where
    go 0 acc = return (reverse acc)
    go wanted acc = do
      buffer <- readBuffer s
      let takeN = min wanted (B.length buffer)
      if takeN > 0
        then writeBuffer s (B.drop takeN buffer) >> go (wanted - takeN) (B.take takeN buffer : acc)
        else readChunk wanted s >> go wanted acc

-- | Receive through the first occurrence of a delimiter, including it.
recvRawUntil :: ByteString -> Connection -> IO ByteString
recvRawUntil end s = go B.empty
  where
    go acc = do
      buffer <- readBuffer s
      let combined = acc <> buffer
      case B.breakSubstring end combined of
        (before, rest) | not (B.null rest) -> do
          writeBuffer s (B.drop (B.length end) rest)
          return (before <> end)
        _ -> writeBuffer s B.empty >> readChunk 0 s >> go combined

readChunk :: Int -> Connection -> IO ()
readChunk n (Connection s ref) = do
  chunk <- N.recv s (max 4096 n)
  if B.null chunk then throwIO eofError else modifyIORef' ref (<> chunk)

readBuffer :: Connection -> IO ByteString
readBuffer (Connection _ ref) = readIORef ref

writeBuffer :: Connection -> ByteString -> IO ()
writeBuffer (Connection _ ref) = writeIORef ref

eofError :: MemcacheError
eofError = ProtocolError UnexpectedEOF { protocolError = "" }

-- | A key encoded for the Meta Text wire protocol.
data WireKey = WireKey
  -- | The bytes sent as the key.
  { wireKey :: !ByteString
  -- | Whether the key uses the protocol's base64 binary-key mode.
  , wireB64 :: !Bool
  } deriving (Eq, Show)

-- | Encode a key, using base64 when it contains protocol-invalid bytes.
encodeKey :: Key -> Either ClientError WireKey
encodeKey key
  | B.null key = Left (KeyTooLong "empty key")
  | B.length key > 250 = Left (KeyTooLong "key exceeds 250 bytes")
  | B.length wire > 250 = Left (KeyTooLong "encoded key exceeds 250 bytes")
  | otherwise = Right (WireKey wire b64)
  where
    b64 = B.any (\c -> c <= 0x20 || c == 0x7f) key
    wire = if b64 then Base64.encode key else key

keyArgs :: WireKey -> ByteString
keyArgs (WireKey wire b64) = wire <> if b64 then " b" else B.empty

keyFlag :: WireKey -> ByteString
keyFlag (WireKey _ b64) = if b64 then " b" else B.empty

dec :: Show a => a -> ByteString
dec = C.pack . show

-- | CRLF used to terminate Meta Text requests.
crlf :: ByteString
crlf = "\r\n"

-- | Meta Text storage operation.
data StoreMode
  -- | Store whether or not the key exists.
  = Set
  -- | Store only when the key does not exist.
  | Add
  -- | Store only when the key exists.
  | Replace
  -- | Add bytes to the end of an existing value.
  | Append
  -- | Add bytes to the beginning of an existing value.
  | Prepend
  deriving (Eq, Show)

-- | Meta Text arithmetic operation.
data ArithMode
  -- | Increment a numeric value.
  = Incr
  -- | Decrement a numeric value.
  | Decr
  deriving (Eq, Show)

storeMode :: StoreMode -> ByteString
storeMode Set = "S"
storeMode Add = "E"
storeMode Replace = "R"
storeMode Append = "A"
storeMode Prepend = "P"

arithMode :: ArithMode -> ByteString
arithMode Incr = "I"
arithMode Decr = "D"

cmdMetaGet, cmdMetaSet, cmdMetaDelete, cmdMetaArith, cmdMetaNoOp :: ByteString
cmdMetaGet = "mg"
cmdMetaSet = "ms"
cmdMetaDelete = "md"
cmdMetaArith = "ma"
cmdMetaNoOp = "mn"

cmdFlush, cmdVersion, cmdQuit, cmdStats, cmdSet :: ByteString
cmdFlush = "flush_all"
cmdVersion = "version"
cmdQuit = "quit"
cmdStats = "stats"
cmdSet = "set"

cas :: Version -> ByteString
cas n = if n == 0 then B.empty else " C" <> dec n

-- | Encode a get request for one key.
getRequest :: WireKey -> ByteString
getRequest key = cmdMetaGet <> " " <> keyArgs key <> " v f c" <> crlf

-- | Encode a quiet multi-get request for one key.
getManyRequest :: WireKey -> ByteString
getManyRequest key = cmdMetaGet <> " " <> keyArgs key <> " v f c k q" <> crlf

-- | Encode a get-and-touch request.
gatRequest :: WireKey -> Expiration -> ByteString
gatRequest key expiry = cmdMetaGet <> " " <> keyArgs key <> " v f c T" <> dec expiry <> crlf

-- | Encode a touch request.
touchRequest :: WireKey -> Expiration -> ByteString
touchRequest key expiry = cmdMetaGet <> " " <> keyArgs key <> " T" <> dec expiry <> " c" <> crlf

-- | Encode a storage request.
storeRequest :: StoreMode -> WireKey -> Value -> Flags -> Expiration -> Version -> ByteString
storeRequest mode key value flags expiry version =
  cmdMetaSet <> " " <> wireKey key <> " " <> dec (B.length value) <> " F" <> dec flags <> " T" <> dec expiry
  <> cas version <> " M" <> storeMode mode <> keyFlag key <> " c" <> crlf <> value <> crlf

-- | Encode an increment or decrement request.
arithmeticRequest :: ArithMode -> WireKey -> Initial -> Delta -> Expiration -> Version -> ByteString
arithmeticRequest mode key initial delta expiry version =
  cmdMetaArith <> " " <> keyArgs key <> " D" <> dec delta <> cas version <> " M" <> arithMode mode <> " v c"
  <> (if expiry == maxBound then B.empty else " N" <> dec expiry <> " J" <> dec initial)
  <> crlf

-- | Encode an append or prepend request.
modifyRequest :: StoreMode -> WireKey -> Value -> Version -> ByteString
modifyRequest mode key value version =
  cmdMetaSet <> " " <> wireKey key <> " " <> dec (B.length value) <> " M" <> storeMode mode
  <> keyFlag key <> cas version <> " c" <> crlf <> value <> crlf

-- | Encode a delete request.
deleteRequest :: WireKey -> Version -> ByteString
deleteRequest key version = cmdMetaDelete <> " " <> keyArgs key <> cas version <> crlf

-- | Encode a flush request, optionally delayed until the given expiration.
flushRequest :: Maybe Expiration -> ByteString
flushRequest Nothing = cmdFlush <> crlf
flushRequest (Just expiry) = cmdFlush <> " " <> dec expiry <> crlf

-- | Encode a version request.
versionRequest, quitRequest :: ByteString
versionRequest = cmdVersion <> crlf
quitRequest = cmdQuit <> crlf

-- | Encode a stats request, optionally scoped to a key.
statsRequest :: Maybe Key -> ByteString
statsRequest Nothing = cmdStats <> crlf
statsRequest (Just key) = cmdStats <> " " <> key <> crlf

-- | Encode the text auth-file request, authenticating using the value as its
-- space-separated credentials.
authRequest :: Value -> ByteString
authRequest value = cmdSet <> " auth 0 0 " <> dec (B.length value) <> crlf <> value <> crlf

-- | Encode a Meta Text no-op request.
noOpRequest :: ByteString
noOpRequest = cmdMetaNoOp <> crlf