Modeling a key-value-store with snapshot isolation

Looking at the english wikipedia page of tla+ one may find an example specification of a key-value-store with snapshot isolation for concurrent transactions.

I’ve uploaded an alloy model of this mechanism to the models repository in this github pr, which ports the specification, extends it with TransitionPredicates and verifies the anomalies that should and should not be possible.

This started as a university project for a course on alloy 6. You may find the complete source in the PullRequest I’ve linked, the following is simply an excerpt containing the specification and assertions.

Enjoy :slight_smile:

-- ISVS project: Key-value-store with snapshot isolation
-- Ported from TLA+ @ https://en.wikipedia.org/wiki/TLA%2B#Examples
-- Part II: Snapshot isolation

-- Macro for relations that don't change during transitions
let unchanged[r] { (r)' = (r) }

-- Set of all keys in the key-value-store
sig Key {}

-- Set of all values
sig Value {}

-- The store (changed over time by transactions committing)
one sig Store {
	var store: Key -> lone Value,							-- Each key points to one or no value
	var openTx: set Transaction,							-- Set of open snapshot transactions
}

-- Set of all transactions
sig Transaction {
	var snapshotStore: Key -> lone Value,			-- Snapshot of `store` for this transaction
	var written: set Key,										-- Keys written to in this transaction
	var missed: set Key,										-- Writes (from concurrently committed transactions) invisible to this transaction
}

-- Open a new transaction
pred openTx[t: Transaction] {
	t not in Store.openTx
	openTx' = Store -> (Store.openTx ++ t)
	snapshotStore' = snapshotStore ++ (t -> Store.store)

	unchanged[written]
	unchanged[missed]
	unchanged[store]
}

-- Add a key-value-pair to an open transaction
pred add[t: Transaction, k: Key, v: Value] {
	t in Store.openTx
	no t.snapshotStore[k]										-- Key may not have value yet, otherwise update must be done
	snapshotStore' = snapshotStore ++ (t -> (t.snapshotStore ++ k -> v))
	written' = written ++ t -> (t.written + k)			-- Add k to t.written
	
	unchanged[openTx]
	unchanged[missed]
	unchanged[store]
}

-- Update the value of a key-value-pair in an open transaction
pred update[t: Transaction, k: Key, v: Value] {
	t in Store.openTx
	some t.snapshotStore[k]									-- Key must have value, otherwise add must be done
	snapshotStore' = snapshotStore ++ (t -> (t.snapshotStore ++ k -> v))
	written' = written ++ t -> (t.written + k)			-- Add k to t.written
	
	unchanged[openTx]
	unchanged[missed]
	unchanged[store]
}

-- Remove a key-value-pair from an open transaction
pred remove[t: Transaction, k: Key] {
	t in Store.openTx
	some t.snapshotStore[k]									-- Key must have value
	snapshotStore' = snapshotStore - (t -> k -> Value)
	written' = written ++ t -> (t.written + k)			-- Add k to t.written

	unchanged[openTx]
	unchanged[missed]
	unchanged[store]
}

-- Rollback open transaction, doesn't affect store
pred rollbackTx[t: Transaction] {
	t in Store.openTx
	openTx' = Store -> (Store.openTx - t)
	snapshotStore' = snapshotStore - (t -> Key -> Value)
	written' = written - (t -> Key)
	missed' = missed - (t -> Key)

	unchanged[store]
}

-- Commit open transaction, merge with store
pred commitTx[t: Transaction] {
	t in Store.openTx
	no (t.missed & t.written)
	openTx' = Store -> (Store.openTx - t)
	snapshotStore' = snapshotStore - (t -> Key -> Value)
	written' = written - (t -> Key)

	-- All other currently open transactions add what t has written to their set of missed writes
	all tx: Transaction | tx.missed'= (tx in (Store.openTx - t) implies tx.missed + t.written else none)
	-- Update all keys in store that t has written too (including removal!)
	all k: Key | Store.store'[k] = (k in t.written implies t.snapshotStore[k] else Store.store[k])
}

-- Do nothing / stutter (not necessary, but is useful for debugging)
pred nop {
	unchanged[store]
	unchanged[openTx]
	unchanged[snapshotStore]
	unchanged[written]
	unchanged[missed]
}

-- Initial state
pred init {
	no Store.store 													-- no values are stored
	no Store.openTx												-- no open transactions
	no snapshotStore												-- no snapshots
	no written														-- no writes yet
	no missed															-- no missed writes yet
}

-- Next state transition
fun next: Transition {
	t_openTx.Transaction 
	+ t_add.Value.Key.Transaction
	+ t_update.Value.Key.Transaction
	+ t_remove.Key.Transaction
	+ t_rollbackTx.Transaction
	+ t_commitTx.Transaction
	+ t_nop
}

-- Begin with init state and always transition using `next`
fact KeyValueStore {
	init
	always some { this/next }
}
-- ENDSECTION MAIN



-- SECTION VISUALIZATION
-- These functions are used to wrap transition predicates
-- This allows showing in the visualizer which transition happens in each step
enum Transition { Open, Add, Update, Remove, Rollback, Commit, Nop }

fun t_openTx: Transition -> Transaction {
	{ tp: Open, t: Transaction | openTx[t] }
}

fun t_add: Transition -> Transaction -> Key -> Value {
	{ tp: Add, t: Transaction, k: Key, v: Value | add[t,k,v] }
}

fun t_update: Transition -> Transaction -> Key -> Value {
	{ tp: Update, t: Transaction, k: Key, v: Value | update[t,k,v] }
}

fun t_remove: Transition -> Transaction -> Key {
	{ tp: Remove, t: Transaction, k: Key | remove[t,k] }
}

fun t_rollbackTx: Transition -> Transaction {
	{ tp: Rollback, t: Transaction | rollbackTx[t] }
}

fun t_commitTx: Transition -> Transaction {
	{ tp: Commit, t: Transaction | commitTx[t] }
}

fun t_nop: Transition {
	{ tp: Nop | nop }
}
-- ENDSECTION VISUALIZATION



-- SECTION ANOMALY VERIFICATION
-- Assertions assume the anomaly *cannot* occur. If a counterexample is found
-- the specified isolation strategy does not prevent the specified anomaly

assert DirtyWrite {
	-- Writes from concurrent transactions may not override each other
	no disj t1, t2: Transaction, k: Key, disj v1, v2: Value | {
		openTx[t1]
		;openTx[t2]
		;add[t1,k,v1]
		;add[t2,k,v2]
		;commitTx[t1]
		;commitTx[t2]
	}
}
check DirtyWrite

assert DirtyRead {
	-- Writes from concurrent transactions may not affect each others reads
	no disj t1, t2: Transaction, k: Key, disj v1, v2: Value | {
		-- Since the following add implies that k cannot have a value when t1 is opened
		-- The following condition is unnecessary, it does however make clearer what this intends to verify
		Store.store[k] = v2
		;openTx[t1]
		;openTx[t2]
		;add[t1,k,v1]
		;t2.snapshotStore[k] = v1
	}
}
check DirtyRead

assert NonRepeatableRead {
	-- Writes from concurrently committed transactions may not affect reads of others
	-- Only commits before opening a transaction may be taken into account
	no disj t1, t2: Transaction, k: Key, disj v1, v2: Value | {
		Store.store[k] = v1
		openTx[t1]
		;openTx[t2]
		;t1.snapshotStore[k] = v1
		;update[t2,k,v2]
		;commitTx[t2]
		;t1.snapshotStore[k] = v2
	}
}
check NonRepeatableRead

assert PhantomRow {
	-- Concurrent transactions may not add new rows (key-value-pairs) to each other
	no disj t1, t2: Transaction, k: Key, v: Value | {
		#Store.store = 0
		openTx[t1]
		;openTx[t2]
		;#t1.snapshotStore = 0
		;add[t2,k,v]
		;commitTx[t2]
		-- Make sure commitTx[t2] and the verification occur at the same time.
		-- Otherwise t1 might have just added a new row itself
		#t1.snapshotStore = 1
	}
}
check PhantomRow

pred noDuplicateWrites {
	-- No transaction is allowed to add/update to a value, that's already in its snapshot
	-- This should ensure no duplicated values enter the store
	all t: Transaction | all v: t.snapshotStore[Key] | no k: Key | add[t,k,v] or update[t,k,v]
}
assert WriteSkew {
	-- Never writing an existing values should ensure no duplicated values in store
	always noDuplicateWrites implies always #Store.store[Key] = #Store.store
}
check WriteSkew
-- ENDSECTION ANOMALY VERIFICATION

Any feedback is greatly appreciated! Please let’s discuss this model! :slight_smile:

1 Like

Hi, sorry very busy these days. Looks like an interesting model! A few comments (not necessarily corrections) though:

Store doesn’t seem useful, unless you expect to to switch to a model with several stores. Indeed you may as well put store in Key and openTx as var subset signature of Transaction. Now you can keep it for conceptual purposes of course.

On l.30, you may as well use +.

On l.43 (and others), written' = written ++ t -> (t.written + k) could be rewritten as written' = written + t -> k, couldn’t it?

On l. 78, snapshotStore' = snapshotStore - (t -> Key -> Value) cam be rewritten as snapshotStore' = snapshotStore - t <: snapshotStore.

Notice that with wrap-around semantics for integers, you may get relations with the same apparent cardinality although they don’t have the same number of elements.

Also I would suggest using the expect keyword which is very useful when doing regression testing or when communicating a model to someone (this message board for instance).

1 Like

Tristan – this is really impressive, and very nice to have a comparison with TLA+. Congrats! Curious to know whether you found any bugs running the checks. And are your checks expressible in TLA+? Wondering why they don’t have any beyond type preservation.

Hi David,
thank you for the suggestions :slight_smile:
I agree that Store isn’t too useful, indeed it made some of the relations more difficult to express. The TLA+ example made use of the fact that relations needn’t be defined in a signature in that language, so I thought to just ‘wrap’ them in a dummy sig of sorts. I’ll see how this changes when moving it into Key like you suggested. This might make some statements less clear to read (to distinguish between store and snapshot that is), will have to try this out.

The changes to the relation operators you suggested seem valid, I’m no expert with these yet. I’ll try them out and let you know what I find.

Regarding your last point I’ll have to read up on what expect does, haven’t seen that yet (or perhaps forgotten about it).

Thank you for your reply! ^^
– Tristan

Hi Daniel,
Thank you, I’m glad to hear that :slight_smile:

No I didn’t encounter any bugs so far, everything has been going as expected. I would have been surprised to find a bug in the SQL standard, at least regarding isolation levels.

I’m most certain the checks are expressible in TLA+, unfortunately I don’t speak that language so I can’t guarantee that.
I’ve started learning Alloy recently, and the syntax is close enough to understand the Wikipedia example but writing checks would be a different story! :smiley:

Regarding why there are no checks on the Wikipedia page: I guess they might have only been interested with specification, not verification in the example; though I agree the TLA+ code on its own seems rather non-useful.
– Tristan

Really nice model! I like how you structured it, it is very readable.

1 Like

Hi David,
I really like your suggestions now that I’ve played around with them. The rewrites you suggested do indeed work and make everything a lot easier to read, I added some parenthesis here and there to make it even clearer.
I replaced the openTx relation with a var sig, which contains OpenTransactions, this is really nice for visualization and also makes the code simpler to read, great suggestion as well! :slight_smile:

Getting rid of Store also was a good suggestion. Putting store into Key makes most statements a lot more concise. I feel this makes the visualization less easy to read, as you now have to show all Keys to see what value they’re currently mapped to. This clutters the visualizer a bit. But I think the trade-off makes sense, as the Store sig was kind of confusing anyways. And the visualization might just look odd to me because I’ve looked at the previous version so much.

I’ve updated everything on GitHub, I’m not sure how the process for updating posts here goes. I can’t seem to edit my original post, so should I just post everything again in a comment or how does this usually go?

Thanks to everyone for the feedback so far, it’s been really helpful as well as very nice to hear! :slight_smile:

– Tristan

Edit: Oh and I forgot to mention: Expect makes a lot of sense, I wasn’t aware of that. Thanks for pointing it out!

Can you show an example of your visualization issue and explain what you would like to see?

Hi David,
Yeah so comparing the two screenshots, the first version has two benefits compared to the one without Store:

  1. All key-value-pairs are visualized in the same entity Store, essentially a list. In the version without store all Keys have lone Value stored
  2. Store pointing at Transactions created a second “layer” (I don’t know what these are called officially), so more entities would fit the screen.

I’m not allowed to post more than one image so I’ve combined them, the second version has the yellow Transaction and hides un-open transactions. Examples from GitHub:

I struggle to describe it, but the second one just feels worse to look at.
Maybe there is a better way to visualize this, what do you think?

– Tristan