Working with transactions


Most database APIs have a way of dealing with transactions - a set of operations all of which much work, and a way of rolling back if they don't

Indeed many of the backends implemented here have this capability, but I haven't yet exposed that through the abstraction layer. The main reason for this is that many of the backends are not actually databases, and therefore don't have a roll back capability or the concept of transactions. 

However since I had always planned to introduce rollback, I've implemented these kind of databases to use  DriverMemory first, before committing to the backend. This means that I can now start to implement transactions whether simulated, or using the transactional capabilities of the backend.

Here's how it works. 

  • Transactions are accessible through the normal DbAbstraction  handler object, and you use an anonymous function to execute your function. 
  • A normal result package should be returned that can be tested for result.handleCode < 0 meaning failure. 
  • A transaction failure will be signaled with result.transaction.code < 0 
  • If a failure is detected, a rollback to before the transaction will be executed 
  • There will be a lock taken out for the  entire transaction
  • Although caching can be active in each of the individual  items of the transaction as usual, there is no caching of the transaction result itself.
  • The options will be used to modify the transaction behavior, or to pass additional info to your function - to be dealt with later
  • Today various operations are locked to preserve consistency in multi user environments. With transactions a lock is taken out around the transaction, not for the individual operations within the transaction. For operations not enclosed in transactions, by default locking is enabled for operations that write, but not for read. However there is an option to lock both read and write operations. 
Some notes on performance
  • For backends that emulate databases (so far sheets, drive and properties), you'll see a performance improvement for everything if you wrap multiple operations in transactions. This is because the whole thing is done in memory. The down side is that nothing else (that needs a lock) can operate on that database during the transaction. 
  • Aggressive locking can hurt performance when there are many simultaneous users, but you can use to guarantee consistency between writes/reads
  • Operations within transactions are must faster in aggregate than separate ones. However you do lock out others for the duration of the transaction. Another side effect is that if you follow a write with a read, some databases haven't yet properly registered by the time the read comes, and will return the wrong count. This is especially true with Orchestrate. 
You can set locking when you open the database - for example. 

  var dbParameters =  {
    "siloid": "multi",
    "dbid": "1yTQFdN_O2nFb9obm7AHCTmPKpf5cwAd78uNQJiCcjPk",
    "transactions": cDbAbstraction.dhConstants.TRANSACTIONS.ENABLED,
    "locking" : cDbAbstraction.dhConstants.LOCKING.AGGRESSIVE
  };

values for locking are 
cDbAbstraction.dhConstants.LOCKING.AGGRESSIVE
cDbAbstraction.dhConstants.LOCKING.ENABLED  (default)
cDbAbstraction.dhConstants.LOCKING.DISABLED

values for transactions are
cDbAbstraction.dhConstants.TRANSACTIONS.ENABLED (default)
cDbAbstraction.dhConstants.TRANSACTIONS.DISABLED

If you have some code wrapped in transactions, and you disable transactions (or the driver does not yet support them), then each operations will be performed as today, but following the setting for locking

You wrap operations in a transaction like this

var result = dbHandler.transaction (
    function(db) {
        // your related activities

    },
    options
);
  


Example

Sheets are a particular problem, since they don't have stable IDs (row numbers are used as IDs), and since the design principle here is not to do anything that affects the sheet's standalone nature (anybody can change it at any time), the row number of an item can change at any time. It's especially important to wrap operations that rely on previous operations inside a transaction. Below is a query, where the keys are used by a subsequent get. Wrapping the entire thing in a transaction ensure that some other user does not affect the row numbers in between


  var r2 = handler.transaction ( function(db,options) {
      
    var result = handler.query ( { 
      strain:strain,
      'stuff.age': handler.constraints([[c.GT,25]])
    },{limit:1}, 1, true );
    
    assert ([ 
      result.handleCode >= 0,
      result.data.length ===1, 
      result.handleKeys.length ===1
    ], result, "limitkeycheck1");
    
    // testing Get
    
    var r2 = handler.get(result.handleKeys);
    assert ([
      r2.handleCode >= 0,
      r2.data.length === result.data.length,
      r2.data.every (function(d) { return d.stuff.age > 25 ; })
    ], r2, "getcheck1");
    
    // status and result to be handled by transaction wrapper
    return r2;
  });
  assert(r2.transaction.code >=0, r2, 'transaction 1');


another example - this time we are doing an update. It's important that the row number IDs dont change between the query that finds them, and the subsequent update, so wrapping them in a transaction ensures this.

  var r2 = handler.transaction ( function(db,options) {
  
    var result = db.query (
      { strain:strain,
       'stuff.sex': 'male'
      },undefined,1,true);
    
      assert ([ 
        result.handleCode >= 0,
        testData.filter(function(d) { 
          return  (d.stuff.sex === 'male')  ; 
        }).length === result.handleKeys.length
      ], result, "does male work");
  
      var r2 = db.update( result.handleKeys, result.data.map (function(d) { 
        d.stuff.man = d.stuff.sex === 'male'; return d; 
      }));
      assert (r2.handleCode >= 0, r2, 'real update');
      
      return r2;
  
  });
  assert(r2.transaction.code >=0, r2, 'transaction 2');

What handler.transaction() does 

/**
   * transaction wrapper
   * @param {function} transactionFunction things that need to happen inside a transaction
   * @param {object} options any options you want to pass on to your transaction function
   * @return {object} a normal result package
   */
  this.transaction = function (transactionFunction , options) {
    
    // get id of current transaction if any
    var id = self.getTransactionId();
    
    // if there was already one under way that's a bad thing
    if (id) { 
      // report a transaction within a transaction as an error
      var results= makeResults (enums.CODE.TRANSACTION_FAILURE);
      results.transaction = {
        id: id,
        code: enums.CODE.OK,
        error:enums.ERROR.TRANSACTION_FAILURE
      };
      results.transaction = transaction;
      return results;
    }

    
    // set up new transaction
    var transaction = {
      id: self.setTransaction(),
      code: enums.CODE.OK,
      error:''
    };

    // the rules
    // if a driver is transaction aware
    //   it knows about locking regime of a transaction
    //   the entire transaction will be locked
    //   the items of the transaction are forbidden to do any locking
    // further, a transaction capable driver will be able to do rollback etc.
    // for non-aware drivers, a transaction is a non event - nothing happens, the parts of it are executed normally
    // if transactions are disabled, then all drivers are treated as non-transaction aware
    var transactionAware = driver.transactionAware && self.transactionsState() === enums.TRANSACTIONS.ENABLED;
    var transactionCapable = transactionAware && driver.transactionCapable;
    
    // if locking is disabled then no locking is done on the transaction
    var lockingEnabled = self.lockingState() !== enums.LOCKING.DISABLED && !driver.lockingBypass;
    var transactionLockBypass = !transactionAware || !lockingEnabled;    
    
    // enclose the whole transaction 
    var result = doGuts_ ( transactionLockBypass ,  "transaction:"+transaction.id , function (bypass) {
      
      
      // only applies to drivers that are able to do transactions
      if (transactionCapable) {

        // let the driver know it's doing a transaction
        driver.beginTransaction(transaction.id);
        
        // do the work
        try {
          
          // get the current state data, and execute the transaction contents
          driver.transactionData();
          var r = transactionFunction (self , options);
          
          if (r.handleCode < 0 ) {
            localRollBack();
          }
          else {
            // commit the transaction
            if (driver.getTransactionBox().dirty) {
              self.voidCache();
            }
            var r = driver.commitTransaction(transaction.id);
            transaction.code = r.handleCode;
            transaction.error = r.handleError;
          }
        }
        catch (err) {
          localRollBack();
          var r = self.makeResults(enums.CODE.TRANSACTION_FAILURE, err);
        }

        return r;
        
      }
      else {
      
        // the function is executed normally.
        // the driver should detect whether it is part of a transaction and act accordingly
       
        transaction.code = enums.CODE.TRANSACTION_INCAPABLE;
        transaction.error = self.getErrorText(transaction.code);
        
        // do the thing
        return transactionFunction (self , options); 
      
      }
    
    });
    
    // mark transaction as over
    result.transaction = transaction;
    self.clearTransaction();
    return result;
    
    function localRollBack() {
      var r = driver.rollbackTransaction(transaction.id);
      transaction.code = r.handleCode < 0 ? enums.CODE.TRANSACTION_ROLLBACK_FAILED :  enums.CODE.TRANSACTION_ROLLBACK;
      transaction.error = self.getErrorText(transaction.code);
      self.voidCache();
    }
    
  };
  
What an enabled driver will do
  • self.transactionCapable = true;
  • self.transactionAware = true;
  • detect if it is in a transaction, and not do any locking itself. 
  • Here is a sheet save(), instrumented for transactions.

  self.save = function (obs) {
    return delegate.save(self.unFlatten(obs));
  };

However sheets, properties and drive can delegate all their workload to the memory driver in a transaction, since there will be no other updates going on. This makes things much more efficient and also means that they can share the delegated code which looks like this
What a failed transaction looks like

Here's a deliberate error in a transaction - i've spelt remove wrongly, so I want it to rollback the save operation.

  var result = handler.transaction (function(db) {
    
    db.save([
      {name:'he',ob:{a:'a1',b:'b1'}},
      {name:'she',ob:{a:'ax',b:'b2'}}
    ]); 
    
    return db.remov({name:'me'});

  });

and the result


{
  "handleCode": -24,
  "handleError": "(DbAbstraction says:TRANSACTION_FAILURE) (TypeError: Cannot find function remov in object [object Object].) some transaction failure",
  "data": [],
  "handleVersion": "cDbAbstraction:2.1.1",
  "driverVersion": "cDriverMemory:2.1.0",
  "table": "multi",
  "dbId": "multi",
  "transaction": {
    "id": "xiui3bg5f0f",
    "code": -22,
    "error": "transaction failed, but rolled back successfully"
  }
}

A note on keys

As previously noted, some drivers don't have the capability to store unique keys. In this case a unique key is generated for them temporarily. This is how the operations within a transaction can communicate with each other. This means that you can't always rely on keys being the same inside and outside transactions. If you are dealing with keys (for example get/update), it's always best to wrap those in a transaction. For example, with a sheet driver...

var result = handler.query({name:'john'},undefined, undefined, true);

result.handleKeys, will contain row numbers at that point in time - which may change at any time by other user operations, so they should not be relied on - so the following may not return the right data (it will detect it has changed and give you an error)

var r2 = handler.get (result.handleKeys) 

whereas
handler.transaction ( function (db) {
  var result = handler.query({name:'john'},undefined, undefined, true);
  var r2 = handler.get (result.handleKeys);
});

will return the correct value since the operations wont be interrupted mid flight by another update instance. Therefore, if you are using keys for database backends without a unique key capability, you should keep them isolated to within transactions.



DbAbstraction full code

























Comments