Closed drakedeatonuk closed 1 month ago
Hi, thank you for the suggestion, but I see that you are trying to implement distributed transactions without a shared coordinator. There is no guarantee that the rollback will ever be called, or that all the rollbacks will be called (the app can crash in the meantime). There is multiple ways in which you can end up with inconsistent data.
That alone is the reason I won't consider adding a plugin like this, but feel free to implement it yourself with the existing plugin as a reference.
Btw, if you're working with 3rd party APIs, you would definitely benefit from the "Outbox" pattern.
Right gotchya, that makes a lot of sense. Thanks very much for the feedback. Spent some time reading up on the outbox pattern today. I can see the benefits of the approach over my current solution. Much more robust.... Looks like I've got some work to do!
π
@Papooch I've been spending more time looking into the Outbox pattern as I'm keen to try to use it to implement rollbacks across different third party services in a more robust way than the approach I proposed previously.
If you've got a couple minutes at any point I'd love to know what you think of this approach:
Outbox
table stores transactions for every step in a workflow.hasWorkflowFailed: true
'Outbox
table.outboxEventId - unique row id workflowId - a unique identifier for the stack in which the workflow is occurring eventType - the name of the event to emit for this workflow step rollbackEventType - the name of the event to emit in the case of rolling back this outbox event rollbackArgs - the args to pass when calling the rollbackEventType hasWorkflowFailed - false if no sep in the workflow has failed, true if any has. createdOn updatedAt
class WorkflowSvc {
constructor () { }
async run(
args: T
) {
const dbCreate = await UserDbSvc.create();
const thirdPartyCreate = await UserThirdPartySvc.create();
const transactionEnder = await this.OutboxSvc.complete()
}
}
class UserDbSvc {
constructor(
private OutboxSvc: OutboxSvc,
private DbSvc: DbSvc
) {}
async create() {
return this.DbSvc.user.create()
.then(user =>
this.OutboxSvc.create({
eventType: 'user.created',
rollbackEventType: 'user.delete',
rollbackPayload: { where: { userId: newUser.userId } },
})
)
.catch(e => this.OutboxSvc.fail())
}
}
class OutboxSvc {
constructor(
private DbSvc: DbSvc,
private ClsSvc: ClsSvc<object>
) {}
/**
* creates a new outbox entry with a 'stackId' which links all outbox transactions that occurr in the same workflow
*/
async create(args) {
return this.Db.outbox.create({
data: {
...args,
stackId: this.ClsSvc.get("stackId")
}
})
}
/**
* ends an set of transactions by deleting all outbox transactions that match the current workflow's stackId
*/
async complete() {
return this.Db.outbox.deleteMany({
where: {
stackId: this.ClsSvc.get("stackId")
}
})
}
/**
* ends an set of transactions by deleting all outbox transactions that match the current workflow's stackId
*/
async fail() {
return this.Db.outbox.updateMany({
where: {
stackId: this.ClsSvc.get("stackId")
},
data: {
hasWorkflowFailed: true
}
})
}
}
OutboxListener {
// poll the DB table periodically
// if any rows are marked as "hasWorkflowFailed == true", rollback workflow based on 'createdOn' times
}
This is quite a simple example but it should provide the gist of the approach. Hopefully I have made the logic clear enough. I appreciate it's not strictly the outbox pattern, but it certainly borrows from it.
There's potentially scope to make a cls plugin that could abstract away some of the outbox logic, I am thinking.
Anyways - if you have some minutes, would love to hear what you think! No worries if not.
π
Hi, yes, it makes sense. Although this is not definitely an Outbox pattern, it's much closer to the "Saga" pattern (or sometimes called a Long-Running Action (LRA)). A Saga instance stores state of a single workflow execution.
Your "rollback events" are what is commonly referred to as "compensating actions" in the BASE world - an action that undoes an earlier change, until which the sytem state can be inconsistent (as opposed to ACID, where each state change must be atomic and consistent).
Before you go ahead and reinvent the entire wheel, have a look at https://temporal.io/, which implements this kind of pattern.
Very cool stuff @Papooch appreciate the insight.
I've found an example of the Saga pattern with compensating actions using Temporal: https://github.com/temporalio/samples-typescript/blob/main/saga/src/workflows.ts . I'll have a crack at adding a workflow into my nestjs monolith.
Will be some months before I have the time to tackle this but may let you know how I get on down the road! Thanks again π
In the project I'm working on, we use a
Rollback
class that we wrap around function returns in order to manage multi-transaction rollbacks across multiple third party services (e.g. db api, payment processor api, customer support api, etc...).This is roughly how the rollback logic works:
With the above structures, you can essentially manage quite complex rollbacks across many different layers.
Here is an example of how the above structures can be used to manage rollbacks across multiple third party services:
As a nest-js/cli plugin, the above logic would be greatly simplified:
To summarise, how this plugin would be used:
Rollbackable
decoratorRollbackHost
into any services with methods used within theRollbackable
-wrapped method.RollbackHost.rollback
within theRollbackable
-wrapped method....
To summarise how this plugin differs from the
Transactional
plugin. In this plugin:Any feedback on feasibility or ergonomics would be greatly appreciated :)