Skip to content

Latest commit

 

History

History
171 lines (120 loc) · 5.82 KB

Memo.md

File metadata and controls

171 lines (120 loc) · 5.82 KB

Memo

A simple in-memory key-value store

Welcome to the first daemons tutorial, in which we walk through writing an in-memory key-value store with an RPC-like interface. The code for this tutorial is Memo.hs.

Concretely, we want a program such that:

  • memo put x 42 associates the value 42 with the key x, and

  • memo get x returns the value 42.

First of all, the extensions and imports:

{-# LANGUAGE DeriveGeneric, OverloadedStrings #-}

We need DeriveGenerics for cereal to generate serializers and deserializers automatically, and we enable OverloadedStrings because it makes working with ByteStrings much nicer.

module Main where

import Control.Concurrent.MVar ( MVar, newMVar, modifyMVar )
import Data.ByteString.Char8 ( ByteString )
import Data.Default ( def )
import Data.Serialize ( Serialize )
import Data.String ( fromString )
import qualified Data.Map as M
import GHC.Generics
import System.Environment ( getArgs )
import System.Daemon

Our key-value store will be a Map ByteString ByteString and we'll store it in an MVar to synchronize concurrent accesses. Instead of handcrafting a binary protocol for our daemon, we take the easy road and generate it automatically with Data.Serialize and GHC.Generics.

We import System.Daemon which is the high-level interface to the daemons library. The daemons' configuration is an instance of Data.Default, so we'll be able to use the defaults.

data Command = Put ByteString ByteString
             | Get ByteString
               deriving ( Generic, Show )

instance Serialize Command

We define a datatype for the put <key> <value> and get <key> commands. We let GHC derive the Generics instance, which gives us a pure Haskell representation of the type; this is used by the Serialize instance to generate all the necessary binary serialization and deserialization code.

data Response = Failed String
              | Value ByteString
                deriving ( Generic, Show )

instance Serialize Response

Similarly, we define a datatype for the possible responses. These can either be values requested by get <key>, or failure messages.

type Book = M.Map ByteString ByteString

handleCommand :: MVar Book -> Command -> IO Response
handleCommand bookVar comm = modifyMVar bookVar $ \book -> return $

Our "book" is just a map of ByteStrings; our command handler takes this map and a command, and returns a response.

Whenever the daemon receives a command, it spawns a new thread and runs the command handler. We want to share the book between these concurrent calls to the handler, so we stick it in an MVar.

An MVar is basically a thread-safe box which holds at most one item. We use modifyMVar which takes the book out of the MVar, runs our function with it, and puts the returned book back in the MVar.

    case comm of
      Get key -> ( book
                 , maybe (Failed "not found") Value (M.lookup key book) )

A get <key> command does not change the book, so we just return it. We look up the key and return its value or a failure message.

      Put key value -> ( M.insert key value book
                       , Value "ok" )

A put <key> <value> command inserts the key-value pair into the book, and returns a confirmation message.

main :: IO ()
main = do
    bookVar <- newMVar M.empty
    let options = def { daemonPort = 7856 }
    ensureDaemonRunning "memo" options (handleCommand bookVar)

Before doing anything else, we need to ensure that the daemon is running: we create an empty book, customize the daemon's default options, and finally start it. Note that ensureDaemonRunning checks if the daemon is running and starts it otherwise; so, the daemon will be started the first time the program is run, and all later runs will use the initial daemon.

    args <- getArgs
    let args' = map fromString args

Now it's time to handle the user input. First, we convert all the arguments to ByteStrings for ease of use.

    res <- case args' of
      ["get", key]        -> runClient "localhost"  7856 (Get key)
      ["put", key, value] -> runClient "localhost"  7856 (Put key value)
      _                   -> error "invalid command"

Next, we parse the arguments into a command and send it to the daemon. We call runClient with the port we gave earlier to ensureDaemonRunning and with the parsed command.

    print (res :: Maybe Response)

Finally, we print the returned response. Note that runClient is polymorphic in its return so we need to specify the type of the response.

Now let's see it in action:

{-
% dist/build/memo/memo get apples
Daemon started on port 7856
Just (Failed "not found")

% dist/build/memo/memo put apples 23
Just (Value "ok")

% dist/build/memo/memo get apples
Just (Value "23")
-}

To recap, we:

  • wrote data-types for commands and responses and gave them Serialize instances,

  • wrote a handler that takes a command and returns a response,

  • ensured that our daemon is running with ensureDaemonRunning, and

  • sent commands and received responses with runClient.

This tutorial illustrates the basic concepts behind daemons, but hides a powerful feature: the interface is streaming. See the Queue (Poor Man's Task Queue) tutorial for an example use of the streaming interface.