feathers-sequelize icon indicating copy to clipboard operation
feathers-sequelize copied to clipboard

Transactions support

Open mogadanez opened this issue 7 years ago • 19 comments

Readme says that adapter supports transactions. but not found any other mentions even in sources. Is it any guides how to use it?

mogadanez avatar Jan 21 '18 20:01 mogadanez

Some notes:

1. patch method

https://github.com/feathersjs-ecosystem/feathers-sequelize/blob/master/lib/index.js#L180 https://github.com/feathersjs-ecosystem/feathers-sequelize/blob/master/lib/index.js#L189

getModel method accept params but update method accept constructed options which use params.sequelize

So for right use transaction I need pass params like { sequelize: {transaction: t}, transaction: t}

2.events events not have any knowledge about transactions, so emit message to client even if transaction is reverted.

mogadanez avatar Jan 23 '18 19:01 mogadanez

It also worth mentioning, that update method doesn't even support transactions, or any sequelize parameters.

https://github.com/feathersjs-ecosystem/feathers-sequelize/blob/master/lib/index.js#L256 return instance.update(copy, {raw: false}).then(() => this._get(id, {sequelize: options})); The only parameter passed to the update sequelize call is { raw: false }

jumika avatar Mar 04 '18 14:03 jumika

For the events i created these hooks:

import { SKIP } from '@feathersjs/feathers';

function stackTransactionEvents(context) {
  const { params, service } = context;
  if(!params.sequelize || !params.sequelize.transaction) {
    return context;
  }

  if(!params.stackedEvents) params.stackedEvents = [];
  let event;
  switch(context.method) {
    case 'create':
    case 'update':
    case 'remove':
      event = context.method + 'd';
      break;
    case 'patch':
      event = 'patched';
      break;
  }
  if(event) {
    params.stackedEvents.push({
      service,
      event,
      data: context.dispatch ? context.dispatch : context.result,
      context
    });
  }
  return SKIP;
}

function unstackTransactionEvents(context) {
  const { params } = context;
  if(!params.sequelize || !params.sequelize.transaction || !params.stackedEvents) {
    return context;
  }
  params.stackedEvents.forEach(function(stackedEvent) {
    const {service, event, context} = stackedEvent;
    let { data } = stackedEvent;
    if(!Array.isArray(data)) {
      data = [data];
    }
    console.log('EMITTING ' + event);
    service.emit(event, data, context);
  });
  return context;
}

Use something like this as a patch before hook: (Where content and route are associated models with respective services)

function patchTransaction(context) {
  const { data , app, service, params } = context;
  if(params.sequelize && params.sequelize.transaction) {
    return context;
  }
  // If the associated content ad route are sent with the data, then a transaction is created
  if(data.content && data.route) {
    const seq = app.get('sequelizeClient');
    return seq.transaction(function(t) {
      const paramsWithTransaction = {sequelize: {transaction: t}};
      return app.service('content').patch(data.contentId, data.content, paramsWithTransaction)
      .then(function() {
        return app.service('route').patch(data.routeId, data.route, paramsWithTransaction)
        .then(function() {
          return service.patch(data.id, data, paramsWithTransaction)
        });
      });
    }).then(async function() {
      context.result = await service.get(data.id);
      return SKIP;
    });
  }
  return context;
}

You have to use the stackTransactionEvents as the last after hook on your services which are called in your transaction. With returning SKIP the automatic event dispatching is disabled.

You have to use the unstackTransactionEvents as the last after hook on your service which creating the transaction.

Please keep inn mind, that I'm not a feathers expert. Maybe the code has some flaws, or design misconceptions.

It would be nice, if someone from the core team could take a look at it, and confirm this is a right approach.

@daffl What do you think?

jumika avatar Mar 04 '18 15:03 jumika

Good catch on not wanting events to be emitted if the transaction is aborted.

I don't think the team will say this is a good approach as it's a hack. Do you really want a SKIP in there as you have to remember your hook ordering?

I think it best if a hook.skipEvent prop is supported that the eventHook function, as a 'finally' hook called after 'after' hooks, checks whether to emit an event.

https://github.com/feathersjs/feathers/blob/master/lib/events.js#L6

const eventHook = exports.eventHook = function eventHook () {
  return function (hook) {
    if (hook.skipEvent) return; // <---- ADD THIS
    const { app, service } = hook;
    const eventName = app.eventMappings[hook.method];
    const isHookEvent = service._hookEvents && service._hookEvents.indexOf(eventName) !== -1;

    // If this event is not being sent yet and we are not in an error hook
    if (eventName && isHookEvent && hook.type !== 'error') {
      const results = Array.isArray(hook.result) ? hook.result : [ hook.result ];

      results.forEach(element => service.emit(eventName, element, hook));
    }
  };
};

I did a quick and dirty PR: https://github.com/feathersjs/feathers/pull/862

@daffl

TimNZ avatar Apr 28 '18 09:04 TimNZ

Are there any guides on how to use transactions? I'm trying but haven't been successful yet.

bernardobelchior avatar Jun 16 '18 12:06 bernardobelchior

Feathers specific: Pass 'sequelize' as a param to your service calls, with a nested 'transaction'.

Read up on transactions in Sequelize for everything else to do with transaction lifetime.

app.get('sequelizeClient').transaction(transOptions, transaction => {
    return app.service('serviceName').create({data},{ sequelize: { transaction}  })
})

TimNZ avatar Jun 17 '18 07:06 TimNZ

Thank you! Would you be interested in some documentation regarding transactions?

bernardobelchior avatar Jun 17 '18 08:06 bernardobelchior

Just noticed the comments about update() not passing the transaction.

params.sequelize is not consistently passed to all sequelize calls, I'll do a PR.

TimNZ avatar Jun 17 '18 09:06 TimNZ

My intention is to make multiple service call on before create hook. When I tried inside a service's before create hook, i got "TypeError: Converting circular structure to JSON" error. What cause the problem ? Is there something wrong ?

myService.hooks.js:

  function (context) {
    ...
    app.get('sequelizeClient').transaction(transOptions, transaction => {
      return app.service('message').create({ text: "a new record" }, { sequelize: { transaction}  })
         .then(res => {
           // console.log('Result', res);
         })
         .catch(err => {
           // console.log('Error', err);
     });
  })

tunaung22 avatar Sep 02 '18 10:09 tunaung22

Does anyone know how to do transactions for an entire service? Something like what feathers-knex has. LINK

bmbariah avatar Jul 18 '19 10:07 bmbariah

any update on this?

RickVazquez avatar Sep 28 '19 22:09 RickVazquez

Copy the feathers-knex hooks.js and change it to use unmanaged Sequelize transaction mechanism.

https://sequelize.org/master/manual/transactions.html

TimNZ avatar Sep 28 '19 23:09 TimNZ

@tunaung2048 Sequelize transaction object has references back to parent => circular reference when console.log is traversing it to output.

My intention is to make multiple service call on before create hook. When I tried inside a service's before create hook, i got "TypeError: Converting circular structure to JSON" error. What cause the problem ? Is there something wrong ?

myService.hooks.js:

  function (context) {
    ...
    app.get('sequelizeClient').transaction(transOptions, transaction => {
      return app.service('message').create({ text: "a new record" }, { sequelize: { transaction}  })
         .then(res => {
           // console.log('Result', res);
         })
         .catch(err => {
           // console.log('Error', err);
     });
  })

TimNZ avatar Sep 29 '19 08:09 TimNZ

This is how I manage system-wide transaction support for ALL sequelize requests. Add the following in app.hooks.js

// Application hooks that run for every service
module.exports = {
  before: {
    all: [function (context) {
      const sequelize = context.app.get('sequelizeClient');
      context.params.transaction = sequelize.transaction();
      //console.log(context);
    }],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  },

  after: {
    all: [function (context) {
      //context.params.transaction.commit();
      return context.params.transaction.then(t => {
        return t.commit();
      });
    }],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  },

  error: {
    all: [function (context) {
      //context.params.transaction.rollback();
      return context.params.transaction.then(t => {
        return t.rollback();
      });
    }],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  }
};
`

bmbariah avatar Jan 21 '20 16:01 bmbariah

this is a fantastic cookbook recipe.

Thank you,

Mark Edwards

On Tue, Jan 21, 2020 at 9:53 AM Boniface Mbaria [email protected] wrote:

This is how I manage system-wide transaction support for ALL sequelize requests. Add the following in app.hooks.js

// Application hooks that run for every servicemodule.exports = { before: { all: [function (context) { const sequelize = context.app.get('sequelizeClient'); context.params.transaction = sequelize.transaction(); //console.log(context); }], find: [], get: [], create: [], update: [], patch: [], remove: [] },

after: { all: [function (context) { //context.params.transaction.commit(); return context.params.transaction.then(t => { return t.commit(); }); }], find: [], get: [], create: [], update: [], patch: [], remove: [] },

error: { all: [function (context) { //context.params.transaction.rollback(); return context.params.transaction.then(t => { return t.rollback(); }); }], find: [], get: [], create: [], update: [], patch: [], remove: [] } };`

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/feathersjs-ecosystem/feathers-sequelize/issues/188?email_source=notifications&email_token=AAWJ3YQQ7QWXOKQ6AHHQEQLQ64R7TA5CNFSM4EMX2TDKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEJQOE4Y#issuecomment-576774771, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAWJ3YWKA23KOIBVV5OEK3TQ64R7TANCNFSM4EMX2TDA .

edwardsmarkf avatar Jan 21 '20 18:01 edwardsmarkf

How would you approach this problem if you need the reference to the same transaction in the service and in an after hook? If there is an error in the service or in an after hook (to create a related entity), could you do the roll back in the error:all hook?

Earthii avatar Apr 01 '20 19:04 Earthii

@Earthii

Don't know if this helps but all my tables have a last_request_id (UUID) column. In my app.hooks.js I add this:

const { v1: uuidv1 } = require('uuid');

before: {
    all: [
      async context => {
        const { params } = context;
        if (!params.request_id) {
          params.request_id = uuidv1();
        }
     ],
     create: [
        async context => {
          const { data } = context;
          data.last_request_id = params.request_id;
      ],
     update: [
        async context => {
          const { data } = context;
          data.last_request_id = params.request_id;
      ],
     patch: [
       async context => {
          const { data } = context;
          data.last_request_id = params.request_id;
      ]

This adds the same uuid to last_request_id on any table that is part of the current transaction before it is created/updated/patched and allows me to find affected tables and rows in subsequent hooks.

To identify the paths/services in current request associated with the `last_request_id I also add the following to app.hooks.js

before: {
    all: [
      async context => {
        const { params, path } = context;
        if (!params.request_id) {
          params.request_id = uuidv1();
        }

        if (!params.rollback) {
           context.params.rollback = [];
        }
        context.params.rollback.push(path);
     ],

Now you will have access to the services that were processed in the current request where you need them

console.log(params.rollback);
//   [ 'users', 'emails', 'phones' ]

corepay avatar Jun 29 '20 16:06 corepay

Thanks everyone! This is a mix of multiple solutions.

It seems to work for me when I call a service from another hook using:

const file = await context.app.service('files').create({
                        parentId: context.data.parentId,
                        type: 'PDF',
                        name: 'my_neme.pdf'
                    }, context.params);

sequelize-transaction.ts

/*
 * Use this hook to manipulate incoming or outgoing data.
 * For more information on hooks see: http://docs.feathersjs.com/api/hooks.html
 */
import {HookContext, Service} from '@feathersjs/feathers';
import {Transaction} from 'sequelize';
import {v4 as uuid} from 'uuid';

import Debug from 'debug';
// eslint-disable-next-line new-cap
const debug = Debug('sequelize-transaction');

interface StackedEvent {
    service: Service<any>;
    event: string;
    data: any;
    context: HookContext;
}

async function before (context: HookContext): Promise<void> {
    debug('transaction create? ======================================================================================');
    const uuidContext = uuid();
    if (context.params.uuidStack === undefined) {
        context.params.uuidStack = [] as string[];
    }
    if (context.params.stackedEvents === undefined) {
        context.params.stackedEvents = [] as StackedEvent[];
    }
    context.params.uuidStack.push(uuidContext);
    debug(`context uuid ${uuidContext}`);
    debug(`context uuidStack [${context.params.uuidStack}]`);
    debug(`context transactionOwner ${context.params.transactionOwner}`);
    debug(`context path ${context.path}`);
    debug(`context method ${context.method}`);
    const sequelize = context.app.get('sequelizeClient');

    // If no transaction created
    if (
        context.params.sequelize === null || context.params.sequelize === undefined ||
        context.params.sequelize.transaction === null || context.params.sequelize.transaction === undefined
    ) {
        // Create the transaction
        context.params.sequelize = context.params.sequelize || {};
        context.params.sequelize.transaction = await sequelize.transaction(
            Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED);
        context.params.transactionOwner = uuidContext;
        debug(`new transaction created with owner ${uuidContext}`);
    } else {
        debug(`no new transaction created. There is already a transaction from owner ${uuidContext}`);
    }
    debug('==========================================================================================================');
}


async function after (context: HookContext): Promise<void> {
    const uuidContext = context.params.uuidStack.pop();
    debug(`event ${context.params.event}`);
    debug(context.params.event);
    debug('transaction commit? ======================================================================================');
    debug(`context uuid ${uuidContext}`);
    debug(`context uuidStack [${context.params.uuidStack}]`);
    debug(`context transactionOwner ${context.params.transactionOwner}`);
    debug(`context path ${context.path}`);
    debug(`context method ${context.method}`);

    if (context.params.transactionOwner !== undefined) {
        // Prevent from emitting event
        context.event = null;
        // But add it to the stack of events
        let event;
        switch (context.method) {
            case 'create':
            case 'update':
            case 'remove':
                event = `${context.method}d`;
                break;
            case 'patch':
                event = 'patched';
                break;
            default:
                break;
        }


        if (event) {
            context.params.stackedEvents.push({
                service: context.service,
                event,
                data: context.dispatch ? context.dispatch : context.result,
                context
            } as StackedEvent);
            debug('added an event to the stack');
        } else {
            debug('WARNING: no event added');
        }

        if (uuidContext === context.params.transactionOwner) {
            debug('this is the last context in the stack.');
            debug('commit changes');
            await context.params.sequelize.transaction.commit();

            // Emit all events
            debug('emit all events');
            context.params.stackedEvents.forEach((stackedEvent: StackedEvent) => {
                debug('not the last event in the stack. Will not emit event');
                let data = stackedEvent.data;
                if (!Array.isArray(data)) {
                    data = [data];
                }
                debug(`    - ${stackedEvent.event} ${stackedEvent.context.path}`);
                stackedEvent.service.emit(
                    stackedEvent.event,
                    data,
                    stackedEvent.context
                );
            });
        } else {
            debug('not the last event in the stack. Will not commit/emit event');
        }
    }
    debug('==========================================================================================================');
}

async function error (context: HookContext): Promise<void> {
    const contextUuid = context.params.uuidStack.pop();
    debug(context.params.event);
    debug('transaction rollback? ====================================================================================');
    debug(`path ${context.path}`);
    debug(`method ${context.method}`);
    debug(`uuid ${contextUuid}`);
    debug(`uuidStack [${context.params.uuidStack}]`);
    debug(`transactionOwner ${context.params.transactionOwner}`);

    if (contextUuid === context.params.transactionOwner) {
        debug('this is the last context in the stack.');
        debug('rollback changes');
        await context.params.sequelize.transaction.rollback();
    } else {
        debug('not the last event in the stack. Will not rollback');
    }
    debug('==========================================================================================================');
}

export const SequelizeTransaction = {
    before,
    after,
    error
};

quentin-roche avatar May 06 '21 14:05 quentin-roche

Tks @edwardsmarkf edwardsmarkf and @yohane55 yohane55

In my case I wanted to use the same transaction across multiple services. When I send a POST to /notification service I wanted to save all tags, using my /tag service and save all on the same request/transaction.

So I did some changes on this hook and the behaviour was changed from transaction by service to transaction by request:

You can see more details here: https://github.com/denisgmarques/feathersjs-sequelize-transaction-across-multiple-services

denisgmarques avatar Jun 22 '22 19:06 denisgmarques