среда, 4 января 2017 г.

nginx-haskell-module: labeled media routing example

In this article I will show how to setup HTTP routing with nginx and Nginx Haskell module to mutable arrays of geographically distributed labeled media storages, front-ended by REST API media managers. Let's assume that the managers accept only 3 types of requests: /conf, /read and /write. On the /conf request a manager must return a JSON object with a list of all media storages it manages with their parameters: labels, hints, mode (RW or RO) and timestamp referring to the last update of the data. On the /read and the /write requests a manager must try to read from or write to a media storage according to values of the request parameters label or hint respectively. On successful /write request the label of the affected media storage must be returned: thus the client will be able to provide the label value in subsequent /read requests. If a /read or a /write request was unsuccessful then HTTP 404 status must be returned. I won't discuss implementation of the media managers: the spoken requirements and clear understanding that they must serve as transparent transformers of the REST requests proxied via media routers to the media storage layer is enough for the purpose of the article. The media routers is another story. They must serve user's /read and /write requests properly proxying them to those media managers which know about supplied label or hint. To collect information about available labels and hints and keep it valid, the routers must regularly poll their own media managers and other routers, or they can sign into a message queue to obtain updates asynchronously. For the sake of simplicity I will use the polling model: in this case on any unexpected response a client may assume that the media storage layout was altered but the router had not received the update yet, and simply retry the request in a minute or what the router polling interval is. Active polling requires asynchronous services from the nginx-haskell-module. Let's poll other media routers with /conf request as for media managers, expecting that they return a JSON object with a list of configurations collected from their own media managers. The model is depicted in the following image.
There are 3 geographically (or by other criteria) separated areas in the LAN which are colored differently. On front of every area a single Labeled Media Router stands (LMR 1, LMR 2 and LMR 3): this manifests the closest to the client's area (i.e. WAN) layer called Routers (to be precise, all the routers are depicted as double towers: it means that they may stand after a load balancer). The second layer, Managers, consists of media managers bound to media routers (LMM 1 through LMM 7): these bounds are statically defined in the nginx configuration. Some media managers are doubled as media routers: the situation when many routers are bound to many managers via a load balancer affects the collected data update algorithm in a media router. The lowest layer, Media, consists of media storages that may change dynamically (including addition and removal of new storages and altering their parameters). The media storages must have labels (dir_1, dir_2 etc.), a mode (RW or RO) and, optionally, a list of hints. On the image, some storages have a single hint fast. If the list of hints is empty then a storage is supposed to have hint default. Considering that storages with the same label are replicated somehow implies that reading from a storage in a different area is safe: normally data on such storages is expected to be equal, however when data is not found, the media manager must return status 404 and the router must skip to another area. Presence of replication is depicted by blue dashed arrows for storages with label dir_1, other storages with equal labels are also replicated but I did not show this to not overload the image. Mutual polling between all media routers with /conf requests is shown with bold red arrows. An example of user's /read request is shown on the image. The user requested reading a file from label dir_1. Router LMR 1 found that the request could be proxied to media manager LMM 1, but it responded with status 404 and the router passed the request to media manager LMM 4 from another area, having skipped another media manager LMM 3 from its own area. An interesting scenario with two proxy_pass actions in a single request! I'll show how to achieve this later, but now I want to specify requirements to /read and /write requests and the media router behavior more accurately.
  • /read
    • parameter label must be provided, it is supposed to have been given in the /write response from the media manager where data was actually written,
    • media storages with RW or RO mode can be chosen,
    • if a media manager returns status 404 then the router must skip other media managers in the current area that hold this label behind,
    • if a media manager is not accessible or returns 502, 503 and 504 then the router must try to pass the request to another media manager in the current area that hold the label behind or to a manager from another area if there is no suitable managers in the current area,
    • if there are more than one managers that hold the label behind then they must be chosen in round-robin manner in every new request to the given nginx worker process,
    • if a media manager is not accessible or returns 502, 503 and 504 then it must be blacklisted for a time period specified in the nginx configuration,
    • if the list of suitable media managers get exhausted while no successful response has been received then the media router must return status 503.
  • /write
    • parameter hint can be provided, if it was not then it is supposed to be default,
    • only media storages with RW mode can be chosen,
    • other rules from the /read clause that regard to statuses 404, 502, 503 and 504, inaccessibility, round-robin cycling and media managers exhaustion are applied here too.
Now let's turn to nginx configuration file. Below is a configuration for a media router.
user                    nginx;
worker_processes        2;

events {
    worker_connections  1024;
}

http {
    default_type        application/octet-stream;
    sendfile            on;

    include nginx-lmr-http-rules.conf;

    haskell_run_service queryEndpoints $hs_all_backends
        'Conf { updateInterval = Sec 20
              , blacklistInterval = Min 1
              , backends = ("/conf",
                                ["127.0.0.1:8011"
                                ,"127.0.0.1:8012"
                                ,"127.0.0.1:8013"
                                ]
                           )
              , partners = ("/conf",
                                ["127.0.0.1:8020"
                                ,"127.0.0.1:8030"
                                ]
                           )
              }';

    server {
        listen       8010;
        server_name  main;
        error_log    /var/log/nginx/lmr-error.log;
        access_log   /var/log/nginx/lmr-access.log;

        include nginx-lmr-server-rules.conf;
    }
}
Look at the directive haskell_run_service. It passes a typed data Conf to the haskell module being loaded from the shared library lmr.so in file nginx-lmr-http-rules.conf which content is shown below.
haskell load /var/lib/nginx/lmr.so;
#haskell rts_options -l;

haskell_var_nocacheable $hs_msg
                        $hs_seqn
                        $hs_key
                        $hs_start
                        $hs_idx
                        $hs_backend
                        $hs_status;

haskell_var_compensate_uri_changes $hs_msg;
I put haskell code in a separate source file lmr.hs because I could not have it put in the configuration even if I wanted: length of an nginx variable's value cannot exceed 4096 bytes when it is read from a configuration file, but content of the lmr.hs does exceed. Type Conf is defined in lmr.hs as
data Conf = Conf { updateInterval    :: TimeInterval
                 , blacklistInterval :: TimeInterval
                 , backends          :: (Url, [Destination])
                 , partners          :: (Url, [Destination])
                 } deriving (Read)

type Destination = String   -- IP address or domain name
type Url = String           -- normally must start with /

data TimeInterval = Hr Int
                  | Min Int
                  | Sec Int
                  | HrMin (Int, Int)
                  | MinSec (Int, Int)
                  deriving (Read)
Function queryEndpoints is a service that will run every updateInterval, i.e. 20 sec: this is the negotiation process between media routers (the partners) depicted in the image with bold red arrows, blacklistInterval is a global reference that defines for how long time inaccessible media routers must be blacklisted, backends are own media servers (i.e. LMM 1, LMM 2 and LMM 3 as for LMR 1 in terms of the image) with the URL to their /conf location, and partners are other media routers (i.e. LMR 2 and LMR 3 as for LMR 1) with the URL to their /conf location. Below is definition of queryEndpoints.
queryEndpoints (C8.unpack -> conf) firstRun = do
    let Conf (toSec -> upd) (toSec -> bl) (url, own) (purl, remote) =
            readDef (Conf (Hr 24) (Min 1) ("", []) ("", [])) conf
    if firstRun
        then atomicWriteIORef blInterval bl
        else threadDelaySec upd
    (M.fromList -> obd) <-
        mapConcurrently
            (\d -> catchBadResponseOwn d $
                second (fromMaybe (BackendData 0 M.empty) . decode) <$>
                    query url d
            ) own
    (M.fromList . ((Own, obd) :) . map (first Remote) -> abd) <-
        mapConcurrently
            (\d -> catchBadResponseRemote d $
                second (fromMaybe M.empty . decode) <$>
                    query purl d
            ) remote
    oldbd <- readIORef allBackends
    let allbd =
            M.mapWithKey
                (\p -> M.mapWithKey
                    (\d a -> case M.lookup p oldbd >>= M.lookup d of
                        Just v@(BackendData ts _) ->
                            if ts > timestamp a then v else a
                        _ -> a
                    )
                ) abd
        newRoutes = toRoutes allbd
    atomicWriteIORef allBackends allbd
    oldRoutes <- fromRRRoutes . snd . snd <$> readIORef routes
    when (newRoutes /= oldRoutes) $ do
        rr <- mkRRRoutes newRoutes
        atomicModifyIORef routes $ \((a, _), o) -> ((o, (a + 2, rr)), ())
    return $ encode newRoutes
    where query u = runKleisli $ arr id &&& Kleisli (getUrl . flip mkAddr u)
          mkAddr = (("http://" ++) .) . (++)
          catchBadResponse f d = handle $
              \(_ :: SomeException) -> ((d, ) . f d) <$> readIORef allBackends
          catchBadResponseOwn = catchBadResponse $
              \d -> fromMaybe (BackendData 0 M.empty) . M.lookup d .
                  fromMaybe M.empty . M.lookup Own
          catchBadResponseRemote = catchBadResponse $
              \d -> fromMaybe M.empty . M.lookup (Remote d)
          threadDelaySec = threadDelay . (* 1e6)
          toSec (Hr h)          = 3600 * h
          toSec (Min m)         = 60 * m
          toSec (Sec s)         = s
          toSec (HrMin (h, m))  = 3600 * h + 60 * m
          toSec (MinSec (m, s)) = 60 * m + s
ngxExportServiceIOYY 'queryEndpoints

getResponse url = fmap responseBody . (parseRequest url >>=)

httpManager = unsafePerformIO $ newManager defaultManagerSettings
{-# NOINLINE httpManager #-}

getUrl url = getResponse url $ flip httpLbs httpManager
The function returns an encoded JSON object (written into variable $hs_all_backends on the nginx side) wrapped inside IO Monad: this means that queryEndpoints performs several actions with side effects sequentially. The first action is reading data Conf passed from the nginx configuration. Then it updates a global reference blInterval if the service runs for the first time, or waits 20 sec otherwise. The next action is querying own media managers (backends) and then querying other media routers (partners), and collecting results in allbd with respect to timestamps received from the backends which are compared with data collected on the previous iteration (oldbd which is read from the global reference allBackends). Then allbd gets written in the allBackends. So far queryEndpoints updates data with type CollectedData that closely represents the original layout of partners and backends.
type Label = String
type Hint = String

type Timestamp = Integer

data BackendData = BackendData { timestamp :: Timestamp
                               , labels    :: Map Label LabelData
                               } deriving (Generic, Show)
instance FromJSON BackendData
instance ToJSON BackendData

data Possession = Own
                | Remote Destination
                deriving (Generic, Read, Show, Eq, Ord)
instance ToJSON Possession
instance ToJSONKey Possession

data Mode = RW
          | RO
          deriving (Generic, Read, Show, Eq, Ord)
instance FromJSON Mode
instance ToJSON Mode

type PartnerData = Map Destination BackendData
type CollectedData = Map Possession PartnerData

data LabelData = LabelData { mode :: Mode
                           , hint :: [Hint]
                           } deriving (Generic, Show)
instance ToJSON LabelData
instance FromJSON LabelData
Next actions in the function queryEndpoints are doing transformation from allbd to newRoutes of type (Routes, Routes) adapted for being used in the search algorithm both for labels (/read) and hints (/write). The first element of the tuple contains elements laid out for /read requests, the second element is for /write requests.
type Backends = Map Possession [Destination]
type Routes = Map Hint (Map Label Backends)

both :: Arrow a => a b c -> a (b, b) (c, c)
both = join (***)

toRoutes :: CollectedData -> (Routes, Routes)
toRoutes = both (mkRoutes . sort) .
    (
        map (first show) . filter ((AnyHint ==) . fst) . map snd &&&
        map (show *** first (const "any")) . filter ((AnyHint /=) . fst) .
            fromMaybe [] . M.lookup RW . combineByFst .
                sortBy (compare `on` fst)
    ) .
    concatMap (\(m, hs, l, p, d) -> [(m, (h, (l, (p, d)))) | h <- hs]) .
        concatMap (\(p, v) ->
            concatMap (\(d, v) ->
                map (\(l, v) -> (mode v, ckHint $ hint v, l, p, d)) $
                    M.assocs $ labels v) $ M.assocs v) . M.assocs
    where ckHint h = AnyHint :
              if null h then [PlainHint "default"] else map PlainHint h
          combineByFst :: Ord a => [(a, b)] -> Map a [b]
          combineByFst =
              M.fromDistinctAscList .
                  map (uncurry (foldr $ \b (c, a) -> (c, snd b : a)) .
                        ((, []) . fst . head &&& id)) . groupBy ((==) `on` fst)
          mkRoutes =
              M.map (M.map $ M.map (map head . group . sort) . combineByFst) .
                  M.map combineByFst . combineByFst
Thus, the type Routes is a Map of Map of Map. The innermost Map's key is Possession that is just a distance to backends (Own or Remote addr), the value is [Destination]: a list of addresses of all backends that correspond to specific hint and label which is respectively keys of the outer and the middle Maps. As soon as Possession keys are always sorted with Own as the first element (as it should derive Ord expectedly), the own media managers will always be the first in the search hierarchy. The hints are unrelated for /read requests whereas labels are unrelated for /write requests, that's why a special tagged AnyHint is used for the read part of the returned Routes tuple, and value "any" is used as the label for the write part of the tuple. Below is the definition of TaggedHint.
data TaggedHint = AnyHint
                | PlainHint Hint
                deriving Eq
instance Show TaggedHint where
    show AnyHint       = "any"
    show (PlainHint x) = x
So, the hint in the search hierarchy of Routes in the read part will also look as "any". After making newRoutes in queryEndpoints we firstly check if it's equal to the stripped value held in the second element of a tuple from the global reference routes defined as
type RRBackends = Map Possession ([Destination], Maybe (RoundRobin Int))
type RRRoutes = Map Hint (Map Label RRBackends)
type SeqNumber = Integer

routes :: IORef ((SeqNumber, (RRRoutes, RRRoutes)),
                 (SeqNumber, (RRRoutes, RRRoutes)))
routes = unsafePerformIO $ newIORef ((-1, (M.empty, M.empty)),
                                     (0,  (M.empty, M.empty)))
{-# NOINLINE routes #-}
(type RoundRobin is imported from Data.RoundRobin of package roundRobin). The routes' tuple holds two elements with search hierarchies enriched with round-robin elements: the old and the current. The old value is held to avoid loss of the search path in a request currently being processed when the search hierarchy gets altered after an asynchronous update in queryEndpoints: each request gets tagged with the SeqNumber from the current element of the tuple and is supposed to survive two sequential updates of the search hierarchy (if by some reason a request fails to trace down all the search path until a successful response and the element with its SeqNumber in the search hierarchy has been already rewritten then it gets finalized with writeFinalMsg in getMsg, see below). Let's get back to the action in queryEndpoints. If newRoutes and the stripped (i.e. without round-robin elements) second tuple of the routes differ then the second element of routes (i.e. current routes) gets moved to the first place (it becomes old) and the enriched copy of newRoutes gets put on the second place. Stripping enriched routes is performed in function fromRRRoutes.
fromRRRoutes :: (RRRoutes, RRRoutes) -> (Routes, Routes)
fromRRRoutes = both $ M.map $ M.map $ M.map fst
Enriching plain routes with round-robin elements is performed in function mkRRRoutes.
bothKleisli :: Monad m => (a -> m b) -> (a, a) -> m (b, b)
bothKleisli = runKleisli . both . Kleisli

mkRRRoutes :: (Routes, Routes) -> IO (RRRoutes, RRRoutes)
mkRRRoutes = bothKleisli $ mapM $ mapM $ mapM $
    runKleisli $
        arr id &&&
        Kleisli (
                    maybe (return Nothing) (fmap Just) .
                        (nonEmpty . flip take [1 ..] . length >=> ckLen >=>
                            Just . newRoundRobin)
                )
    where ckLen x@(_ :| _ : _) = Just x
          ckLen _ = Nothing
So, if the innermost Map's value of the Routes, i.e. the list of backends contains N elements and N is more than one then it gets bound to a RoundRobin element with a reference to N indexes starting from 1. The last things that I want to say about queryEndpoints is that the HTTP engine httpLbs used in it was taken from package http-client. It means that all requests are asynchronous and with adjustable timeouts. All exceptions that function query may produce are properly caught with functions catchBadResponseOwn and catchBadResponseRemote: they never return broken data replacing it with the existing good data instead. Now let's get back to the mysterious and frightening nginx directives haskell_var_nocacheable and haskell_var_compensate_uri_changes from file nginx-lmr-http-rules.conf and show content of file nginx-lmr-server-rules.conf.
set $debug 1;

location ~ '^/(Read|Write)/([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)$' {
    internal;

    set $op      $1;
    set $hint    $2;
    set $label   $3;
    set $seqn    $4;
    set $key     $5;
    set $start   $6;
    set $idx     $7;
    set $backend $8;
    set $stat    $9;

    set $head /$op/$hint/$label;

    recursive_error_pages on;
    error_page 404 =
        $head/$hs_seqn/$hs_key/$hs_start/$hs_idx/$hs_backend/NotFound;
    error_page 502 503 504 =
        $head/$hs_seqn/$hs_key/$hs_start/$hs_idx/$hs_backend/NotAccessible;
    error_page 409 =503 @Stop;

    proxy_intercept_errors on;

    haskell_run getMsg $hs_msg
        'Msg { op = $op
             , hnt = "$hint"
             , label = "$label"
             , seqn = $seqn
             , key = $key
             , start = $start
             , idx = $idx
             , backend = "$backend"
             , status = $stat
             }';

    haskell_run getSeqn    $hs_seqn    $hs_msg;
    haskell_run getKey     $hs_key     $hs_msg;
    haskell_run getStart   $hs_start   $hs_msg;
    haskell_run getIdx     $hs_idx     $hs_msg;
    haskell_run getBackend $hs_backend $hs_msg;
    haskell_run getStatus  $hs_status  $hs_msg;

    set $debugFinalMsg $debug::$hs_status;

    if ($debugFinalMsg = 1::NonExistent) {
        echo_status 503;
        echo "No route found";
        echo_after_body "[$pid] $hs_msg";
        break;
    }

    if ($hs_status = NonExistent) {
        return 409;
    }

    if ($debug) {
        echo_after_body "[$pid] $hs_msg";
        break;
    }

    proxy_pass http://$hs_backend$request_uri;
}

location @Stop {
    return 503;
}

location /backends {
    echo $hs_all_backends;
}

location /blacklist {
    haskell_run getBlacklist $hs_blacklist '';
    echo $hs_blacklist;
}

location /read {
    if ($arg_label = '') {
        return 400;
    }
    if ($arg_label ~ /) {
        return 400;
    }

    rewrite ^ /Read/any/$arg_label/0/0/0/0/START/Ok last;
}

location /write {
    if ($arg_hint ~ /) {
        return 400;
    }
    set $hint $arg_hint;
    if ($arg_hint = '') {
        set $hint default;
    }

    rewrite ^ /Write/$hint/any/0/0/0/0/START/Ok last;
}

location /conf {
    haskell_run getOwnBackends $hs_own_backends '';
    echo $hs_own_backends;
}
Both the /read and the /write requests get rewritten to a regexp location that starts with /Read or /Write, the rewritten request gets supplied with the hint and the label with four zeros and the mysterious tail /START/Ok. What going on here is starting of the loop where suitable media managers will be found or not found and the request's operation (read or write) will be successfully executed or failed! The four zeros are starting values for the loop: sequential number, key — index of the current search position in the innermost Map of type Routes, i.e. Possession or distance, start — index that will be assigned by the round-robin element from the RRRoutes on the first try if number of suitable media managers is more than one, and index — the current offset from the start value that will also be assigned from within the haskell module. The START value is rather arbitrary: this is an initial value of the backend to proxy_pass to and will be assigned from the haskell part, Ok corresponds to the internal state of the loop. The loop itself is encoded in the regexp location. Ability to iterate over the loop is the result of internal redirections provided by error_page directives which redirect to the same location when backends respond with bad statuses. But internal redirections in nginx have shortcomings: values of variables can be either cached forever or the variable's handler will be accessed on every access to the variable. We need somewhat in the middle: caching variables within a single internal redirection and updating them on every new internal redirection. This is exactly what directive haskell_var_nocacheable does! Another shortcoming of the nginx internal redirections is their finite number (10 or so): there is an internal counter uri_changes bound to the request's context, it gets decremented on every new internal redirection and when it reaches value 0 nginx finalizes the request. We cannot say how many internal redirections we need: apparently 10 can be not enough, and static limit is not good for us at all. Directive haskell_var_compensate_uri_changes makes the variable's handler increment (if possible) the request's uri_change value. As soon as variable $hs_msg is also nocacheable, its handler will be accessed once per single internal redirection thus compensating uri_changes decrements. Thus, the two directives haskell_var_nocacheable and haskell_var_compensate_uri_changes can make error_page internal redirections Turing-complete! New parameters of the loop including the most important one — the backend where to proxy_pass to, are received via a synchronous call to a haskell handler getMsg. Function getMsg is wrapped in IO Monad, however it does not make unsafe or unpredictable side effects like reading and writing from network sockets or files: it only accesses round-robin elements in the routes data and may also alter the global reference blacklist. This means that we can safely regard getMsg as safe in the sense of synchronicity. Here is how getMsg defined.
readMsg = readDef (Msg Read "" "" 0 0 0 0 "" NotReadable) . C8.unpack

writeMsg = return . C8L.pack . show

writeFinalMsg m = writeMsg m { backend = "STOP", status = NonExistent }

getMsg (readMsg -> m@Msg { status = NotReadable }) =
    writeFinalMsg m
getMsg (readMsg -> m@(Msg op hnt label seqn key start idx b st)) = do
    when (st == NotAccessible) $ do
        bl <- readIORef blacklist
        when (b `M.notMember` bl) $
            getCurrentTime >>= modifyIORef' blacklist . M.insert b
    (getRoutes seqn >=> return . rSelect op >=>
        return . second (M.lookup hnt >=> M.lookup label) -> r) <-
            readIORef routes
    case r >>= \x@(_, d) -> (x, ) <$> (d >>= elemAt key) of
        Nothing -> writeFinalMsg m
        Just ((n, fromJust -> d), (_, gr)) -> do
            (s, i, b) <- if st == NotFound
                             then return (0, 0, Nothing)
                             else getNextInGroup start
                                      (advanceIdx start st idx) gr
            case b of
                Nothing -> do
                    ((s, i, b), k) <- getNext (key + 1) d
                    case b of
                        Nothing -> do
                            unblacklistAll d
                            writeFinalMsg m { seqn = n }
                        Just v -> writeMsg $ Msg op hnt label n k s i v Ok
                Just v -> writeMsg $ Msg op hnt label n key s i v Ok
    where rSelect Read  = second fst
          rSelect Write = second snd
          getRoutes v (a@(x, _), b@(y, _)) | v == 0 = Just b
                                           | v == y = Just b
                                           | v == x = Just a
                                           | otherwise = Nothing
          elemAt i m | i < M.size m = Just $ M.elemAt i m
                     | otherwise = Nothing
          getNextInGroup _ 1 (_, Nothing) =
              return (0, 0, Nothing)
          getNextInGroup _ _ (dst, Nothing) =
              (0, 0, ) <$> ckBl (head dst)  -- using head is safe here
                                            -- because dst cannot be []
          getNextInGroup s i (dst, Just rr) = do
              ns <- if s == 0 then select rr else return s
              ((i +) . length -> ni, headDef Nothing -> d) <-
                  span isNothing <$> mapM ckBl
                        (take (length dst - i) $ drop (ns - 1 + i) $ cycle dst)
              return (ns, ni, d)
          advanceIdx 0 Ok = const 0
          advanceIdx 0 _  = const 1
          advanceIdx _ _  = succ
          ckBl d = do
              bl <- readIORef blacklist
              case M.lookup d bl of
                  Nothing -> return $ Just d
                  Just t -> do
                      now <- getCurrentTime
                      (fromIntegral -> bli) <- readIORef blInterval
                      if diffUTCTime now t > bli
                          then do
                              modifyIORef' blacklist $ M.delete d
                              return $ Just d
                          else return Nothing
          getNext k d = do
              (length -> nk, headDef (0, 0, Nothing) -> d) <-
                  span (\(_, _, b) -> isNothing b) <$>
                      mapM (getNextInGroup 0 0) (M.elems $ M.drop k d)
              return (d, k + nk)
          unblacklistAll = mapM_ $
              mapM_ (modifyIORef' blacklist . M.delete) . fst
ngxExportIOYY 'getMsg
The function tries to find a backend in the routes data according to received data: hint, label, sequential number etc. In case of any errors it writeFinalMsg and the nginx loop finishes. The data is sent from the nginx part as a message of type Msg and returned back from getMsg in the same type.
data BackendStatus = Ok             -- In / Out
                   | NotFound       -- In
                   | NotAccessible  -- In
                   | NotReadable    -- In
                   | NonExistent    -- Out
                   deriving (Read, Show, Eq)

data Msg = Msg { op      :: Op
               , hnt     :: Hint
               , label   :: Label
               , seqn    :: SeqNumber
               , key     :: Int
               , start   :: Int
               , idx     :: Int
               , backend :: Destination
               , status  :: BackendStatus
               } deriving (Read, Show)
The nginx part extracts separate values from the message using special getters.
getSeqn = C8L.pack . show . seqn . readMsg
ngxExportYY 'getSeqn

getKey = C8L.pack . show . key . readMsg
ngxExportYY 'getKey

getStart = C8L.pack . show . start . readMsg
ngxExportYY 'getStart

getIdx = C8L.pack . show . idx . readMsg
ngxExportYY 'getIdx

getBackend = C8L.pack . backend . readMsg
ngxExportYY 'getBackend

getStatus = C8L.pack . show . status . readMsg
ngxExportYY 'getStatus
Locations /conf and /blacklist require additional getters.
getOwnBackends = const $
    encode . fromMaybe M.empty . M.lookup Own <$> readIORef allBackends
ngxExportIOYY 'getOwnBackends

getBlacklist = const $
    encode <$> readIORef blacklist
ngxExportIOYY 'getBlacklist
I won't explain how getMsg works in details: it must be simple. Instead, I'll move to the module's compilation requirements and then make some tests in an environment that emulates what was depicted in the image from the beginning of the article. The requirements of lmr.hs are: ghc-8.0.1 or higher, containers-0.5.8.1 or higher, modules bytestring, async, aeson, http-client, roundRobin, safe and ngx-export. They all can be installed with cabal, but make sure that all depended modules were (re)installed with appropriate containers module's version. The lmr.hs can be compiled with command
ghc -O2 -dynamic -shared -fPIC -lHSrts_thr-ghc$(ghc --numeric-version) lmr.hs -o lmr.so -fforce-recomp
or
ghc -O2 -dynamic -shared -fPIC -lHSrts_thr_debug-ghc$(ghc --numeric-version) lmr.hs -o lmr.so -fforce-recomp -eventlog
if you want to collect haskell events to analyze performance and GC further (in this case you must uncomment the line with directive haskell rts_options -l in file nginx-lmr-http-rules.conf and make sure that the user of an nginx worker process, i.e. nginx, may write into the current directory). My nginx build configuration is
/usr/local/nginx/sbin/nginx -V
nginx version: nginx/1.10.2
built by gcc 6.3.1 20161221 (Red Hat 6.3.1-1) (GCC) 
configure arguments: --add-module=/home/lyokha/devel/nginx-haskell-module --add-module=../echo-nginx-module-0.60
Let's emulate media managers in a dedicated nginx configuration file nginx-lmr-backends.conf.
user                    nginx;
worker_processes        2;

events {
    worker_connections  1024;
}

http {
    default_type        application/octet-stream;
    sendfile            on;

    server {
        listen       8011;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            #echo "In 8011";
            return 404;
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_1" : {
                      "mode": "RW",
                      "hint": ["default"]
                    },
                    "dir_3" : {
                      "mode": "RO",
                      "hint": ["default", "fast"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8012;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            #echo "In 8012";
            return 404;
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_1" : {
                      "mode": "RO",
                      "hint": ["default"]
                    },
                    "dir_2" : {
                      "mode": "RW",
                      "hint": ["default", "fast"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8013;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            echo "In 8013";
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_4" : {
                      "mode": "RW",
                      "hint": ["default"]
                    },
                    "dir_3" : {
                      "mode": "RO",
                      "hint": ["default", "fast"]
                    },
                    "dir_10" : {
                      "mode": "RO",
                      "hint": ["default", "fast"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8021;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            echo "In 8021";
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_1" : {
                      "mode": "RW",
                      "hint": ["default"]
                    },
                    "dir_2" : {
                      "mode": "RW",
                      "hint": ["default", "fast"]
                    },
                    "dir_5" : {
                      "mode": "RW",
                      "hint": ["default"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8022;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            echo "In 8022";
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_1" : {
                      "mode": "RW",
                      "hint": ["default"]
                    },
                    "dir_2" : {
                      "mode": "RW",
                      "hint": ["default", "fast"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8031;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            echo "In 8031";
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_3" : {
                      "mode": "RW",
                      "hint": ["default"]
                    },
                    "dir_2" : {
                      "mode": "RO",
                      "hint": ["default", "fast"]
                    },
                    "dir_4" : {
                      "mode": "RW",
                      "hint": ["default"]
                    }
                  }
                }
            ';
        }
    }

    server {
        listen       8032;
        server_name  backend;

        location ~ ^/(?:read|write)$ {
            echo "In 8032";
        }

        location /conf {
            echo
            '
                {
                  "timestamp": 1423432,
                  "labels": {
                    "dir_10" : {
                      "mode": "RW",
                      "hint": ["default", "fast"]
                    }
                  }
                }
            ';
        }
    }
}
Media managers located by port numbers 8011, 8012 and 8013 are LMM 1, LMM 2 and LMM 3 in terms of the image, 8021 and 8022 are LMM 4 and LMM 5, 8031 and 8032 are LMM 6 and LMM 7. Read and write requests to them return HTTP status 200 and a short message about their location, except for 8011 and 8012 which return status 404. Let the first nginx configuration shown in the article (with data Conf) be the configuration for LMR 1 — nginx-lmr-8010.conf. We also need configurations for LMR 2 and LMR 3 — nginx-lmr-8020.conf and nginx-lmr-8030.conf that are clones of nginx-lmr-8010.conf with correctly adjusted values of partners and backends. As a superuser move lmr.so to directory /var/lib/nginx and start nginx.
cp -i lmr.so /var/lib/nginx
/usr/local/nginx/sbin/nginx -c /home/lyokha/devel/nginx-haskell-module/examples/labeledMediaRouting/nginx-lmr-8010.conf
(Here paths on my computer are shown, they may differ on yours). Let' look at configuration of our media router.
curl -s 'http://localhost:8010/backends' | jq .
[
  {},
  {}
]
curl -s 'http://localhost:8010/conf' | jq .
{
  "127.0.0.1:8011": {
    "labels": {},
    "timestamp": 0
  },
  "127.0.0.1:8012": {
    "labels": {},
    "timestamp": 0
  },
  "127.0.0.1:8013": {
    "labels": {},
    "timestamp": 0
  }
}
(Command jq is an excellent command-line JSON parser that can pretty-print and colorize JSON objects.) Here we see that the both parts of the routes are empty and all the 3 media managers have zero configuration. Anyway, let's try to read from yet inexistent dir_1.
curl -D- 'http://localhost:8010/read?label=dir_1'
HTTP/1.1 503 Service Temporarily Unavailable
Server: nginx/1.10.2
Date: Wed, 04 Jan 2017 14:56:40 GMT
Content-Type: application/octet-stream
Transfer-Encoding: chunked
Connection: keep-alive

No route found
[28548] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 0, key = 0, start = 0, idx = 0, backend = "STOP", status = NonExistent}
Good, No routes found as expected, there is also a debug message with the worker's PID and data Msg returned by function getMsg. Let's start the backends and wait at most 20 sec until the media router find them.
/usr/local/nginx/sbin/nginx -c /home/lyokha/devel/nginx-haskell-module/examples/labeledMediaRouting/nginx-lmr-backends.conf
Waiting...
curl -s 'http://localhost:8010/backends' | jq .
[
  {
    "any": {
      "dir_1": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8011",
            "127.0.0.1:8012"
          ]
        ]
      ],
      "dir_10": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8013"
          ]
        ]
      ],
      "dir_2": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8012"
          ]
        ]
      ],
      "dir_3": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8011",
            "127.0.0.1:8013"
          ]
        ]
      ],
      "dir_4": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8013"
          ]
        ]
      ]
    }
  },
  {
    "default": {
      "any": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8011",
            "127.0.0.1:8012",
            "127.0.0.1:8013"
          ]
        ]
      ]
    },
    "fast": {
      "any": [
        [
          {
            "tag": "Own"
          },
          [
            "127.0.0.1:8012"
          ]
        ]
      ]
    }
  }
]
curl -s 'http://localhost:8010/conf' | jq .
{
  "127.0.0.1:8011": {
    "labels": {
      "dir_1": {
        "mode": "RW",
        "hint": [
          "default"
        ]
      },
      "dir_3": {
        "mode": "RO",
        "hint": [
          "default",
          "fast"
        ]
      }
    },
    "timestamp": 1423432
  },
  "127.0.0.1:8012": {
    "labels": {
      "dir_1": {
        "mode": "RO",
        "hint": [
          "default"
        ]
      },
      "dir_2": {
        "mode": "RW",
        "hint": [
          "default",
          "fast"
        ]
      }
    },
    "timestamp": 1423432
  },
  "127.0.0.1:8013": {
    "labels": {
      "dir_4": {
        "mode": "RW",
        "hint": [
          "default"
        ]
      },
      "dir_3": {
        "mode": "RO",
        "hint": [
          "default",
          "fast"
        ]
      },
      "dir_10": {
        "mode": "RO",
        "hint": [
          "default",
          "fast"
        ]
      }
    },
    "timestamp": 1423432
  }
}
Ouch! Much longer output, and we actually see the structure of LMM 1, LMM 2 and LMM 3 from the image and the media storages behind them. Remember that 8011 and 8012 return status 404?
curl -D- 'http://localhost:8010/read?label=dir_1'
HTTP/1.1 503 Service Temporarily Unavailable
Server: nginx/1.10.2
Date: Wed, 04 Jan 2017 15:09:18 GMT
Content-Type: application/octet-stream
Transfer-Encoding: chunked
Connection: keep-alive

No route found
[28548] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 1, key = 0, start = 1, idx = 0, backend = "STOP", status = NonExistent}
What about simple dir_10 and dir_3 with 2 media managers 8011 and 8013?
curl 'http://localhost:8010/read?label=dir_10'
In 8013
[28548] Msg {op = Read, hnt = "any", label = "dir_10", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_10'
In 8013
[28548] Msg {op = Read, hnt = "any", label = "dir_10", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_10'
In 8013
[28548] Msg {op = Read, hnt = "any", label = "dir_10", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
No route found
[28548] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 1, idx = 0, backend = "STOP", status = NonExistent}
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28548] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
No route found
[28548] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 1, idx = 0, backend = "STOP", status = NonExistent}
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28548] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8013", status = Ok}
(I no longer print HTTP headers to make the output more compact.) Label dir_10 behaves as expected, but label dir_3 gets found and then not found in cycle. Is this what we expected? Yes! Because our backend is actually misconfigured. Remember that when a backend return status 404 we expect that any other backend from this area (i.e. with specific Possession key) will also return 404 and so we skip to another area. In this situation we have a single area and a directory dir_3 with 2 backends: it means that they are chosen in round-robin manner (cycling of start value in the debug message witnesses that). When backend 8011 gets chosen it returns 404 and getMsg skips to the next (inexistent) area without checking other backends in this area, finally emitting the final message with status NonExistent. When the other backend 8013 gets chosen it returns good message In 8013. In the next request the round-robin mechanism choses backend 8011 and so on. Let 8011 return 503: this must fix the issue and the 8013 must be chosen every time with blacklisting of 8011. Replace the line with return 404 in the read/write location of server 8011 in file nginx-lmr-backends.conf with return 503 and restart backends only.
pkill -HUP -f '/usr/local/nginx/sbin/nginx.*nginx-lmr-backends.conf'
The behavior of the dir_3 must alter immediately because the change of the returned status did not change the structure of the routes and hence there is no need to wait.
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 1, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 1, idx = 1, backend = "127.0.0.1:8013", status = Ok}
Correct. Reaching by idx value 1 prompts us that there are blacklisted backends (8011?). Check it.
curl -s 'http://localhost:8010/blacklist' | jq .
{
  "127.0.0.1:8011": "2017-01-04T15:46:03.805737876Z"
}
Let's make 8011 return a good message with status 200. For that, uncomment the line with In 8011 in file nginx-lmr-backends.conf and comment the next line with return 503 (that was return 404 in the beginning). Restart backends only (with the specially crafted signal -HUP as was shown above), wait 1 min (or nothing if the request come to the unaffected nginx worker) and test dir_3 again.
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
In 8011
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 1, idx = 0, backend = "127.0.0.1:8011", status = Ok}
curl 'http://localhost:8010/read?label=dir_3'
In 8013
[28547] Msg {op = Read, hnt = "any", label = "dir_3", seqn = 1, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8013", status = Ok}
curl -s 'http://localhost:8010/blacklist' | jq .
{}
Finally we get a healthy round-robin cycle with the empty blacklist. Let's now undo all changes in backends and start the other 2 media routers.
pkill -HUP -f '/usr/local/nginx/sbin/nginx.*nginx-lmr-backends.conf'
/usr/local/nginx/sbin/nginx -c /home/lyokha/devel/nginx-haskell-module/examples/labeledMediaRouting/nginx-lmr-8020.conf 
/usr/local/nginx/sbin/nginx -c /home/lyokha/devel/nginx-haskell-module/examples/labeledMediaRouting/nginx-lmr-8030.conf
Now they'll start to negotiate with each other every 20 sec per router. You will see that when tailing -f file /var/log/nginx/lmr-access.log or in a sniffer output. Let's look how they see dir_1 for reading requests.
curl -s 'http://localhost:8010/backends' | jq .[0].any.dir_1
[
  [
    {
      "tag": "Own"
    },
    [
      "127.0.0.1:8011",
      "127.0.0.1:8012"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8020"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ]
]
curl -s 'http://localhost:8020/backends' | jq .[0].any.dir_1
[
  [
    {
      "tag": "Own"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8010"
    },
    [
      "127.0.0.1:8011",
      "127.0.0.1:8012"
    ]
  ]
]
curl -s 'http://localhost:8030/backends' | jq .[0].any.dir_1
[
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8010"
    },
    [
      "127.0.0.1:8011",
      "127.0.0.1:8012"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8020"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ]
]
As soon as backends 8011 and 8012 return 404 they all must behave similarly on the read requests.
curl 'http://localhost:8010/read?label=dir_1'
In 8021
[28547] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8010/read?label=dir_1'
In 8022
[28547] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 1, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8010/read?label=dir_1'
In 8021
[28547] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8020/read?label=dir_1'
In 8021
[18664] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 0, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8020/read?label=dir_1'
In 8022
[18664] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8020/read?label=dir_1'
In 8021
[18664] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 2, key = 0, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8030/read?label=dir_1'
In 8021
[18701] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 1, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8030/read?label=dir_1'
In 8022
[18701] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 1, key = 1, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8030/read?label=dir_1'
In 8021
[18701] Msg {op = Read, hnt = "any", label = "dir_1", seqn = 1, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
Label dir_2 must look differently.
curl -s 'http://localhost:8010/backends' | jq .[0].any.dir_2
[
  [
    {
      "tag": "Own"
    },
    [
      "127.0.0.1:8012"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8020"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8030"
    },
    [
      "127.0.0.1:8031"
    ]
  ]
]
curl -s 'http://localhost:8020/backends' | jq .[0].any.dir_2
[
  [
    {
      "tag": "Own"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8010"
    },
    [
      "127.0.0.1:8012"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8030"
    },
    [
      "127.0.0.1:8031"
    ]
  ]
]
curl -s 'http://localhost:8030/backends' | jq .[0].any.dir_2
[
  [
    {
      "tag": "Own"
    },
    [
      "127.0.0.1:8031"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8010"
    },
    [
      "127.0.0.1:8012"
    ]
  ],
  [
    {
      "tag": "Remote",
      "contents": "127.0.0.1:8020"
    },
    [
      "127.0.0.1:8021",
      "127.0.0.1:8022"
    ]
  ]
]
Accessing dir_2 from routers 8010 and 8020 must return In 8021 and In 8022 whereas 8030 must always return In 8031. Check it.
curl 'http://localhost:8010/read?label=dir_2'
In 8021
[28548] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8010/read?label=dir_2'
In 8022
[28548] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 1, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8010/read?label=dir_2'
In 8021
[28548] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 1, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8020/read?label=dir_2'
In 8022
[18662] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8020/read?label=dir_2'
In 8021
[18662] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 0, start = 1, idx = 0, backend = "127.0.0.1:8021", status = Ok}
curl 'http://localhost:8020/read?label=dir_2'
In 8022
[18662] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 2, key = 0, start = 2, idx = 0, backend = "127.0.0.1:8022", status = Ok}
curl 'http://localhost:8030/read?label=dir_2'
In 8031
[18703] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8031", status = Ok}
curl 'http://localhost:8030/read?label=dir_2'
In 8031
[18703] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8031", status = Ok}
curl 'http://localhost:8030/read?label=dir_2'
In 8031
[18703] Msg {op = Read, hnt = "any", label = "dir_2", seqn = 1, key = 0, start = 0, idx = 0, backend = "127.0.0.1:8031", status = Ok}
By the way you may notice that requests to the routers 8010 and 8020 receive sequential number 2 from getMsg whereas requests to 8030 receive 1. This is not a surprise: we started 8020 and 8030 after 8010 and it replaced its configuration only once. The router 8020 was very quick to initialize its configuration before it was changed again by starting of the 8030. The 8030 was starting after the others and its configuration did not change since then. We still did not test several important scenarios here, for example addition of new labels and replacing timestamps in /conf responses from media managers, but I won't do that: the article is already too big and the shown tests do enough to give a taste of the proposed routing approach. The source code and nginx configuration files for the tests can be found here.

среда, 12 октября 2016 г.

Asynchronous handlers in nginx-haskell-module

My journey into learning of how to make haskell and nginx cooperate keeps going. Below is the latest chapter Asynchronous tasks with side effects from the README in the nginx-haskell-module. All variable handlers we met so far were pure haskell functions without side effects. Inability to put side effects into pure functions has a great significance in the sense that it gives strong guarantees about the time the functions run. In haskell, functions that may produce side effects are normally wrapped inside IO monad. They can do various non-deterministic IO computations like reading or writing files, connecting to network servers etc., which, in principle, may last unpredictably long or even eternally. Despite this, having IO functions as nginx variable handlers are extremely tempting as it makes possible to perform arbitrary IO tasks during an HTTP request. To eliminate their non-probabilistic duration downside, they could be run asynchronously in green threads provided by the haskell RTS library, and somehow signal the nginx worker’s main thread after their computations finish. This is exactly what happens in special handler NGX_EXPORT_ASYNC_IOY_Y. Consider the following example.
user                    nobody;
worker_processes        2;

events {
    worker_connections  1024;
}

http {
    default_type        application/octet-stream;
    sendfile            on;

    haskell compile threaded standalone /tmp/ngx_haskell.hs '

import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy.Char8 as C8L
import           Network.HTTP.Client
import           Control.Concurrent
import           Control.Exception
import           Safe

catchHttpException = (`catch` \e ->
        return $ C8L.pack $ "HTTP EXCEPTION: " ++ show (e :: HttpException))

getResponse (C8.unpack -> url) = fmap responseBody . (parseRequest url >>=)

getUrl url = do
    man <- newManager defaultManagerSettings
    catchHttpException $ getResponse url $ flip httpLbs man
NGX_EXPORT_ASYNC_IOY_Y (getUrl)

threadDelaySec = threadDelay . (* 10^6)

delay (readDef 0 . C8.unpack -> v) =
    threadDelaySec v >> return (C8L.pack $ show v)
NGX_EXPORT_ASYNC_IOY_Y (delay)

    ';

    server {
        listen       8010;
        server_name  main;
        error_log    /tmp/nginx-test-haskell-error.log;
        access_log   /tmp/nginx-test-haskell-access.log;

        location / {
            haskell_run_async getUrl $hs_async_ya
                    "http://ya.ru";
            haskell_run_async getUrl $hs_async_httpbin
                    "http://httpbin.org";
            haskell_run_async getUrl $hs_async_hackage
                    "http://hackage.haskell.org";
            echo "------> YA.RU:\n\n$hs_async_ya\n";
            echo "------> HTTPBIN.ORG:\n\n$hs_async_httpbin\n";
            echo "------> HACKAGE.HASKELL.ORG:\n\n$hs_async_hackage";
        }

        location /rewrite {
            rewrite ^ / last;
        }

        location /delay {
            haskell_run_async delay $hs_async_elapsed $arg_a;
            echo "Elapsed $hs_async_elapsed seconds";
        }
    }
}
Notice that the haskell code was compiled with flag threaded which is important for running asynchronous tasks. Function getUrl is an HTTP client that returns the response body or a special message if an HTTP exception has happened. Inside location / there are 3 directives haskell_run_async which spawn 3 asynchronous tasks run by getUrl, and bind future results to 3 different variables accessed later by directives echo in the nginx content phase. Async variable handlers are very special. In fact, the IO task gets spawned even if the bound variable is not accessed anywhere. All the tasks are spawned during early nginx rewrite phase (before all rewrite directives) or late rewrite phase (when all location rewrites are done: this ensures that all tasks in the final rewritten location will run). The request won’t proceed to later phases until all async tasks are done. Technically, an async task signals the main nginx thread when it finishes by writing a byte into the write-end file descriptor of a dedicated self-pipe. The read-end file descriptor of the pipe are polled by the nginx event poller (normally epoll in Linux). When a task is finished, the poller calls a special callback that checks if there are more async tasks for this request and spawns the next one or finally finishes the rewrite phase handler by returning NGX_DECLINED. All types of exceptions are caught inside async handlers. If an exception has happened, the async handler writes its message in the bound variable’s data, whereas the variable handler logs it when accessed. However, for better control, you may want to catch exceptions inside your code like in the getUrl. Let’s do some tests.
curl 'http://localhost:8010/'
Here you will see too long output with the 3 http sites content, I don’t show it here. Let’s run 20 requests simultaneously.
for i in {1..20} ; do curl -s 'http://localhost:8010/' & done
20 times longer output! Let’s make a timer for 20 seconds from 20 parallel requests.
for i in {1..20} ; do curl -s "http://localhost:8010/delay?a=$i" & done
Elapsed 1 seconds
Elapsed 2 seconds
Elapsed 3 seconds
Elapsed 4 seconds
Elapsed 5 seconds
Elapsed 6 seconds
Elapsed 7 seconds
Elapsed 8 seconds
Elapsed 9 seconds
Elapsed 10 seconds
Elapsed 11 seconds
Elapsed 12 seconds
Elapsed 13 seconds
Elapsed 14 seconds
Elapsed 15 seconds
Elapsed 16 seconds
Elapsed 17 seconds
Elapsed 18 seconds
Elapsed 19 seconds
Elapsed 20 seconds
Make sure it prints out every one second: this marks that requests are processed asynchronously! In the second test we ran 20 HTTP requests simultaneously, but could run hundreds and thousands! Some servers may reject so many requests at once (despite the fact that the manager from the Network.HTTP.Client is so advanced that it can share a single connection to the same host between all requests provided it was defined at the top level like
httpManager = unsafePerformIO $ newManager defaultManagerSettings
{-# NOINLINE httpManager #-}

getUrl url = catchHttpException $ getResponse url $ flip httpLbs httpManager
). Fortunately, we can limit number of simultaneous requests with semaphores. Let’s make a semaphore that allows only 1 task at once.
sem1 = unsafePerformIO $ S.new 1
{-# NOINLINE sem1 #-}
Functions unsafePerformIO and new must be imported from modules System.IO.Unsafe and Control.Concurrent.MSem (qualified as S) respectively. This code looks ugly, nevertheless it is safe and will work as expected in our new async handlers getUrl1 and delay1.
getUrl1 url = do
    man <- newManager defaultManagerSettings
    catchHttpException $ getResponse url $ S.with sem1 . flip httpLbs man
NGX_EXPORT_ASYNC_IOY_Y (getUrl1)

delay1 (readDef 0 . C8.unpack -> v) =
    S.with sem1 (threadDelaySec v) >> return (C8L.pack $ show v)
NGX_EXPORT_ASYNC_IOY_Y (delay1)
Put the new handlers in locations / and /delay and make the 20-requests tests again to see how they change the async behavior. For example, responses from location /delay must become so long as if they were not run asynchronously, however they must be finishing not in order. Be aware that sem1 is shared between all async handlers that use it, this means that simultaneous requests to locations / and /delay will probably wait for each other: use different semaphores for different handlers when it is not desirable. Starting an async task that normally returns identical result on every new request may be unnecessarily expensive. In the above example function getUrl must presumably return the same value during a long period of time (days, months or even years). For this case there is another handler NGX_EXPORT_SERVICE_IOY_Y that runs an async task as a service. Let’s put the following service function inside our haskell code.
getUrlService url firstRun = do
    unless firstRun $ threadDelaySec 20
    getUrl url
NGX_EXPORT_SERVICE_IOY_Y (getUrlService)
(For function unless module Control.Monad must be additionally imported.) Function getUrlService accepts two arguments, the second is a boolean value that denotes whether the service runs for the first time: it is supposed to be used to skip threadDelay on the first run. Using threadDelay in a service task is very important, because without any delay nginx will restart it very often. Let’s start getUrlService.
    haskell_run_service getUrlService $hs_service_ya "http://ya.ru";
    haskell_run_service getUrlService $hs_service_httpbin "http://httpbin.org";
Directives haskell_run_service must locate in the http clause of the nginx configuration after directive haskell compile. In contrast with other types of handlers, service handlers cannot refer to variables in their arguments as soon as nginx variable handlers always refer to a request which is not possible here. Put locations for showing data collected by the services and we are done.
        location /ya {
            echo $hs_service_ya;
        }

        location /httpbin {
            echo $hs_service_httpbin;
        }
Complex scenarios may require synchronous access to handlers with side effects. For example it could be an ad-hoc error_page redirection loop: asynchronous handlers do not suit here very well. For such cases another handler NGX_EXPORT_IOY_Y may appear useful. Below is a toy example of a synchronous handler declaration.
getIOValue = const $ return $ C8L.pack "HELLO WORLD!"
NGX_EXPORT_IOY_Y (getIOValue)
You can find all the examples shown here in file test/tsung/nginx-async.conf.

среда, 31 августа 2016 г.

Бенчмаркинг cgrep, ack и hlgrep

Когда-то я сравнивал cgrep и ack с точки зрения функциональных возможностей, пришла пора сравнить их скорости выполнения. Кроме того, недавно мне удалось серьезно оптимизировать программу hl, да так, что при рекурсивном грепе она стала во многих случаях быстрее, чем ack. Сильной оптимизации было подвергнуто время старта программы, поэтому преимущество в скорости особенно заметно при небольшом объеме входных данных. Как и ack, hl написана на perl, но реализует другую функциональность, а именно богатую подсветку входных данных, в том числе на основе сниппетов. Таким образом, рекурсивный греп в hl — возможность чисто факультативная, без большого количества опций, доступных в cgrep и ack. Тем интересней добавить его для сравнения к двум последним. Я не стал добавлять для сравнения стандартный grep. Ему по плечу такие простые задачи, как рекурсивный поиск символов, однако он настолько быстр, что с легкостью опережает на порядок все три узкоспециализированные программы (в частности, на моей машине скорость поиска символов ARGS программой grep внутри директории /usr/include/boost в четыре раза превосходит скорость cgrep). Для бенчмаркинга я использовал замечательную программу bench, которая по сути является простым объединением библиотек criterion (я писал о criterion здесь) и turtle. Поскольку я хочу погонять шелл-функциию cgr, представленную в статье, ссылка на которую приводится в самом начале данной статьи, мне придется обернуть команду для bench в bash -ci — ведь turtle в лице bench не является полноценной bash-совместимой оболочкой и не способна загружать функции bash. Для hl я тоже буду использовать шелл-функцию hlgrep, которая определена так:
function hlgrep
{
    `env which hl` -t -r -n "$@" |
            sed 's/:\(\x1b\[22;38;5;224;49m\)\([[:digit:]]\+\)'`
               `'\(\x1b\[22;38;5;248;49m\)\(\x1b\[0m\): / ⎜ \1\2\1\3 ⎜ /' |
            column -t -s⎜ -o⎜ |
            sed 's/\(\s\+\)\(\(\x1b\[22;38;5;224;49m\)[[:digit:]]\+\3'`
               `'\x1b\[22;38;5;248;49m\)\(\s\+\)/\4\2\1/' |
            hl -u -216 '\x{239C}'
}
Но даже чистую cgrep нужно будет обернуть в bash. Проблема в том, что bench подменяет канал stdin в дочерних процессах, запущенных для тестирования, на новый канал, а cgrep, определив подмену, считает, что последний аргумент его командной строки, который обычно указывает на директорию для чтения входных файлов, должен быть просто еще одним элементом для поиска. Таким образом, нам придется сначала выполнить cd, а уже затем запускать cgrep, но уже без ссылки на директорию. Проще всего это сделать в отдельной оболочке. По той же самой причине подмены stdin, в ack нужно будет добавить специальную опцию --nofilter, которая, впрочем, не должна повлиять на его скорость. Есть еще один тонкий момент, связанный с bench: он аварийно завершается в случае, если тестируемая программа возвращает не нулевое значение. Именно это делает ack, если не находит ни одного совпадения. Поэтому перед запуском bench следует проверить, что совпадения имеются для всех тестов с ack (и, автоматически, для других тестируемых программ), при этом желательно, чтобы совпадений было больше — в конце концов мы тестируем полезную нагрузку. В качестве сценариев для тестирования я выбрал поиск символов ARGS в директории /usr/include/boost на моей машине и поиск символов parse внутри относительно небольшого проекта cexmc. Во втором случае я проверил также чистые cgrep, hl и ack. Каждый тест длился по 60 секунд. И вот что из этого вышло.
bench -v2 -L60 \
'bash -ci "(cd /usr/include/boost; cgr ARGS)"' \
'bash -ci "(cd /usr/include/boost; hlgrep ARGS)"' \
'bash -ci "(cd /usr/include/boost; ack --nofilter ARGS)"' \
'bash -ci "(cd ~/devel/cexmc; cgr parse)"' \
'bash -ci "(cd ~/devel/cexmc; hlgrep parse)"' \
'bash -ci "(cd ~/devel/cexmc; ack --nofilter parse)"' \
'bash -ci "(cd ~/devel/cexmc; cgrep -r parse)"' \
'bash -ci "(cd ~/devel/cexmc; hl -r parse)"'  \
'bash -ci "(cd ~/devel/cexmc; ack --nofilter --noenv parse)"' \
-o ~/bench-cgrep-hlgrep-ack-60s.html
benchmarking bench/bash -ci "(cd /usr/include/boost; cgr ARGS)"
analysing with 1000 resamples
measurement overhead 624.0 ns
bootstrapping with 11 of 12 samples (91%)
time                 694.1 ms   (644.7 ms .. 738.5 ms)
                     0.992 R²   (0.982 R² .. 0.999 R²)
mean                 667.6 ms   (653.5 ms .. 687.1 ms)
std dev              29.20 ms   (19.88 ms .. 38.25 ms)
variance introduced by outliers: 8% (slightly inflated)

benchmarking bench/bash -ci "(cd /usr/include/boost; hlgrep ARGS)"
analysing with 1000 resamples
bootstrapping with 5 of 6 samples (83%)
time                 2.129 s    (2.004 s .. 2.287 s)
                     0.998 R²   (0.992 R² .. 1.000 R²)
mean                 2.197 s    (2.141 s .. 2.249 s)
std dev              68.12 ms   (47.76 ms .. 82.33 ms)
variance introduced by outliers: 14% (moderately inflated)

benchmarking bench/bash -ci "(cd /usr/include/boost; ack --nofilter ARGS)"
analysing with 1000 resamples
bootstrapping with 5 of 6 samples (83%)
time                 2.250 s    (2.211 s .. 2.360 s)
                     0.999 R²   (0.995 R² .. 1.000 R²)
mean                 2.239 s    (2.219 s .. 2.279 s)
std dev              32.61 ms   (5.023 ms .. 43.91 ms)
found 1 outliers among 5 samples (20.0%)
  1 (20.0%) high mild
variance introduced by outliers: 14% (moderately inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; cgr parse)"
analysing with 1000 resamples
bootstrapping with 36 of 37 samples (97%)
time                 77.04 ms   (76.21 ms .. 78.01 ms)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 76.23 ms   (75.70 ms .. 76.84 ms)
std dev              1.794 ms   (1.429 ms .. 2.352 ms)
found 2 outliers among 36 samples (5.6%)
  1 (2.8%) low mild
  1 (2.8%) high mild
variance introduced by outliers: 8% (slightly inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; hlgrep parse)"
analysing with 1000 resamples
bootstrapping with 33 of 34 samples (97%)
time                 94.47 ms   (93.40 ms .. 95.40 ms)
                     1.000 R²   (0.999 R² .. 1.000 R²)
mean                 93.74 ms   (93.27 ms .. 94.17 ms)
std dev              1.325 ms   (1.054 ms .. 1.740 ms)
variance introduced by outliers: 3% (slightly inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; ack --nofilter parse)"
analysing with 1000 resamples
bootstrapping with 29 of 30 samples (96%)
time                 121.5 ms   (121.0 ms .. 122.0 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 121.6 ms   (121.1 ms .. 122.2 ms)
std dev              1.449 ms   (1.174 ms .. 1.830 ms)
variance introduced by outliers: 3% (slightly inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; cgrep -r parse)"
analysing with 1000 resamples
bootstrapping with 38 of 39 samples (97%)
time                 68.35 ms   (66.80 ms .. 70.55 ms)
                     0.996 R²   (0.994 R² .. 0.999 R²)
mean                 68.03 ms   (67.52 ms .. 68.83 ms)
std dev              2.001 ms   (1.543 ms .. 2.696 ms)
found 5 outliers among 38 samples (13.2%)
  4 (10.5%) high mild
  1 (2.6%) high severe
variance introduced by outliers: 12% (moderately inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; hl -r parse)"
analysing with 1000 resamples
bootstrapping with 34 of 35 samples (97%)
time                 87.98 ms   (87.55 ms .. 88.39 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 88.04 ms   (87.77 ms .. 88.32 ms)
std dev              836.7 μs   (676.7 μs .. 1.051 ms)
variance introduced by outliers: 3% (slightly inflated)

benchmarking bench/bash -ci "(cd ~/devel/cexmc; ack --nofilter --noenv parse)"
analysing with 1000 resamples
bootstrapping with 29 of 30 samples (96%)
time                 120.8 ms   (119.5 ms .. 122.5 ms)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 119.7 ms   (119.3 ms .. 120.3 ms)
std dev              1.456 ms   (1.006 ms .. 2.055 ms)
found 3 outliers among 29 samples (10.3%)
  3 (10.3%) high mild
variance introduced by outliers: 3% (slightly inflated)
Графический отчет здесь. Выводы следующие: cgrep быстрее всех, особенно при большом объеме входных данных (в директории /usr/include/boost) — 0.668 против 2.20 и 2.24 сек у hlgrep и ack. При небольших объемах данных (в директории cexmc) cgrep по-прежнему лидирует, хоть и с небольшим отрывом (0.076 сек), hlgrep значительно опережает ack (0.094 против 0.122 сек) за счет стартовой скорости. Чистые варианты cgrep, hl и ack в директории cexmc незначительно быстрее шелл-функций (0.068 против 0.076 сек в случае cgrep, 0.088 против 0.094 сек в случае hl и 0.120 против 0.122 сек в случае ack) — это говорит о том, что конвейеры, состоящие из программ sed, column и hl, из которых собраны эти функции, достаточно быстры.