-
Notifications
You must be signed in to change notification settings - Fork 220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove race condition in postTransactionOld
where pending (spent) UTxO could be selected as inputs
#2827
Remove race condition in postTransactionOld
where pending (spent) UTxO could be selected as inputs
#2827
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
{-# LANGUAGE NamedFieldPuns #-} | ||
{-# LANGUAGE RankNTypes #-} | ||
|
||
{- HLINT ignore "Use newtype instead of data" -} | ||
|
||
-- | | ||
-- Copyright: © 2021 IOHK | ||
-- License: Apache-2.0 | ||
-- | ||
-- This module provides a utility for ordering concurrent actions | ||
-- via locks. | ||
module Control.Concurrent.Concierge | ||
( Concierge | ||
, newConcierge | ||
, atomicallyWith | ||
, atomicallyWithLifted | ||
) | ||
where | ||
|
||
import Prelude | ||
|
||
import Control.Monad.Class.MonadFork | ||
( MonadThread, ThreadId, myThreadId ) | ||
import Control.Monad.Class.MonadSTM | ||
( MonadSTM | ||
, TVar | ||
, atomically | ||
, modifyTVar | ||
, newTVarIO | ||
, readTVar | ||
, retry | ||
, writeTVar | ||
) | ||
import Control.Monad.Class.MonadThrow | ||
( MonadThrow, bracket ) | ||
import Control.Monad.IO.Class | ||
( MonadIO, liftIO ) | ||
import Data.Map.Strict | ||
( Map ) | ||
|
||
import qualified Data.Map.Strict as Map | ||
|
||
{------------------------------------------------------------------------------- | ||
Concierge | ||
-------------------------------------------------------------------------------} | ||
-- | At a 'Concierge', you can obtain a lock and | ||
-- enforce sequential execution of concurrent 'IO' actions. | ||
-- | ||
-- Back in the old days, hotel concierges used to give out keys. | ||
-- But after the cryptocurrency revolution, they give out locks. :) | ||
-- (The term /lock/ is standard terminology in concurrent programming.) | ||
Comment on lines
+49
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds like you're drawing a long bow with this Concierge name. 😛 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😂 I have a sweet spot for figurative names. I briefly considered "Locksmith", but that suggested that the locks were being created on the fly instead of being rented out. The drawback of figurative names is that they take a moment of explanation, but the drawback of generic names like "lock collection", "lock factory", "lock registry", … is that it's easy to forget what they do. |
||
data Concierge m lock = Concierge | ||
{ locks :: TVar m (Map lock (ThreadId m)) | ||
} | ||
|
||
-- | Create a new 'Concierge' that keeps track of locks. | ||
newConcierge :: MonadSTM m => m (Concierge m lock) | ||
newConcierge = Concierge <$> newTVarIO Map.empty | ||
|
||
-- | Obtain a lock from a 'Concierge' and run an 'IO' action. | ||
-- | ||
-- If the same (equal) lock is already taken at this 'Concierge', | ||
-- the thread will be blocked until the lock becomes available. | ||
-- | ||
-- The action may throw a synchronous or asynchronous exception. | ||
-- In both cases, the lock is returned to the concierge. | ||
atomicallyWith | ||
:: (Ord lock, MonadIO m, MonadThrow m) | ||
=> Concierge IO lock -> lock -> m a -> m a | ||
atomicallyWith = atomicallyWithLifted liftIO | ||
|
||
-- | More polymorphic version of 'atomicallyWith'. | ||
atomicallyWithLifted | ||
:: (Ord lock, MonadSTM m, MonadThread m, MonadThrow n) | ||
=> (forall b. m b -> n b) | ||
-> Concierge m lock -> lock -> n a -> n a | ||
atomicallyWithLifted lift Concierge{locks} lock action = | ||
bracket acquire (const release) (const action) | ||
where | ||
acquire = lift $ do | ||
tid <- myThreadId | ||
atomically $ do | ||
ls <- readTVar locks | ||
case Map.lookup lock ls of | ||
Just _ -> retry | ||
Nothing -> writeTVar locks $ Map.insert lock tid ls | ||
release = lift $ | ||
atomically $ modifyTVar locks $ Map.delete lock |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
{-# LANGUAGE RankNTypes #-} | ||
module Control.Concurrent.ConciergeSpec | ||
( spec | ||
) where | ||
|
||
import Prelude | ||
|
||
import Control.Concurrent.Concierge | ||
( atomicallyWithLifted, newConcierge ) | ||
import Control.Monad.Class.MonadFork | ||
( forkIO ) | ||
import Control.Monad.Class.MonadSay | ||
( say ) | ||
import Control.Monad.Class.MonadTimer | ||
( threadDelay ) | ||
import Control.Monad.IOSim | ||
( IOSim, runSimTrace, selectTraceEventsSay ) | ||
import Control.Monad.Trans.Class | ||
( lift ) | ||
import Control.Monad.Trans.Except | ||
( ExceptT, catchE, runExceptT, throwE ) | ||
import Test.Hspec | ||
( Spec, describe, it, parallel ) | ||
import Test.QuickCheck | ||
( Property, (===) ) | ||
|
||
spec :: Spec | ||
spec = do | ||
parallel $ describe "Control.Concurrent.Concierge" $ do | ||
it "Atomic operations do not interleave" | ||
unit_atomic | ||
|
||
it "throwE in ExceptT releases lock" | ||
unit_exceptT_release_lock | ||
|
||
{------------------------------------------------------------------------------- | ||
Properties | ||
-------------------------------------------------------------------------------} | ||
-- | Deterministic test for atomicity. | ||
-- We have to compare a program run that interleaves | ||
-- against one that is atomic. | ||
unit_atomic :: Bool | ||
unit_atomic = | ||
("ABAB" == sayings testInterleave) && ("AABB" == sayings testAtomic) | ||
where | ||
sayings :: (forall s. IOSim s a) -> String | ||
sayings x = concat . selectTraceEventsSay $ runSimTrace x | ||
|
||
testAtomic = do | ||
concierge <- newConcierge | ||
test $ atomicallyWithLifted id concierge () | ||
testInterleave = test id | ||
|
||
test :: (forall a. IOSim s a -> IOSim s a) -> IOSim s () | ||
test atomically = do | ||
_ <- forkIO $ atomically (delay 1 >> action "B") | ||
atomically $ action "A" | ||
delay 4 | ||
|
||
action :: String -> IOSim s () | ||
action s = say s >> delay 2 >> say s | ||
|
||
delay :: Int -> IOSim s () | ||
delay n = threadDelay (fromIntegral n*0.1) | ||
|
||
-- | Check that using 'throwE' in the 'ExceptE' monad releases the lock | ||
unit_exceptT_release_lock :: Property | ||
unit_exceptT_release_lock = | ||
["A"] === selectTraceEventsSay (runSimTrace $ runExceptT test) | ||
where | ||
liftE :: IOSim s a -> ExceptT String (IOSim s) a | ||
liftE = lift | ||
|
||
test :: ExceptT String (IOSim s) () | ||
test = do | ||
concierge <- liftE newConcierge | ||
let atomically = atomicallyWithLifted liftE concierge () | ||
_ <- tryE $ atomically $ throwE "X" | ||
atomically $ liftE $ say "A" | ||
|
||
-- not exported in transformers <= 0.5.6 | ||
tryE :: Monad m => ExceptT e m a -> ExceptT e m (Either e a) | ||
tryE action = (Right <$> action) `catchE` (pure . Left) |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should be able to put this lock variable into the
WalletLayer
type, right?Then we wouldn't need a Concierge.
As I understand the problem, the lock we need is a spending coin selection lock. While there is a coin selection running, if the resulting TxIns will be marked as spent (pending), then we must prevent other threads from selecting and spending any UTxO from the same wallet.
Unfortunately, as you previously pointed out, much of
postTransactionOld
should be in the Wallet layer not API server layer. But it should still be possible to put this lock variable in the Wallet layer where it belongs.Also, a future version of
constructTransaction
(the new transaction API) will have a query parameter to mark the UTxO which were selected as spent (pending). So we will need exactly the same coin selection locking there.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, it's not a single lock for the entire layer, but one lock for each wallet id. 😅 The first approach that comes to mind for managing these locks would be to create each one lock at the beginning of the lifecycle of each wallet id. However, I think that it's less intrusive to use this Concierge thing instead, because it doesn't need to know about the lifecycle.
My current thinking is that these locks around
postTransactionOld
are supposed to be a quick fix for an issue that Binance is having, but not more. Imposing an ordering on thePOST transactions
endpoint seemed like an adequate solution for that, and that is something I would attribute to theApiLayer
, even though I (perhaps paradoxically) agree thatpostTransactionOld
should be part of the wallet layer.On the Wallet layer, the possibility to do
selectAssets
andsubmitTx
separately makes it possible to circumvent the lock. Hence, on the wallet layer, I think thatpostTransactionOld
should come without a lock. I think that for this layer, we need a more principled solution, especially because it is possible to do a coin selection and then discard the result without ever submitting to the chain, e.g. asestimateFee
does. I would prefer a solution in terms of pure data dependencies: A user can do as many coin selections as they like, but each of these will reference the wallet state from which the selection was made. Submitting one selection to the wallet will render the others invalid, because they do not fit to the new wallet state; this could result in a helpful error message.TL;DR: My preference would be more pure code (later), less locks. 😅 Hence this "throwaway" solution to locking the POST transactions endpoint.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK - I appreciate the quick fix approach, because as the name suggests,
postTransactionOld
is not going to be around for long.I probably should have said it explicitly - there is one WalletLayer per WalletId, so it seems to me like the natural home for this lock variable. The lock would be needed in the medium term, even after
postTransactionOld
has gone.I think we pretty much agree but perhaps don't have a common vocabulary for this thing. I differentiate between "coin selections" and "spending coin selections" - the latter is where the TxIns to be spent must be immediately marked as Used with a Pending transaction. The former don't need a lock, the latter do.
It would be preferable for the wallet to run spending coin selections sequentially, rather than potentially return error messages about conflicts, invalid txins, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh. I did not know that. This fact was not apparent to me, because all the functions in
Cardano.Wallet
take aWalletId
and go through great pains to throw anErrNoSuchWallet
exception if that Id is invalid, hence I assumed that theWalletLayer
cannot be tailored to be a specificWalletId
, for then there would be no reason to throw this exception. Oh my. Can I vote to put this on the "to renovate" wishlist? 😅Hm. For the sake of argument, let me try to find a way to get a lock into the
WalletLayer
. AWalletLayer
is created only withhoistResource
applied to anApiLayer
:https://github.com/input-output-hk/cardano-wallet/blob/3f9513b926f0c9ebb7884da386e7419e8da56e27/lib/core/src/Cardano/Wallet/Api.hs#L1090-L1095
This is used in
withWorkerCtx
which is called in pretty much every a REST API endpoint:https://github.com/input-output-hk/cardano-wallet/blob/3f9513b926f0c9ebb7884da386e7419e8da56e27/lib/core/src/Cardano/Wallet/Api/Server.hs#L3109-L3140
Oh my. This means that in order to create a lock in the
WalletLayer
, I would have to create it in theApiLayer
first, and then percolate it to the right wallet Id. In other words, the collection of locks (the equivalent of theConcierge
thing) will not go away, instead, now it also has to be propagated to eachWalletLayer
. And I would have to think about the lifecycle: When do I create the lock, e.g. callnewMVar
? I suppose inregisterWorker
, but I'm not entirely sure actually.https://github.com/input-output-hk/cardano-wallet/blob/3f9513b926f0c9ebb7884da386e7419e8da56e27/lib/core/src/Cardano/Wallet/Api/Server.hs#L3071-L3102
Hm. I think the easiest solution is the following: If you feel that the
WalletLayer
should at least know about the locks, then I can add aConcierge
field to theWalletLayer
and propagate a vanilla copy from theApiLayer
. But adding a lifecycle for the lock seems to be more trouble than it's worth at the moment; I would rather work on the DB layer renovation which will allow us to make the code much more pure, which in turn makes most of these problems disappear. 😅