Feat(medusa) - Orchestrator builder (#4472)

* chore: Trasanction Orchestrator builder

* Feat(medusa): Workflow Manager (#4506)
This commit is contained in:
Carlos R. L. Rodrigues
2023-07-13 10:53:55 -03:00
committed by GitHub
parent fe6586e560
commit 43427b8893
11 changed files with 1408 additions and 112 deletions
@@ -0,0 +1,435 @@
import { OrchestratorBuilder } from "../../transaction/orchestrator-builder"
describe("OrchestratorBuilder", () => {
let builder: OrchestratorBuilder
beforeEach(() => {
builder = new OrchestratorBuilder()
})
it("should load a TransactionStepsDefinition", () => {
builder.load({ action: "foo" })
expect(builder.build()).toEqual({
action: "foo",
})
})
it("should add a new action after the last action set", () => {
builder.addAction("foo")
expect(builder.build()).toEqual({
action: "foo",
})
builder.addAction("bar")
expect(builder.build()).toEqual({
action: "foo",
next: {
action: "bar",
},
})
})
it("should replace an action by another keeping its next steps", () => {
builder.addAction("foo").addAction("axe").replaceAction("foo", "bar")
expect(builder.build()).toEqual({
action: "bar",
next: {
action: "axe",
},
})
})
it("should insert a new action before an existing action", () => {
builder.addAction("foo").addAction("bar").insertActionBefore("bar", "axe")
expect(builder.build()).toEqual({
action: "foo",
next: {
action: "axe",
next: {
action: "bar",
},
},
})
})
it("should insert a new action after an existing action", () => {
builder.addAction("foo").addAction("axe").insertActionAfter("foo", "bar")
expect(builder.build()).toEqual({
action: "foo",
next: {
action: "bar",
next: {
action: "axe",
},
},
})
})
it("should move an existing action and its next steps to another place. the destination will become next steps of the final branch", () => {
builder
.addAction("foo")
.addAction("bar")
.addAction("axe")
.addAction("zzz")
.moveAction("axe", "foo")
expect(builder.build()).toEqual({
action: "axe",
next: {
action: "zzz",
next: {
action: "foo",
next: {
action: "bar",
},
},
},
})
})
it("should merge two action to run in parallel", () => {
builder
.addAction("foo")
.addAction("bar")
.addAction("axe")
.mergeActions("foo", "axe")
expect(builder.build()).toEqual({
next: [
{
action: "foo",
next: { action: "bar" },
},
{ action: "axe" },
],
})
})
it("should merge multiple actions to run in parallel", () => {
builder
.addAction("foo")
.addAction("bar")
.addAction("axe")
.addAction("step")
.mergeActions("bar", "axe", "step")
expect(builder.build()).toEqual({
action: "foo",
next: [
{
action: "bar",
},
{
action: "axe",
},
{
action: "step",
},
],
})
})
it("should delete an action", () => {
builder.addAction("foo").deleteAction("foo")
expect(builder.build()).toEqual({})
})
it("should delete an action and keep all the next steps of that branch", () => {
builder
.addAction("foo")
.addAction("bar")
.addAction("axe")
.deleteAction("bar")
expect(builder.build()).toEqual({
action: "foo",
next: {
action: "axe",
},
})
})
it("should delete an action and remove all the next steps of that branch", () => {
builder
.addAction("foo")
.addAction("bar")
.addAction("axe")
.addAction("step")
.pruneAction("bar")
expect(builder.build()).toEqual({
action: "foo",
})
})
it("should append a new action to the end of a given action's branch", () => {
builder
.load({
action: "foo",
next: [
{
action: "bar",
next: {
action: "zzz",
},
},
{
action: "axe",
},
],
})
.appendAction("step", "bar", { saveResponse: true })
expect(builder.build()).toEqual({
action: "foo",
next: [
{
action: "bar",
next: {
action: "zzz",
next: {
action: "step",
saveResponse: true,
},
},
},
{
action: "axe",
},
],
})
})
describe("Composing Complex Transactions", () => {
const loadedFlow = {
next: {
action: "createProduct",
saveResponse: true,
next: {
action: "attachToSalesChannel",
saveResponse: true,
next: {
action: "createPrices",
saveResponse: true,
next: {
action: "createInventoryItems",
saveResponse: true,
next: {
action: "attachInventoryItems",
noCompensation: true,
},
},
},
},
},
}
it("should load a transaction and add two steps", () => {
const builder = new OrchestratorBuilder(loadedFlow)
builder
.addAction("step_1", { saveResponse: true })
.addAction("step_2", { saveResponse: true })
expect(builder.build()).toEqual({
action: "createProduct",
saveResponse: true,
next: {
action: "attachToSalesChannel",
saveResponse: true,
next: {
action: "createPrices",
saveResponse: true,
next: {
action: "createInventoryItems",
saveResponse: true,
next: {
action: "attachInventoryItems",
noCompensation: true,
next: {
action: "step_1",
saveResponse: true,
next: {
action: "step_2",
saveResponse: true,
},
},
},
},
},
},
})
})
it("should load a transaction, add 2 steps and merge step_1 to run in parallel with createProduct", () => {
const builder = new OrchestratorBuilder(loadedFlow)
builder
.addAction("step_1", { saveResponse: true })
.addAction("step_2", { saveResponse: true })
.mergeActions("createProduct", "step_1")
expect(builder.build()).toEqual({
next: [
{
action: "createProduct",
saveResponse: true,
next: {
action: "attachToSalesChannel",
saveResponse: true,
next: {
action: "createPrices",
saveResponse: true,
next: {
action: "createInventoryItems",
saveResponse: true,
next: {
action: "attachInventoryItems",
noCompensation: true,
},
},
},
},
},
{
action: "step_1",
saveResponse: true,
next: {
action: "step_2",
saveResponse: true,
},
},
],
})
})
it("should load a transaction, add 2 steps and move 'step_1' and all its next steps to run before 'createPrices'", () => {
const builder = new OrchestratorBuilder(loadedFlow)
builder
.addAction("step_1", { saveResponse: true })
.addAction("step_2", { saveResponse: true })
.moveAction("step_1", "createPrices")
expect(builder.build()).toEqual({
action: "createProduct",
saveResponse: true,
next: {
action: "attachToSalesChannel",
saveResponse: true,
next: {
action: "step_1",
saveResponse: true,
next: {
action: "step_2",
saveResponse: true,
next: {
action: "createPrices",
saveResponse: true,
next: {
action: "createInventoryItems",
saveResponse: true,
next: {
action: "attachInventoryItems",
noCompensation: true,
},
},
},
},
},
},
})
})
it("should load a transaction, add 2 steps and move 'step_1' to run before 'createPrices' and merge next steps", () => {
const builder = new OrchestratorBuilder(loadedFlow)
builder
.addAction("step_1", { saveResponse: true })
.addAction("step_2", { saveResponse: true })
.moveAndMergeNextAction("step_1", "createPrices")
expect(builder.build()).toEqual({
action: "createProduct",
saveResponse: true,
next: {
action: "attachToSalesChannel",
saveResponse: true,
next: {
action: "step_1",
saveResponse: true,
next: [
{
action: "step_2",
saveResponse: true,
},
{
action: "createPrices",
saveResponse: true,
next: {
action: "createInventoryItems",
saveResponse: true,
next: {
action: "attachInventoryItems",
noCompensation: true,
},
},
},
],
},
},
})
})
it("Fully compose a complex transaction", () => {
const builder = new OrchestratorBuilder()
builder
.addAction("step_1", { saveResponse: true })
.addAction("step_2", { saveResponse: true })
.addAction("step_3", { saveResponse: true })
builder.insertActionBefore("step_3", "step_2.5", {
saveResponse: false,
noCompensation: true,
})
builder.insertActionAfter("step_1", "step_1.1", { saveResponse: true })
builder.insertActionAfter("step_3", "step_4", { async: false })
builder
.mergeActions("step_2", "step_2.5", "step_3")
.addAction("step_5", { noCompensation: true })
builder.deleteAction("step_3")
expect(builder.build()).toEqual({
action: "step_1",
saveResponse: true,
next: {
action: "step_1.1",
saveResponse: true,
next: [
{
action: "step_2",
saveResponse: true,
},
{
action: "step_2.5",
saveResponse: false,
noCompensation: true,
},
{
action: "step_4",
async: false,
next: {
action: "step_5",
noCompensation: true,
},
},
],
},
})
})
})
})
@@ -68,7 +68,7 @@ describe("Transaction Orchestrator", () => {
expect(mocks.one).toBeCalledWith(
expect.objectContaining({
metadata: {
producer: "transaction-name",
model_id: "transaction-name",
reply_to_topic: "trans:transaction-name",
idempotency_key: "transaction_id_123:firstMethod:invoke",
action: "firstMethod",
@@ -83,7 +83,7 @@ describe("Transaction Orchestrator", () => {
expect(mocks.two).toBeCalledWith(
expect.objectContaining({
metadata: {
producer: "transaction-name",
model_id: "transaction-name",
reply_to_topic: "trans:transaction-name",
idempotency_key: "transaction_id_123:secondMethod:invoke",
action: "secondMethod",
@@ -191,7 +191,7 @@ describe("Transaction Orchestrator", () => {
expect(actionOrder).toEqual(["one", "two", "three"])
})
it("Should store invoke's step response if flag 'saveResponse' is set to true", async () => {
it("Should store invoke's step response by default or if flag 'saveResponse' is set to true and ignore it if set to false", async () => {
const mocks = {
one: jest.fn().mockImplementation((data) => {
return { abc: 1234 }
@@ -244,15 +244,13 @@ describe("Transaction Orchestrator", () => {
const flow: TransactionStepsDefinition = {
next: {
action: "firstMethod",
saveResponse: true,
next: {
action: "secondMethod",
saveResponse: true,
next: {
action: "thirdMethod",
saveResponse: true,
next: {
action: "fourthMethod",
saveResponse: false,
},
},
},
@@ -275,6 +273,9 @@ describe("Transaction Orchestrator", () => {
expect(mocks.three).toBeCalledWith(
{ prop: 123 },
{
payload: {
prop: 123,
},
invoke: {
firstMethod: { abc: 1234 },
secondMethod: { def: "567" },
@@ -662,7 +663,10 @@ describe("Transaction Orchestrator", () => {
const transaction = await strategy.beginTransaction(
"transaction_id_123",
handler
handler,
{
myPayloadProp: "test",
}
)
await strategy.resume(transaction)
@@ -0,0 +1,176 @@
import { WorkflowManager } from "../../transaction/workflow-manager"
import { TransactionState } from "../../transaction/types"
describe("WorkflowManager", () => {
const container: any = {}
let handlers
let flow: WorkflowManager
let asyncStepIdempotencyKey: string
beforeEach(() => {
jest.resetAllMocks()
WorkflowManager.unregisterAll()
handlers = new Map()
handlers.set("foo", {
invoke: jest.fn().mockResolvedValue({ done: true }),
compensate: jest.fn(() => {}),
})
handlers.set("bar", {
invoke: jest.fn().mockResolvedValue({ done: true }),
compensate: jest.fn().mockResolvedValue({}),
})
handlers.set("broken", {
invoke: jest.fn(() => {
throw new Error("Step Failed")
}),
compensate: jest.fn().mockResolvedValue({ bar: 123, reverted: true }),
})
handlers.set("callExternal", {
invoke: jest.fn((container, payload, invoke, metadata) => {
asyncStepIdempotencyKey = metadata.idempotency_key
}),
})
WorkflowManager.register(
"create-product",
{
action: "foo",
next: {
action: "bar",
},
},
handlers
)
WorkflowManager.register(
"broken-delivery",
{
action: "foo",
next: {
action: "broken",
},
},
handlers
)
WorkflowManager.register(
"deliver-product",
{
action: "foo",
next: {
action: "callExternal",
async: true,
noCompensation: true,
next: {
action: "bar",
},
},
},
handlers
)
flow = new WorkflowManager(container)
})
it("should return all registered workflows", () => {
const wf = Object.keys(Object.fromEntries(WorkflowManager.getWorkflows()))
expect(wf).toEqual(["create-product", "broken-delivery", "deliver-product"])
})
it("should begin a transaction and returns its final state", async () => {
const transaction = await flow.begin("create-product", "t-id", {
input: 123,
})
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
expect(transaction.getState()).toBe(TransactionState.DONE)
})
it("should begin a transaction and revert it when fail", async () => {
const transaction = await flow.begin("broken-delivery", "t-id")
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("broken").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
expect(handlers.get("broken").compensate).toHaveBeenCalledTimes(1)
expect(transaction.getState()).toBe(TransactionState.REVERTED)
})
it("should continue an asyncronous transaction after reporting a successful step", async () => {
const transaction = await flow.begin("deliver-product", "t-id")
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
expect(transaction.getState()).toBe(TransactionState.INVOKING)
const continuation = await flow.registerStepSuccess(
"deliver-product",
asyncStepIdempotencyKey,
{ ok: true }
)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
expect(continuation.getState()).toBe(TransactionState.DONE)
})
it("should revert an asyncronous transaction after reporting a failure step", async () => {
const transaction = await flow.begin("deliver-product", "t-id")
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
expect(transaction.getState()).toBe(TransactionState.INVOKING)
const continuation = await flow.registerStepFailure(
"deliver-product",
asyncStepIdempotencyKey,
{ ok: true }
)
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
expect(handlers.get("bar").compensate).toHaveBeenCalledTimes(0)
// Failed because the async is flagged as noCompensation
expect(continuation.getState()).toBe(TransactionState.FAILED)
})
it("should update an existing flow with a new step and a new handler", async () => {
const definition =
WorkflowManager.getTransactionDefinition("create-product")
definition.insertActionBefore("bar", "xor", { maxRetries: 3 })
const additionalHandlers = new Map()
additionalHandlers.set("xor", {
invoke: jest.fn().mockResolvedValue({ done: true }),
compensate: jest.fn().mockResolvedValue({}),
})
WorkflowManager.update("create-product", definition, additionalHandlers)
const transaction = await flow.begin("create-product", "t-id")
console.log(transaction)
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
expect(additionalHandlers.get("xor").invoke).toHaveBeenCalledTimes(1)
expect(transaction.getState()).toBe(TransactionState.DONE)
})
})