Some failures break the thing they happen in. After a failed write, a database transaction is aborted: committing it would be wrong, and so would catching the error and running the next statement. error! tx, :tag; raises :tag like error :tag; and also ends tx. Function types list these failures in exn! tx [ ... ], after the ordinary exn [ ... ].
The examples use a small stand-in for a transaction. with opens its scope, and With.execute records how the body left it (Resource scopes).
type! Tx = {
existing: Vector(String);
mut writes: Int;
mut balance: Int;
mut outcome: String;
}
let beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx = (balancebalance: Int: Int) -> Tx =>
Tx {
existing = ["home""home": String];
writes = 00: Int;
balance = balancebalance: Int;
outcome = "open""open": String;
};
module behavior With(Tx) {
executeexecute: fn(manager: Book.TerminalFailures.Tx, body: (fn(Book.TerminalFailures.Tx) -> ScopeExit('a)) @ required) -> Unit = fn(managermanager: Book.TerminalFailures.Tx: Tx, bodybody: fn(Book.TerminalFailures.Tx) -> ScopeExit('a): (fn(Tx) -> ScopeExit('error)) @ required) -> Unit {
manager.outcome := match bodybody: fn(Book.TerminalFailures.Tx) -> ScopeExit('a)(managermanager: Book.TerminalFailures.Tx) {
| Success -> "committed""committed": String
| Failure(_) -> "rolled back""rolled back": String
| Ended(_) -> "aborted""aborted": String
};
};
}
Ending a parameter
A function can end its own parameter. Domain code does this to roll back the whole transaction: once withdraw fails, nothing else may run in tx. The inferred type says which failures end tx.
let withdrawwithdraw: pure fn(tx: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ] = fn(txtx: Book.TerminalFailures.Tx: Tx, amountamount: Int: Int) {
if amountamount: Int > txtx: Book.TerminalFailures.Tx.balancebalance: Int {
error! tx, :insufficient_funds;
}
tx.balance := txtx: Book.TerminalFailures.Tx.balancebalance: Int - amountamount: Int;
};
Ending A Parameter
✓withdrawwithdraw: pure fn(tx: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ]: fn(tx: Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ];A written signature states both rows, and so does a contract: the companion .shc publishes withdraw and insert with their exn! rows, and the checker holds each body to them. An empty slug is an ordinary failure: it fails before anything is written, so tx stays usable. A slug that is already taken breaks the transaction, the way a unique-index violation aborts one in Postgres.
let insertinsert: pure fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ] = fn(txtx: Book.TerminalFailures.Tx: Tx, slugslug: String: String) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ] {
if slugslug: String == """": String {
error :blank;
}
if Vector.findfind: pure fn!('a)(items: Vector(String) @ 'm, ~f: fn(value: String @ 'm) -> Bool) -> Option(String)(txtx: Book.TerminalFailures.Tx.existingexisting: Vector(String), f = (existingexisting: String) => existingexisting: String == slugslug: String) != NoneNone: Option(String) {
error! tx, :db.unique(slugslug: String);
}
tx.writes := txtx: Book.TerminalFailures.Tx.writeswrites: Int + 11: Int;
};
Passing it along
Passing tx to a function that ends its parameter ends tx too, so helpers infer exn! tx without writing it. The argument must be a name that can be ended: a parameter or a with binding.
let publishpublish: fn(tx: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) | :insufficient_funds ] = fn(txtx: Book.TerminalFailures.Tx: Tx, slugslug: String: String, priceprice: Int: Int) {
insertinsert: pure fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ](txtx: Book.TerminalFailures.Tx, slugslug: String);
withdrawwithdraw: pure fn(tx: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ](txtx: Book.TerminalFailures.Tx, priceprice: Int);
};
A closure that ends a binding it captured ends it at the call that runs the closure, so insert_all ends tx as well.
let insert_allinsert_all: fn(tx: Book.TerminalFailures.Tx, slugs: Vector(String)) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ] = fn(txtx: Book.TerminalFailures.Tx: Tx, slugsslugs: Vector(String): Vector(String)) {
Vector.iteriter: pure fn!('a)(items: Vector(String) @ 'm, ~f: fn(value: String @ 'm) -> Unit) -> Unit(slugsslugs: Vector(String), f = (slugslug: String) => insertinsert: pure fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ](txtx: Book.TerminalFailures.Tx, slugslug: String));
};
Passing It Along
✓publishpublish: fn(tx: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) | :insufficient_funds ]: fn(tx: Tx, slug: String, price: Int) -> Unit,
exn [ :blank ],
exn! tx [ :db.unique(String) | :insufficient_funds ];
✓insert_allinsert_all: fn(tx: Book.TerminalFailures.Tx, slugs: Vector(String)) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ]: fn(tx: Tx, slugs: Vector(String)) -> Unit,
exn [ :blank ],
exn! tx [ :db.unique(String) ];Leaving the scope
with introduced tx, so leaving its block turns failures that ended tx into ordinary failures. On the way out, With.execute sees the body end with Ended instead of Failure: the failure ended its own resource. Outside the block a handler may absorb them; by then the transaction has been aborted. An ordinary failure, such as an empty slug, still rolls back.
let checkoutcheckout: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank | :db.unique(String) | :insufficient_funds ] = fn(ledgerledger: Book.TerminalFailures.Tx: Tx, slugslug: String: String, priceprice: Int: Int) {
with txtx: Book.TerminalFailures.Tx = ledgerledger: Book.TerminalFailures.Tx {
publishpublish: fn(tx: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) | :insufficient_funds ](txtx: Book.TerminalFailures.Tx, slugslug: String, priceprice: Int);
};
};
let attemptattempt: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit = fn(ledgerledger: Book.TerminalFailures.Tx: Tx, slugslug: String: String, priceprice: Int: Int) {
try checkoutcheckout: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank | :db.unique(String) | :insufficient_funds ](ledgerledger: Book.TerminalFailures.Tx, slugslug: String, priceprice: Int) {
| :blank -> ()(): Unit
| :db.unique(_) -> ()(): Unit
| :insufficient_funds -> ()(): Unit
}
};
Leaving The Scope
✓checkoutcheckout: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank | :db.unique(String) | :insufficient_funds ]: fn(ledger: Tx, slug: String, price: Int) -> Unit,
exn [ :blank | :db.unique(String) | :insufficient_funds ];
✓attemptattempt: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit: fn(ledger: Tx, slug: String, price: Int) -> Unit;
let paidpaid: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
attemptattempt: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit(paidpaid: Book.TerminalFailures.Tx, "spring""spring": String, 44: Int);
expect ✓(paidpaid: Book.TerminalFailures.Tx.outcomeoutcome: String, paidpaid: Book.TerminalFailures.Tx.writeswrites: Int, paidpaid: Book.TerminalFailures.Tx.balancebalance: Int) == ("committed""committed": String, 11: Int, 66: Int);
let shortshort: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
attemptattempt: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit(shortshort: Book.TerminalFailures.Tx, "spring""spring": String, 4040: Int);
expect ✓shortshort: Book.TerminalFailures.Tx.outcomeoutcome: String == "aborted""aborted": String;
let blankblank: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
attemptattempt: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit(blankblank: Book.TerminalFailures.Tx, """": String, 44: Int);
expect ✓blankblank: Book.TerminalFailures.Tx.outcomeoutcome: String == "rolled back""rolled back": String;try tx = ledger { body } { arms } handles them in place. The arms sit outside the scope of tx and run after it was aborted (Resource scopes).
let buybuy: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> String = fn(ledgerledger: Book.TerminalFailures.Tx: Tx, slugslug: String: String, priceprice: Int: Int) {
try txtx: Book.TerminalFailures.Tx = ledgerledger: Book.TerminalFailures.Tx {
publishpublish: fn(tx: Book.TerminalFailures.Tx, slug: String, price: Int) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) | :insufficient_funds ](txtx: Book.TerminalFailures.Tx, slugslug: String, priceprice: Int);
"bought""bought": String
} {
| :blank -> "no slug""no slug": String
| :db.unique(_) -> "taken""taken": String
| :insufficient_funds -> "declined""declined": String
}
};
Handling Outside The Scope In Place
✓buybuy: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> String: fn(ledger: Tx, slug: String, price: Int) -> String;
let shortshort: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
expect ✓buybuy: fn(ledger: Book.TerminalFailures.Tx, slug: String, price: Int) -> String(shortshort: Book.TerminalFailures.Tx, "spring""spring": String, 4040: Int) == "declined""declined": String;
expect ✓shortshort: Book.TerminalFailures.Tx.outcomeoutcome: String == "aborted""aborted": String;Nested scopes
Only the scope that introduced an ended name sees Ended. Every other scope the failure leaves sees an ordinary Failure. Here a pool leases a connection and a transaction runs on it. Ending tx aborts the transaction, but the pool takes the connection back. Ending both throws the connection away, because whatever ended it may have left it broken.
type Pool = {
mut idle: Vector(String);
mut discarded: Vector(String);
}
type Connection = {
pool: Pool @ global;
name: String @ global;
}
let leaselease: fn(pool: Book.TerminalFailures.Pool @ global, name: String @ global) -> Book.TerminalFailures.Connection = (poolpool: Book.TerminalFailures.Pool: Pool, namename: String: String) -> Connection =>
Connection { pool = poolpool: Book.TerminalFailures.Pool; name = namename: String; };
module behavior With(Connection) {
executeexecute: fn(conn: Book.TerminalFailures.Connection, body: (fn(Book.TerminalFailures.Connection) -> ScopeExit('a)) @ required) -> Unit = fn(connconn: Book.TerminalFailures.Connection: Connection, bodybody: fn(Book.TerminalFailures.Connection) -> ScopeExit('a): (fn(Connection) -> ScopeExit('error)) @ required) -> Unit {
let poolpool: Book.TerminalFailures.Pool = connconn: Book.TerminalFailures.Connection.poolpool: Book.TerminalFailures.Pool;
match bodybody: fn(Book.TerminalFailures.Connection) -> ScopeExit('a)(connconn: Book.TerminalFailures.Connection) {
| Success | Failure(_) -> pool.idle := Vector.pushpush: pure fn!('a)(items: Vector(String), item: String) -> Vector(String)(poolpool: Book.TerminalFailures.Pool.idleidle: Vector(String), connconn: Book.TerminalFailures.Connection.namename: String)
| Ended(_) -> pool.discarded := Vector.pushpush: pure fn!('a)(items: Vector(String), item: String) -> Vector(String)(poolpool: Book.TerminalFailures.Pool.discardeddiscarded: Vector(String), connconn: Book.TerminalFailures.Connection.namename: String)
};
};
}
let paypay: fn(pool: Book.TerminalFailures.Pool @ global, ledger: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn [ :insufficient_funds ] = fn(poolpool: Book.TerminalFailures.Pool: Pool, ledgerledger: Book.TerminalFailures.Tx: Tx, amountamount: Int: Int) {
with connconn: Book.TerminalFailures.Connection = leaselease: fn(pool: Book.TerminalFailures.Pool @ global, name: String @ global) -> Book.TerminalFailures.Connection(poolpool: Book.TerminalFailures.Pool, "first""first": String) {
with txtx: Book.TerminalFailures.Tx = ledgerledger: Book.TerminalFailures.Tx {
withdrawwithdraw: pure fn(tx: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ](txtx: Book.TerminalFailures.Tx, amountamount: Int)
}
}
};
let loselose: fn(pool: Book.TerminalFailures.Pool @ global, ledger: Book.TerminalFailures.Tx) -> 'a, exn [ :connection_lost ] = fn(poolpool: Book.TerminalFailures.Pool: Pool, ledgerledger: Book.TerminalFailures.Tx: Tx) {
with connconn: Book.TerminalFailures.Connection = leaselease: fn(pool: Book.TerminalFailures.Pool @ global, name: String @ global) -> Book.TerminalFailures.Connection(poolpool: Book.TerminalFailures.Pool, "second""second": String) {
with txtx: Book.TerminalFailures.Tx = ledgerledger: Book.TerminalFailures.Tx {
error! (tx, conn), :connection_lost;
}
}
};
Nested Scopes
let connectionsconnections: Book.TerminalFailures.Pool = Pool { idle = []; discarded = []; };
let shortshort: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
try paypay: fn(pool: Book.TerminalFailures.Pool @ global, ledger: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn [ :insufficient_funds ](connectionsconnections: Book.TerminalFailures.Pool, shortshort: Book.TerminalFailures.Tx, 4040: Int) {
| :insufficient_funds -> ()(): Unit
}
let droppeddropped: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
try loselose: fn(pool: Book.TerminalFailures.Pool @ global, ledger: Book.TerminalFailures.Tx) -> Unit, exn [ :connection_lost ](connectionsconnections: Book.TerminalFailures.Pool, droppeddropped: Book.TerminalFailures.Tx) {
| :connection_lost -> ()(): Unit
}
expect ✓(shortshort: Book.TerminalFailures.Tx.outcomeoutcome: String, droppeddropped: Book.TerminalFailures.Tx.outcomeoutcome: String) == ("aborted""aborted": String, "aborted""aborted": String);
expect ✓(connectionsconnections: Book.TerminalFailures.Pool.idleidle: Vector(String), connectionsconnections: Book.TerminalFailures.Pool.discardeddiscarded: Vector(String)) == (["first""first": String], ["second""second": String]);Translating
Inside tx's scope, a handler that catches a failure ending tx must leave by ending tx again. It may change the failure on the way out. Ordinary failures stay catchable: here an empty slug is skipped.
let claimclaim: fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn! tx [ :slug_taken(String) ] = fn(txtx: Book.TerminalFailures.Tx: Tx, slugslug: String: String) {
try insertinsert: pure fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn [ :blank ], exn! tx [ :db.unique(String) ](txtx: Book.TerminalFailures.Tx, slugslug: String) {
| :db.unique(takentaken: String) -> error! tx, :slug_taken(takentaken: String)
| :blank -> ()(): Unit
}
};
Translating
✓claimclaim: fn(tx: Book.TerminalFailures.Tx, slug: String) -> Unit, exn! tx [ :slug_taken(String) ]: fn(tx: Tx, slug: String) -> Unit, exn! tx [ :slug_taken(String) ];Absorbing it instead, with a named arm, a wildcard, or error rest, is an error. The rule follows the scope, not the syntax: every handler between the failure and the with that introduced tx is inside tx's scope. fn! cannot fail, so it can neither declare exn! nor use error!. The target of error! and exn! is a parameter, a with binding, or a service. Rejected programs shows each case.
Retrying
Retrying is handling too: a retry stage catches a failure and runs the body again. Running again after a failure that ended tx would keep going inside the transaction it just ended, so retry the whole region. Each attempt opens its own scope and sees its own outcome, and the failure that ended tx is ordinary by the time the policy sees it.
let purchasepurchase: fn(ledger: Book.TerminalFailures.Tx, price: Int) -> Unit, exn [ 'a | :insufficient_funds ], eff 'b = fn(ledgerledger: Book.TerminalFailures.Tx: Tx, priceprice: Int: Int) {
with txtx: Book.TerminalFailures.Tx = ledgerledger: Book.TerminalFailures.Tx {
withdrawwithdraw: pure fn(tx: Book.TerminalFailures.Tx, amount: Int) -> Unit, exn! tx [ :insufficient_funds ](txtx: Book.TerminalFailures.Tx, priceprice: Int)
}
} >> Retry.policypolicy: pure fn!(~schedule: Schedule, ~when: (pure fn!(failure: [> ]) -> Bool) @ global) -> Retry.Policy(
schedule = Schedule.recursrecurs: pure fn!(count: Int) -> Schedule(22: Int),
when = (__: [> ]) => TrueTrue: Bool,
);
Retrying The Whole Region
✓purchasepurchase: fn(ledger: Book.TerminalFailures.Tx, price: Int) -> Unit, exn [ :insufficient_funds ]: fn(ledger: Tx, price: Int) -> Unit,
exn [ :insufficient_funds ],
eff [ Clock | Random ];
let shortshort: Book.TerminalFailures.Tx = beginbegin: fn(balance: Int) -> Book.TerminalFailures.Tx(1010: Int);
let outcomeoutcome: String = try {
purchasepurchase: fn(ledger: Book.TerminalFailures.Tx, price: Int) -> Unit, exn [ :insufficient_funds ](shortshort: Book.TerminalFailures.Tx, 4040: Int);
"bought""bought": String
} {
| :insufficient_funds -> "declined""declined": String
};
expect ✓(outcomeoutcome: String, shortshort: Book.TerminalFailures.Tx.outcomeoutcome: String) == ("declined""declined": String, "aborted""aborted": String);Ending a service
A failure can end a service. provide introduced it, so provide turns failures that ended it into ordinary failures, and the provider's drop sees the body end with Ended. Only the provider installed where the failure was raised ends; a provider it shadows sees Failure.
effect service Audit {
write: fn(entry: String) -> Unit;
}
let recordrecord: fn(entry: String) -> Unit, exn! Audit [ :empty_entry ], eff Audit = fn(entryentry: String: String) {
if entryentry: String == """": String {
error! Audit, :empty_entry;
}
Audit.writewrite: fn(entry: String) -> Unit, eff Audit(entryentry: String);
};
let auditingauditing: fn(log: Book.TerminalFailures.AuditLog) -> Service.Provider(Audit) = (loglog: Book.TerminalFailures.AuditLog: AuditLog) =>
module: Service.Provider(Audit) #{
let service Provider: Audit = {
type t = Unit
init = () => ()(): Unit;
drop = fn(selfself: t: t, exitexit: ScopeExit('a): ScopeExit('error)) -> Unit {
match exitexit: ScopeExit('a) {
| Success -> notenote: fn(log: Book.TerminalFailures.AuditLog @ global, entry: String @ global) -> Unit(loglog: Book.TerminalFailures.AuditLog, "commit""commit": String)
| Failure(_) -> notenote: fn(log: Book.TerminalFailures.AuditLog @ global, entry: String @ global) -> Unit(loglog: Book.TerminalFailures.AuditLog, "rollback""rollback": String)
| Ended(_) -> notenote: fn(log: Book.TerminalFailures.AuditLog @ global, entry: String @ global) -> Unit(loglog: Book.TerminalFailures.AuditLog, "abort""abort": String)
}
};
write = fn(selfself: t: t, entryentry: String: String) -> Unit {
notenote: fn(log: Book.TerminalFailures.AuditLog @ global, entry: String @ global) -> Unit(loglog: Book.TerminalFailures.AuditLog, entryentry: String);
};
};
};
let sign_upsign_up: fn(log: Book.TerminalFailures.AuditLog, email: String) -> Unit, exn [ :empty_entry ] = fn(loglog: Book.TerminalFailures.AuditLog: AuditLog, emailemail: String: String) {
provide { Audit = auditingauditing: fn(log: Book.TerminalFailures.AuditLog) -> Service.Provider(Audit)(loglog: Book.TerminalFailures.AuditLog); } {
recordrecord: fn(entry: String) -> Unit, exn! Audit [ :empty_entry ], eff Audit("user ""user ": String ++ emailemail: String);
recordrecord: fn(entry: String) -> Unit, exn! Audit [ :empty_entry ], eff Audit(emailemail: String);
}
};
let registerregister: fn(log: Book.TerminalFailures.AuditLog, email: String) -> String = fn(loglog: Book.TerminalFailures.AuditLog: AuditLog, emailemail: String: String) {
try {
sign_upsign_up: fn(log: Book.TerminalFailures.AuditLog, email: String) -> Unit, exn [ :empty_entry ](loglog: Book.TerminalFailures.AuditLog, emailemail: String);
"created""created": String
} {
| :empty_entry -> "empty""empty": String
}
};
Ending A Service
✓recordrecord: fn(entry: String) -> Unit, exn! Audit [ :empty_entry ], eff Audit: fn(entry: String) -> Unit, exn! Audit [ :empty_entry ], eff Audit;
✓sign_upsign_up: fn(log: Book.TerminalFailures.AuditLog, email: String) -> Unit, exn [ :empty_entry ]: fn(log: AuditLog, email: String) -> Unit, exn [ :empty_entry ];
let loglog: Book.TerminalFailures.AuditLog = AuditLog { entries = []; };
expect ✓(registerregister: fn(log: Book.TerminalFailures.AuditLog, email: String) -> String(loglog: Book.TerminalFailures.AuditLog, "ada@ship.dev""ada@ship.dev": String), registerregister: fn(log: Book.TerminalFailures.AuditLog, email: String) -> String(loglog: Book.TerminalFailures.AuditLog, """": String)) == ("created""created": String, "empty""empty": String);
expect ✓loglog: Book.TerminalFailures.AuditLog.entriesentries: Vector(String) == [
"user ada@ship.dev""user ada@ship.dev": String,
"ada@ship.dev""ada@ship.dev": String,
"commit""commit": String,
"user ""user ": String,
"abort""abort": String,
];