hydra-node
Safe HaskellSafe-Inferred
LanguageGHC2021

Hydra.Network.Etcd

Description

Implements a Hydra network component using etcd.

While implementing a basic broadcast protocol over a distributed key-value store is quite an overkill, the Raft consensus of etcd provides our application with a crash-recovery fault-tolerant "atomic broadcast" out-of-the-box. As a nice side-effect, the network layer becomes very introspectable, while it would also support features like TLS or service discovery.

The component installs, starts and configures an $sel:etcd:EtcdLog instance and connects to it using a GRPC client. We can only write and read from the cluster while connected to the majority cluster.

Broadcasting is implemented using put to some well-known key, while message delivery is done by using watch on the same msg prefix. We keep a last known revision, also stored on disk, to start watch with that revision (+1) and only deliver messages that were not seen before. In case we are not connected to our $sel:etcd:EtcdLog instance or not enough peers (= on a minority cluster), we retry sending, but also store messages to broadcast in a PersistentQueue, which makes the node resilient against crashes while sending.

Connectivity and compatibility with other nodes on the cluster is tracked using the key-value service as well:

  • network connectivity is determined by being able to fetch the member list
  • peer connectivity is tracked (best effort, not authorized) using an entry at 'alive-<advertise>' keys with individual leases and repeated keep-alives
  • each node compare-and-swaps its $sel:version:EtcdLog into a key of the same name to check compatibility (not updatable)

Note that the etcd cluster is configured to compact revisions down to 1000 every 5 minutes. This prevents infinite growth of the key-value store, but also limits how long a node can be disconnected without missing out. 1000 should be more than enough for our use-case as the Hydra protocol will not advance unless all participants are present.

Synopsis

Documentation

withEtcdNetwork :: (ToCBOR msg, FromCBOR msg) => Tracer IO EtcdLog -> ProtocolVersion -> NetworkConfiguration -> NetworkComponent IO msg msg () Source #

Concrete network component that broadcasts messages to an etcd cluster and listens for incoming messages.

connParams :: Tracer IO EtcdLog -> Maybe Timeout -> ConnParams Source #

getClientPort :: NetworkConfiguration -> PortNumber Source #

Get the client port corresponding to a listen address.

The client port used by the started etcd port is offset by the same amount as the listen address is offset by the default port 5001. This will result in the default client port 2379 be used by default still.

peerPortToClientPort :: PortNumber -> PortNumber Source #

Derive the etcd client port from a configured peer (listen) port.

Exposed separately from getClientPort so test fixtures can pre-allocate both the peer and the derived client port without constructing a full NetworkConfiguration. Keep this and getClientPort in lockstep — any change to the offset must happen here, in one place.

checkVersion :: Tracer IO EtcdLog -> Connection -> ProtocolVersion -> NetworkCallback msg IO -> IO () Source #

Check and write version on etcd cluster. This will retry until we are on a majority cluster and succeed. If the version does not match a corresponding Connectivity message is sent via NetworkCallback.

broadcastMessages Source #

Arguments

:: Tracer IO EtcdLog 
-> NetworkConfiguration 
-> Host

Used to identify sender.

-> PersistentQueue IO msg 
-> IO () 

Broadcast messages from a queue to the etcd cluster.

Retries on failure to putMessage in case we are on a minority cluster or when the grpc call timeouts.

Idempotent under transient GrpcDeadlineExceeded: putMessage uses an etcd transaction conditioned on the key's current mod_revision matching the last revision we successfully wrote. If a deadline-exceeded retry arrives at etcd after the original request already committed, the compare fails server-side (the mod_revision has advanced), the failure branch's range tells us what the new revision is, and we move on without writing a second time. So a retry whose original committed creates zero extra etcd revisions and the watcher on each peer sees exactly one event per logical broadcast. Same key namespace as master ('msg-<host>'), no disk growth.

queryInitialModRev :: Tracer IO EtcdLog -> NetworkConfiguration -> Host -> IO Int64 Source #

Query etcd for the current mod_revision of this peer's broadcast key. Returns 0 if the key does not yet exist. Used by broadcastMessages to seed its in-memory baseline so subsequent compare mod_revision checks match etcd's reality from the very first putMessage.

putMessage Source #

Arguments

:: Tracer IO EtcdLog 
-> Connection

Connection provided (and recycled) by broadcastMessages.

-> Host

Used to identify sender.

-> TVar IO Int64

The peer's last observed mod_revision on its own broadcast key.

-> ByteString

Value to write, a batch of serialized messages (see batchValue).

-> IO () 

Broadcast a message to the etcd cluster.

Wraps the etcd put in a Txn:

  • compare: mod_revision(key) == lastModRev
  • success: put(key, value)
  • failure: range(key) (so we can learn the actual mod_revision if our compare failed, e.g. because a previous attempt of ours committed server-side after returning GrpcDeadlineExceeded to the client)

lastModRevVar is updated in both branches: from the response header on a successful put, from the range result on a compare failure. Retries of the same msg after a deadline-exceeded converge to a single effective revision rather than producing duplicate deliveries. Because broadcastMessages seeds lastModRev from etcd at startup (queryInitialModRev), a compare failure unambiguously means "this peer's earlier attempt already wrote a later revision than we have recorded" — i.e. the message is already delivered and the caller can pop.

Cluster-reset behaviour (intentionally fatal): if the compare fails and the range branch returns no kvs, the etcd cluster has lost the key we wrote against — either the data dir was wiped or the cluster was replaced underneath us. We do not silently reseed: this is a node-level event that should surface, not be papered over. putMessage calls fail, which kills the broadcast loop, propagates up to take down the node, and on restart queryInitialModRev re-seeds lastModRev from whatever state etcd actually has.

batchValue :: [ByteString] -> ByteString Source #

Assemble the etcd value for a batch of already-serialized messages: a CBOR list reusing each message's encoding verbatim. Uses the same indefinite-length list format as cardano-binary's list instances, so the value decodes as [msg] on the receiving side.

waitMessages :: forall msg. FromCBOR msg => Tracer IO EtcdLog -> Connection -> FilePath -> NetworkCallback msg IO -> IO () Source #

Fetch and wait for messages from the etcd cluster.

data LastKnownRevisionException Source #

The persisted watch revision could not be read.

Fatal on purpose, and deliberately not a retryableEtcdError: re-reading the same bad file cannot succeed, and carrying on from revision 0 would rewind the watch to the beginning of history. Deleting the file is the fix, so say so.

Constructors

InvalidLastKnownRevision FilePath

The file exists but does not hold a revision.

UnreadableLastKnownRevision FilePath String

The file exists but could not be read.

putLastKnownRevision :: MonadIO m => FilePath -> Natural -> m () Source #

Record the revision, atomically.

NOTE: Written to a temporary file and renamed into place, because a plain encodeFile is not atomic: killing the node mid-write (which happens routinely, both in tests and on restart) would otherwise leave a truncated file that getLastKnownRevision cannot parse.

NOTE: The rename is not fsynced, so an OS crash can still land it ahead of the data blocks. Closing that window costs an fsync per call, and this runs once per watch response, i.e. per received broadcast. The resulting file is instead handled on the next start, by LastKnownRevisionException and its hint.

pollConnectivity Source #

Arguments

:: Tracer IO EtcdLog 
-> Connection 
-> Host

Local host

-> NetworkCallback msg IO 
-> IO () 

Write a well-known key to indicate being alive, keep it alive using a lease and poll other peers entries to yield connectivity events. While doing so, overall network connectivity is determined from the ability to read/write to the cluster.

isTransientGrpcError :: GrpcError -> Bool Source #

Predicate for gRPC errors that we treat as transient — connection blips or etcd-side disruption from which a retry is expected to recover. Anything outside this set is escalated by re-throwing.

GrpcNotFound is included specifically for lease loss: when etcd's RAFT leader changes under network stress, in-flight leases are revoked, and the next operation referencing one ('pollConnectivity.writeAlive') comes back with NOT_FOUND. The recovery is to mark the network as disconnected and let the outer loop recreate the lease, not crash the node.

retryableEtcdError :: SomeException -> Maybe Text Source #

Classify a failure talking to our local etcd, giving a reason to trace when retrying is the right response.

Connection-level failures qualify: etcd is a subprocess we started and connParams reconnects, so a lost connection is a blip rather than a reason to take the node down. Those arrive either as HTTP2Error (straight from the http2 client, e.g. the SETTINGS rate limit of #2817) or ServerDisconnected (grapesy's wrapper when a call outlives its connection). Everything else escalates, including putMessages cluster-reset fail and LastKnownRevisionException.

withGrpcContext :: MonadCatch m => Text -> m a -> m a Source #

Add context to the grpcErrorMessage of any GrpcException raised.

withProcessInterrupt :: (MonadIO m, MonadThrow m) => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a Source #

Like withProcessTerm, but sends first SIGINT and only SIGTERM if not stopped within 5 seconds.

Persistent queue

data PersistentQueue m a Source #

Queue elements carry the item's CBOR serialization, produced once on write and reused for both the on-disk file and the etcd value, so broadcasting does not serialize twice.

Constructors

PersistentQueue 

Fields

newPersistentQueue :: (MonadLabelledSTM m, MonadIO m, FromCBOR a, MonadCatch m, MonadFail m) => Tracer IO EtcdLog -> FilePath -> Natural -> m (PersistentQueue m a) Source #

Create a new persistent queue at file path and given capacity.

writePersistentQueue :: (ToCBOR a, MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> a -> m () Source #

Write a value to the queue, blocking if the queue is full.

peekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m a Source #

Get the next value from the queue without removing it, blocking if the queue is empty.

tryPeekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m (Maybe a) Source #

Like peekPersistentQueue, but returns Nothing instead of blocking when the queue is empty.

peekBatchPersistentQueue :: MonadSTM m => PersistentQueue m a -> Int -> Int -> m [(a, ByteString)] Source #

Get all pending values and their serializations, up to the given count and total byte limits, blocking until at least one is available. Values are not removed; use popBatchPersistentQueue after they were sent.

nextPendingBatch :: MonadSTM m => TVar m (Maybe [(a, ByteString)]) -> PersistentQueue m a -> Int -> Int -> m (Maybe [(a, ByteString)]) Source #

Get the batch to broadcast next: the batch already in flight if there is one, otherwise a fresh one peeked from the queue (and recorded as in flight). Returns Nothing when nothing is pending. The caller must clear the in-flight var after popping a successfully sent batch.

Pinning the in-flight batch across transient retries matters for the compare-fail dedup in putMessage: a put can commit server-side while the client sees e.g. GrpcDeadlineExceeded. The retry must send (and afterwards pop) exactly the content of the committed attempt. Re-peeking on retry could pick up messages enqueued in the meantime; the dedup branch would then declare the grown batch delivered and the never-sent tail would be popped and lost.

popBatchPersistentQueue :: (MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> [(a, ByteString)] -> m () Source #

Remove a batch previously returned by peekBatchPersistentQueue. Pops unconditionally, one item per batch entry: this thread is the sole consumer, so the queue head still holds exactly the peeked items (an item-matching guard could only silently no-op and wedge the queue, see #2742).

removeQueueFile :: MonadIO m => Tracer IO EtcdLog -> FilePath -> Natural -> m () Source #

Delete the backing file of a popped queue item. Failing to delete is traced but not fatal: the message was already broadcast, so a leftover file only means it may be re-broadcast after a restart (at-least-once delivery, same as the crash-recovery path).

Tracing

data EtcdLog Source #

Constructors

EtcdLog 

Fields

Reconnecting 
BroadcastFailed 

Fields

FailedToDecodeLog 

Fields

FailedToDecodeValue 

Fields

CreatedLease 

Fields

LowLeaseTTL 

Fields

NoKeepAliveResponse 
MatchingProtocolVersion 
WatchMessagesStartRevision 

Fields

WatchMessagesFallbackTo 
WatchFailed

The watch stream failed; it is restarted from the last known revision.

Fields

BroadcastDeduped

The etcd transaction wrapping a broadcast put found that our key's mod_revision had moved past what we last recorded — the expected outcome when a GrpcDeadlineExceeded-retried put already committed server-side. No second put was issued.

PersistentQueueLoadFailed

Failed to load persisted queue items from disk on startup. The queue starts empty; any in-flight messages from before the crash are lost.

Fields

PersistentQueueFull

The persistent queue has reached capacity. The calling thread will block until the broadcast loop drains at least one item.

PersistentQueueDeleteFailed

Failed to delete the backing file of an already-broadcast item. The queue keeps operating; the leftover file only means the message may be re-broadcast after a restart.

Fields

Instances

Instances details
ToJSON EtcdLog Source # 
Instance details

Defined in Hydra.Network.Etcd

Methods

toJSON :: EtcdLog -> Value

toEncoding :: EtcdLog -> Encoding

toJSONList :: [EtcdLog] -> Value

toEncodingList :: [EtcdLog] -> Encoding

omitField :: EtcdLog -> Bool

Generic EtcdLog Source # 
Instance details

Defined in Hydra.Network.Etcd

Associated Types

type Rep EtcdLog :: Type -> Type Source #

Show EtcdLog Source # 
Instance details

Defined in Hydra.Network.Etcd

Eq EtcdLog Source # 
Instance details

Defined in Hydra.Network.Etcd

type Rep EtcdLog Source # 
Instance details

Defined in Hydra.Network.Etcd

type Rep EtcdLog = D1 ('MetaData "EtcdLog" "Hydra.Network.Etcd" "hydra-node-2.3.0-1cgalYNmLJQC1YXlYVq7mp" 'False) ((((C1 ('MetaCons "EtcdLog" 'PrefixI 'True) (S1 ('MetaSel ('Just "etcd") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Value)) :+: C1 ('MetaCons "Reconnecting" 'PrefixI 'False) (U1 :: Type -> Type)) :+: (C1 ('MetaCons "BroadcastFailed" 'PrefixI 'True) (S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text)) :+: C1 ('MetaCons "FailedToDecodeLog" 'PrefixI 'True) (S1 ('MetaSel ('Just "log") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text)))) :+: ((C1 ('MetaCons "FailedToDecodeValue" 'PrefixI 'True) (S1 ('MetaSel ('Just "key") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text) :*: (S1 ('MetaSel ('Just "value") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text))) :+: C1 ('MetaCons "CreatedLease" 'PrefixI 'True) (S1 ('MetaSel ('Just "leaseId") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64))) :+: (C1 ('MetaCons "LowLeaseTTL" 'PrefixI 'True) (S1 ('MetaSel ('Just "ttlRemaining") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64)) :+: C1 ('MetaCons "NoKeepAliveResponse" 'PrefixI 'False) (U1 :: Type -> Type)))) :+: (((C1 ('MetaCons "MatchingProtocolVersion" 'PrefixI 'True) (S1 ('MetaSel ('Just "version") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 ProtocolVersion)) :+: C1 ('MetaCons "WatchMessagesStartRevision" 'PrefixI 'True) (S1 ('MetaSel ('Just "startRevision") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64))) :+: (C1 ('MetaCons "WatchMessagesFallbackTo" 'PrefixI 'True) (S1 ('MetaSel ('Just "compactRevision") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64)) :+: C1 ('MetaCons "WatchFailed" 'PrefixI 'True) (S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text)))) :+: ((C1 ('MetaCons "BroadcastDeduped" 'PrefixI 'True) (S1 ('MetaSel ('Just "previousModRev") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64) :*: S1 ('MetaSel ('Just "observedModRev") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Int64)) :+: C1 ('MetaCons "PersistentQueueLoadFailed" 'PrefixI 'True) (S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text))) :+: (C1 ('MetaCons "PersistentQueueFull" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "PersistentQueueDeleteFailed" 'PrefixI 'True) (S1 ('MetaSel ('Just "index") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Natural) :*: S1 ('MetaSel ('Just "reason") 'NoSourceUnpackedness 'NoSourceStrictness 'DecidedStrict) (Rec0 Text))))))