Patterns and Practices
In some ways, STM may seem instantly familiar to people who are used to the properties of transactions from database worlds: the atomic, consistent and isolated properties are exactly the same, and the only difference is the lack of the durable property as the AtomizeJS server does not write anything to disk (though you could easily imagine a future version which does).
In other ways, STM is quite different from normal database
transactions: database transactions have nothing like the retry
or orElse
operations. Instead, well featured relational
databases tend to add trigger
features so that upon changes to rows
or tables, user functions can be automatically invoked. Both
approaches allow an observer-pattern or event-driven callbacks to
be created, though the STM approach is simpler and more intuitive.
Eventing
By using retry
, you can build neat proxy objects. This also
gets around the current limitation of lift
whereby lifting an
object with a custom constructor and prototype will lose the prototype
when the object arrives at other clients. For example, consider a
Player
which has an x
and y
coordinate and a name
:
function Player(name, x, y) {
this.raw = atomize.lift({name: name});
this.setPos(x, y);
this.watch();
}
Player.prototype = {
setPos: function (x, y) {
var self = this;
atomize.atomically(function () {
self.raw.x = x;
self.raw.y = y;
});
},
watch: function () {
var self, watcher;
self = this;
watcher = function (pos) {
atomize.atomically(function () {
if (pos.x === self.raw.x && pos.y === self.raw.y) {
atomize.retry();
} else {
return {x: self.raw.x, y: self.raw.y}
}
}, function (pos) {
self.x = pos.x;
self.y = pos.y;
watcher(pos);
}
};
watcher({x: this.x, y: this.y});
}
};
There is a fair amount of boiler-plate here, and it should be clear how this can be abstracted out to a general proxy mechanism, but as a simple example it demonstrates the following:
- We rely on the the watcher to observe any change in the underlying
raw
object that is managed by AtomizeJS. - When we want to set the position, we just perform a transaction that
modifies the
raw
object and allow the watcher to be notified and propagate the change back to the proxy object. - It's always safe to read the
x
andy
fields of the proxy object: as they're only updated by the watcher they will only ever see values that have been fully committed by other transactions. - The watcher never finishes.
Possibly one surprising aspect of this design though is that setPos
is non-blocking. This can mean that if you perform:
var player = new Player("Fred", 45, 12);
player.setPos(46, 12);
player.setPos(46, 13);
then the two transactions you've issued can commit in either order!
To avoid that there are two choices: either you just ignore any calls
to setPos
whilst a transaction is in-flight:
setPos: function (x, y) {
if (this.blocked) {
return;
}
this.blocked = true;
var self = this;
atomize.atomically(function () {
self.raw.x = x;
self.raw.y = y;
}, function () {
delete self.blocked;
});
},
Or, you convert the whole mechanism to a continuation-passing style:
setPos: function (x, y, cont) {
var self = this;
atomize.atomically(function () {
self.raw.x = x;
self.raw.y = y;
}, cont);
},
but then you'll have to do things like:
var player = new Player("Fred", 45, 12);
player.setPos(46, 12,
function () {
player.setPos(46, 13, function () {});
});
and in fact, you'll note the Player
constructor calls setPos
, so
that will likely want a continuation too.
The former approach is much simpler, but will work only when you can
afford to lose updates: for example when you have a regular tick
coming from a timer that will reissue the same or similar setPos
call a little later on, hopefully after the transaction commits.
Sharing deep data-structures
As explained in the background page, the statement a.b.c.d =
e.f;
creates a read-set of a
, a.b
and e
, and a write set of
c
. If you created a transaction which inspected a
and then chose
to retry
, then it would not be restarted by this assignment
because a
has not been changed.
So if you consider a deep data-structure such as a binary tree, then
it's clear that just retry
-ing after inspecting the root of the
binary tree will not allow you to be informed of all changes to the
tree. There are two choices: either inspect every node of the tree,
and retry only after reading every single node, which would be very
expensive if the tree is deep, or create some other simpler mechanism
which ensures that every change does cause a retried transaction to
restart.
One simple way of doing this is to have an eventCount. Assume that
we have the functions tree_new
, tree_search
, tree_insert
,
tree_remove
and tree_deep_clone
. We could then wrap them:
function Tree (raw) {
if (raw === undefined) {
this.raw = atomize.lift({eventCount: 1, tree: tree_new()});
} else {
this.raw = raw;
}
this.copy = tree_new();
this.watch();
}
Tree.prototype = {
insert: function (key, val) {
var self = this;
atomize.atomically(function () {
self.raw.eventCount += 1;
self.raw.tree = tree_insert(self.raw.tree, key, val);
});
},
remove: function (key) {
var self = this;
atomize.atomically(function () {
self.raw.eventCount += 1;
self.raw.tree = tree_remove(self.raw.tree, key);
});
},
search: function (key) {
return tree_search(this.copy, key);
},
watch: function () {
var self, watcher;
self = this;
watcher = function (eventCount) {
atomize.atomically(function () {
if (eventCount === self.raw.eventCount) {
atomize.retry();
} else {
return {eventCount: self.raw.eventCount,
tree: tree_deep_clone(self.raw.tree)};
}
}, function (copy) {
self.copy = copy.tree;
watcher(copy.eventCount);
});
};
watcher(undefined);
}
};
Again, we build a proxy type object which is constantly being update from the raw tree object in AtomizeJS. But by ensuring that every mutating operation also changes the eventCount field, it becomes trivial to ensure that we get notified whenever the tree is changed.
Nested transactions
AtomizeJS supports nested transactions. When an inner transaction commits, all that happens is that its read and write sets get copied into its parent's transaction log. For example:
function foo () {
atomize.atomically(function () {
atomize.root.pos = atomize.lift({x: 5, y: 6});
atomize.root.text = "hello";
bar();
atomize.root.text = "goodbye";
return atomize.root.text;
}, function (text) {
console.log("foo continuation:" + text);
});
}
function bar () {
atomize.atomically(function () {
if (atomize.root.pos.y % 2 === 0) {
atomize.root.pos.y + 1;
}
atomize.root.pos.x + 1;
return atomize.root.text;
}, function (text) {
console.log("bar continuation: " + text);
});
}
As usual, the bar
continuation gets run after the bar
transaction
commits. But when the bar
transaction commits, you're still in the
foo
transaction. Thus the bar
continuation gets run inside the
foo
transaction. In this case, at the point of the bar
continuation running, the value of text
will be "hello"
. If the
foo
transaction has to be restarted then the bar
transaction will
also be restarted, which can lead to the bar
continuation running
multiple times.
To detect this scenario, there is the inTransaction
API call
which is always safe to call, and will let you detect, for example
when in a continuation, whether or not you are still in a transaction.
One way to ensure that a provided continuation is only ever run once you're outside a transaction would be:
function foo (cont) {
atomize.atomically(function () {
atomize.root.pos = atomize.lift({x: 5, y: 6});
atomize.root.text = "hello";
bar(cont);
atomize.root.text = "goodbye";
return atomize.root.text;
}, maybeCont(cont));
}
function bar (cont) {
atomize.atomically(function () {
if (atomize.root.pos.y % 2 === 0) {
atomize.root.pos.y + 1;
}
atomize.root.pos.x + 1;
return atomize.root.text;
}, maybeCont(cont));
}
function maybeCont(cont) {
return function (result) {
if (atomize.inTransaction()) {
return cont(result);
} else {
return result;
}
};
}
Thus now, regardless of how bar
is invoked (i.e. whether directly or
whether from foo
), the continuation will only be invoked once
outside of all transactions.
It's also worth watching out for nested transactions where the inner
transaction calls retry
: this will block the parent transaction
too. For example:
function foo () {
atomize.atomically(function () {
atomize.root.pos = atomize.lift({x: 5, y: 6});
atomize.root.text = "hello";
bar();
atomize.root.text = "goodbye";
return atomize.root.text;
}, function (text) {
console.log("foo continuation:" + text);
});
}
function bar () {
atomize.atomically(function () {
if (atomize.root.pos.y % 2 === 0) {
atomize.retry();
}
atomize.root.pos.x + 1;
return atomize.root.text;
}, function (text) {
console.log("bar continuation: " + text);
});
}
Here, if pos.y
is even, then the whole transaction suspends, waiting
for someone else to modify pos.y
, before either continuation is
run, or text
gets set to "goodbye"
. Given our previous proxy
objects which started up the watcher from within the constructor, if
such an object were to be created from within another transaction,
you'd find the parent transaction blocks as the child transaction in
the new object has hit the retry
.
In the future, we may be able to add other mechanisms which help to manage these scenarios.