CSC 2/458 18, 21 April 2019 Transactions Reading: Coulouris et al., Chap 16. Relevant to distributed systems (focus of the reading) database systems concurrent data structures (transactional memory) In distributed setting, assume fail-stop or fail-recover threads (possible lost messages) At any individual node, rely on nonvolatile memory historically disk or flash; now may be Optane, or other things soon common but non-universal terminology: data is _transient_ or _persistent_ storage is _volatile_ or _nonvolatile_ _stable storage_ provides atomic (untorn) writes, typically at file block granularity txn API start_txn // app-specific variants of read, write self-abort try_to_commit typical implementation returns status value from txn committed self-aborted // probably not worth re-trying conflict-aborted // might be worth re-trying (maybe) spurious (transient) aborted Instead of re-trying we might return status to caller take some different code path Recall serializability history is equivalent to one in which transactions happen one at a time. If we think of threads as executing transactions in order, then earlier txns in a given thread serialize before later txns in the same thread strict serializability if txn 1 ends before txn 2 starts (even if in different threads), then txn 1 serializes before txn 2 unlike linearizability, applications can _compose_ arbitrary ops inside a txn we don't get locality (can't prove correctness of objects independent of one another) Standard transaction properties: ACID (ideas introduced by Eswaran, Gray, Lorie, and Traiger [CACM 1976]; acronym coined by Harder & Reuter [CompSur 1983]) (reading does a poor job of covering these) (failure) atomic -- whole txn happens or none of it does, even in the presence of crashes consistent -- each txn moves the system from a state in which all invariants hold to another in which all invariants hold isolated -- during (crash-free) execution, a txn sees no external changes, and nobody sees its transient internal states durable -- a transaction that is visible to other threads will survive a crash Independent transactions aren't a challenge to isolation. We'd like to run these in parallel and avoid synchronizing them. Conflicting transactions _are_ a challenge. Transactions are said to conflict if they contain operations that don't commute. The most basic example is that reads commute with one another, but writes (to the same data) don't commute with each other or with reads. Arbitration strategies for conflicting transactions: pessimistic (eager) -- update metadata on initial access; detect conflicts with other transactions; resolve by order of start times, order of arrival time at conflicting object; number of locks held; etc. optimistic (lazy) -- buffer all updates; wait until one txn is ready to commit; do so if it has no conflict with anything that has already committed notion of cascading aborts If I commit T2 on the basis of writes from T1 ("dirty reads" in T2) and then T1 aborts, I'm in trouble. An aggressive system may allow me to read T1's writes, but only if I delay my commit until I'm sure T1 has committed. A similar problem occurs if T1 and T2 both write x. If we're using undo logs (more below) we have to be able to restore to prior state. We're in trouble if some other txn has committed since we did our initial read. How to implement reliably? need a consensus protocol! basically get everybody to agree on the order of start, abort and try_to_commit operations (and the latter's outcomes) -- and thus the order of transactions get participants that access the same object to agree on the order in which they did so. -- to ensure this is consistent with transaction order (or OW force aborts) Note, however, that while consensus allows us to agree on a global order of events, it does note give us a good way of choosing _which_ of several potentially conflicting things to agree on. (Recall that in the presence of concurrent proposals, Paxos can livelock.) You could do global consensus on all lock acquisitions and releases, but that may not scale if transactions can be big. One possibility is to do global consensus on transaction ordering and subgroup consensus on locks: locks are partitioned among a set of (replicated) lock servers. Note that we need to cope with crashes of lock holders, since they aren't replicated for fault tolerance. Easiest approach is to heartbeat, and run consensus on abort and release of all locks when you think it's dead. We must generally log operations w/in a transaction, for A, I, and D redo logs keep record of what you want to write read your own writes from your log on commit, make the updates visible undo logs keep record of what you actually wrote read your own writes in place on abort, put old values back databases and TM systems may do either redo or undo logging undo has better performance if aborts are uncommon (easy to see own writes; lower commit-time latency) distributed systems typically do redo logging higher concurrency, mostly local recovery from crashes acquire read locks on everything as you go along concurrent readers can commit ahead of you upgrade to write locks where necessary at commit time on a crash, release locks and delete logs (crash during the actual commit is not a problem: you're running consensus on whether the overall txn will commit) optimistic systems (including most TM systems) dispense with read locks until commit time, to maximize performance, but this introduces the need for either (1) _incremental validation_, to avoid consistency problems, or (2) some sort of guarantee that reading mutually inconsistent data will never cause catastrophic errors (infinite loop, memory corruption, ...) Note that even with a no-harm guarantee, you still need to validate at least once at commit time, for serializability. Easiest validation strategy is make every write lock a sequence number, remember what the number was when you first read the object, and make sure it's the same at commit time. More options when we consider TM. [ nested transactions [ may, but need not necessarily, entail internal parallelism [ (only if there's no dependence of one on the results of another) [ simplest implementation is to _flatten_ [ better performance in the face of conflicts or failures may ensue if [ we allow a nested txn to abort or retry without rolling the [ parent all the way back [ in the latter implementation, nested commit is provisional on commit [ of parent [ [ nice example from reading: [ [ transfer(n, a, b) : status = [ return atomic [ if withdraw(n, a) == success // not a transaction [ return deposit(n, b) [ [ might be restructured as [ [ transfer(n, a, b) : status = [ return atomic [ concurrently [ if atomic withdraw(n, a) == failure [ self-abort // kills sibling [ if atomic deposit(n, b) == failure [ self-abort // kills sibling recall 2-phase locking (not to be confused with 2-phase commit) don't release any locks until you have acquired all the locks you need in order to commit typically hold all locks until write-back has been completed, to avoid dirty reads and premature writes when lock is not available, typically wait a little bit, then conflict-abort, on the assumption that we have a circular dependence and don't want to deadlock. [ locking gets more complicated with nested transactions [ I'm not going to go into the details [ [ locking also gets more complicated when locks are hierarchical [ e.g., I can lock a record in a database, a whole row, or a whole relation [ want to allow locks on, e.g., separate rows, each of which conflicts [ with relation-level lock multi-version systems may allow locking of particular versions, versions delineated by write locks (reading considers two-version, but you can generalize) need something like vector timestamps to identify mutually-consistent versions across objects has the advantage of allowing large read-only transactions to "commit in the past"