Skip to content

Latest commit

 

History

History
481 lines (320 loc) · 16.9 KB

cds-tx.md

File metadata and controls

481 lines (320 loc) · 16.9 KB
redirect_from status label
node.js/transactions
released
Transactions

Transaction Management

Transaction management in CAP deals with (ACID) database transactions, principal / context propagation on service-to-service calls and tenant isolation.

::: tip In Essence... As an application developer, you don't have to care about transactions, principal propagation, or tenant isolation at all. CAP runtime manages that for you automatically. Only in rare cases, you need to go beyond that level, and use one or more of the options documented hereinafter. :::

[[toc]]


Automatic Transactions

Whenever an instance of cds.Service processes requests, the core framework automatically cares for starting and committing or rolling back database transactions, connection pooling, principal propagation and tenant isolation.

For example a call like that:

await db.read('Books')

... will cause this to take place on SQL level:

-- ACQUIRE connection from pool
CONNECT; -- if no pooled one
BEGIN;
SELECT * from Books;
COMMIT;
-- RELEASE connection to pool

::: tip Service-managed Transactions — whenever a service operation, like db.read() above, is executed, the core framework ensures it will either join an existing transaction, or create a new root transaction. Within event handlers, your service always is in a transaction. :::

Nested Transactions

Services commonly process requests in event handlers, which in turn send requests to other services, like in this simplistic implementation of a bank transfer operation:

const log = cds.connect.to('log')
const db = cds.connect.to('db')

BankingService.on ('transfer', req => {
  let { from, to, amount } = req.data
  await db.update('BankAccount',from).set('balance -=', amount),
  await db.update('BankAccount',to).set('balance +=', amount),
  await log.insert ({ kind:'Transfer', from, to, amount })
})

Again, all transaction handling is done by the CAP core framework, in this case by orchestrating three transactions:

  1. A root transaction for BankingService.transfer
  2. A nested transaction for the calls to the db service
  3. A nested transaction for the calls to the log service

Nested transactions are automatically committed when their root transaction is committed upon successful processing of the request; or rolled back if not.


::: warning No Distributed Transactions — Note that in the previous example, the two nested transactions are synchronized with respect to a final commit / rollback, but not as a distributed atomic transaction. This means, it still can happen, that the commit of one nested transaction succeeds, while the other fails. :::

Manual Transactions

Use cds.tx() to start and commit transactions manually, if you need to ensure two or more queries to run in a single transaction. The easiest way to achieve this is shown below:

cds.tx (async ()=>{
  const [ Emily ] = await db.insert (Authors, {name:'Emily Brontë'})
  await db.insert (Books, { title: 'Wuthering Heights', author: Emily })
})

Learn more about cds.tx(){.learn-more}

This usage variant, which accepts a function with nested operations ...

  1. creates a new root transaction
  2. executes all nested operations in this transaction
  3. automatically finalizes the transaction with commit or rollback

::: tip Only in non-managed environments — as said above: you don't need to care for that if you are in a managed environment, that is, when implementing an event handler. In that case, the core service runtime automatically created a transaction for you already. :::

::: warning ❗ Warning If you're using the database SQLite, it leads to deadlocks when two transactions wait for each other. Parallel transactions are not allowed and a new transaction is not started before the previous one is finished. :::

Background Jobs

Background jobs are tasks to be executed outside of the current transaction, possibly also with other users, and maybe repeatedly. Use cds.spawn() to do so:

// run in current tenant context but with privileged user
// and with a new database transactions each...
cds.spawn ({ user: cds.User.privileged, every: 1000 /* ms */ }, async ()=>{
  const mails = await SELECT.from('Outbox')
  await MailServer.send(mails)
  await DELETE.from('Outbox').where (`ID in ${mails.map(m => m.ID)}`)
})

Learn more about cds.spawn(){.learn-more}

cds. context {#event-contexts .property}

Automatic transaction management, as offered by the CAP, needs access to properties of the invocation context — most prominently, the current user and tenant, or the inbound HTTP request object.

Accessing Context

Access that information anywhere in your code through cds.context like that:

// Accessing current user
const { user } = cds.context
if (user.is('admin')) ...
// Accessing HTTP req, res objects
const { req, res } = cds.context.http
if (!req.is('application/json')) res.send(415)

Learn more about available cds.context properties{.learn-more}

Setting Contexts

Setting cds.context usually happens in inbound authentication middlewares or in inbound protocol adapters. You can also set it in your code, for example, you might implement a simplistic custom authentication middleware like so:

app.use ((req, res, next) => {
  const { 'x-tenant':tenant, 'x-user-id':user } = req.headers
  cds.context = { tenant, user } // Setting cds.context
  next()
})

Continuation-local Variable

cds.context is implemented as a so-called continuation-local variable.

As JavaScript is single-threaded, we cannot capture request-level invocation contexts such (as current user, tenant, or locale) in what other languages like Java call thread-local variables. But luckily, starting with Node v12, means for so-called "Continuation-Local Storage (CLS)" were given to us. Basically, the equivalent of thread-local variables in the asynchronous continuations-based execution model of Node.js.

Context Propagation

When creating new root transactions in calls to cds.tx(), all properties not specified in the context argument are inherited from cds.context, if set in the current continuation.

In effect, this means the new transaction demarcates a new ACID boundary, while it inherits the event context properties unless overridden in the context argument to cds.tx(). The following applies:

cds.context = { tenant:'t1', user:'u1' }
cds.context.user.id === 'u1'          //> true
let tx = cds.tx({ user:'u2' })
tx.context !== cds.context            //> true
tx.context.tenant === 't1'            //> true
tx.context.user.id === 'u2'           //> true
tx.context.user !== cds.context.user  //> true
cds.context.user.id === 'u1'          //> true

cds/srv. tx() {#srv-tx .method}

function srv.tx ( ctx?, fn? : tx<srv> => {...} ) => Promise
function srv.tx ( ctx? ) => tx<srv>
var ctx : { tenant, user, locale }

Use this method to run the given function fn and all nested operations in a new root transaction. For example:

await srv.tx (async tx => {
  let exists = await tx.run ( SELECT(1).from(Books,201).forUpdate() )
  if (exists) await tx.update (Books,201).with(data)
  else await tx.create (Books,{ ID:201,...data })
})

::: details Transaction objects tx<srv>

The tx object created by srv.tx() and passed to the function fn is a derivate of the service instance, constructed like that:

tx = { __proto__:srv,
  context: { tenant, user, locale }, // defaults from cds.context
  model: cds.model, // could be a tenant-extended variant instead
  commit(){...},
  rollback(){...},
}

:::

The new root transaction is also active for all nested operations run from fn, including other services, most important database services. In particular, the following would work as well as expected (this time using cds.tx as shortcut cds.db.tx):

await cds.tx (async () => {
  let exists = await SELECT(1).from(Books,201).forUpdate()
  if (exists) await UPDATE (Books,201).with(data)
  else await INSERT.into (Books,{ ID:201,...data })
})

Optional argument ctx allows to override values for nested contexts, which are otherwise inherited from cds.context, for example:

await cds.tx ({ tenant:t0, user: privileged }, async ()=>{
  // following + nested will now run with specified tenant and user...
  let exists = await SELECT(1).from(Books,201).forUpdate()
  ...
})

If argument fn is omitted, the constructed tx would be returned and can be used to manage the transaction in a fully manual fashion:

const tx = srv.tx() // [!code focus]
try { // [!code focus]
  let exists = await tx.run ( SELECT(1).from(Books,201).forUpdate() )
  if (exists) await tx.update (Books,201).with(data)
  else await tx.create (Books,{ ID:201,...data })
  await tx.commit() // [!code focus]
} catch(e) {
  await tx.rollback(e) // will rethrow e // [!code focus]
} // [!code focus]

::: warning

Note though, that with this usage we've not started a new async context, and all nested calls to other services, like db, will not happen within the confines of the constructed tx.

:::

srv.tx (context?, fn?) → tx<srv>

Use srv.tx() to start new app-controlled transactions manually, most commonly for database services as in this example:

let db = await cds.connect.to('db')
let tx = db.tx()
try {
  await tx.run (SELECT.from(Foo))
  await tx.create (Foo, {...})
  await tx.read (Foo)
  await tx.commit()
} catch(e) {
  await tx.rollback(e)
}

Arguments:

  • context – an optional context object → see below
  • fn – an optional function to run → see below

Returns: a transaction object, which is constructed as a derivate of srv like that:

tx = Object.create (srv, Object.getOwnPropertyDescriptors({
  commit(){...},
  rollback(){...},
}))

In effect, tx objects ...

  • are concrete context-specific — that is tenant-specific — incarnations of srves
  • support all the Service API methods like run, create and read
  • support methods tx.commit and tx.rollback as documented below.

Important: The caller of srv.tx() is responsible to commit or rollback the transaction, otherwise the transaction would never be finalized and respective physical driver connections never be released / returned to pools.

srv.tx ({ tenant?, user?, ... }) → tx<srv> {#srv-tx-ctx}

Optionally specify an object with event context properties as the first argument to execute subsequent operations with different tenant or user context:

let tx = db.tx ({ tenant:'t1' user:'u2' })

The argument is an object with these properties:

  • user — a unique user ID string or an instance of cds.User
  • tenant — a unique string identifying the tenant
  • locale — a locale string in format <language>_<region>

The implementation constructs a new instance of cds.EventContext from the given properties, which is assigned to tx.context of the new transaction.

Learn more in section Continuations & Contexts.{.learn-more}

srv.tx ((tx)=>{...}) → tx<srv> {#srv-tx-fn}

Optionally specify a function as the last argument to have commit and rollback called automatically. For example, the following snippets are equivalent:

await db.tx (async tx => {
  await tx.run (SELECT.from(Foo))
  await tx.create (Foo, {...})
  await tx.read (Foo)
})
let tx = db.tx()
try {
  await tx.run (SELECT.from(Foo))
  await tx.create (Foo, {...})
  await tx.read (Foo)
  await tx.commit()
} catch(e) {
  await tx.rollback(e)
}

In addition to creating a new tx for the current service,

srv.tx (ctx) → tx<srv> {#srv-tx-context}

If the argument is an instance of cds.EventContext the constructed transaction will use this context as it's tx.context. If the specified context was constructed for a transaction started with cds.tx(), the new transaction will be constructed as a nested transaction. If not, the new transaction will be constructed as a root transaction.

cds.context = { tenant:'t1', user:'u2' }
const tx = cds.tx (cds.context)
//> tx is a new root transaction
const tx = cds.context = cds.tx ({ tenant:'t1', user:'u2' })
const tx1 = cds.tx (cds.context)
//> tx1 is a new nested transaction to tx

tx.context cds.EventContext {#tx-context }

Each new transaction created by cds.tx() will get a new instance of cds.EventContext constructed and assigned to this property. If there is a cds.context set in the current continuation, the newly constructed context object will inherit properties from that.

Learn more in section Continuations & Contexts.{.learn-more}

tx.commit (res?) ⇢ res {#commit }

In case of database services, this sends a COMMIT (or ROLLBACK) command to the database and releases the physical connection, that is returns it to the connection pool. In addition, the commit is propagated to all nested transactions.

The methods are bound to the tx instance, and the passed-in argument is returned, or rethrown in case of rollback, which allows them to be used as follows:

let tx = cds.tx()
tx.run(...) .then (tx.commit, tx.rollback)

tx.rollback (err?) ⇢ err {#rollback }

In case of database services, this sends ROLLBACK command to the database and releases the physical connection. In addition, the rollback is propagated to all nested transactions, and if an err object is passed, it is rethrown.

See documentation for commit for common details.{.learn-more}


::: warning Note: commit and rollback both release the physical connection. This means subsequent attempts to send queries via this tx will fail. :::

cds.spawn() {#cds-spawn .method}

Runs the given function as detached continuation in a specified event context (not inheriting from the current one). Options every or after allow to run the function repeatedly or deferred. For example:

cds.spawn ({ tenant:'t0', every: 1000 /* ms */ }, async (tx) => {
  const mails = await SELECT.from('Outbox')
  await MailServer.send(mails)
  await DELETE.from('Outbox').where (`ID in ${mails.map(m => m.ID)}`)
})

::: tip Even though the callback function is executed as a background job, all asynchronous operations inside the callback function must be awaited. Otherwise, transaction handling does not work properly. :::

Arguments:

  • options is the same as the ctx argument for cds.tx(), plus:
    • every: <n> number of milliseconds to use in setInterval(fn,n)
    • after: <n> number of milliseconds to use in setTimeout(fn,n)
    • if non of both is given, setImmediate(fn) is used to run the job
  • fn is a function representing the background task

Returns:

  • An event emitter which allows to register handlers on succeeded, failed, and done events.
let job = cds.spawn(...)
job.on('succeeded', ()=>console.log('succeeded'))
  • In addition, property job.timer returns the response of setTimeout in case option after was used, or setInterval in case of option every. For example, this allows to stop a regular running job like that:
let job = cds.spawn({ every:111 }, ...)
await sleep (11111)
clearInterval (job.timer) // stops the background job loop

The implementation guarantees decoupled execution from request-handling threads/continuations, by...

  • constructing a new root transaction tx per run using cds.tx()
  • setting that as the background run's continuation's cds.context
  • invoking fn, passing tx as argument to it.

Think of it as if each run happens in an own thread with own context, with automatic transaction management.

By default, the nested context inherits all values except timestamp from cds.context, especially user and tenant. Use the argument options if you want to override values, for example to run the background thread with different user or tenant than the one you called cds.spawn() from.

DEPRECATED APIs

srv.tx (req) → tx<srv> {#srv-tx-req}

Prior to release 5, you always had to write application code like that to ensure context propagation and correctly managed transactions:

this.on('READ','Books', req => {
  const tx = cds.tx(req)
  return tx.read ('Books')
})

This still works but is not required nor recommended anymore.