Async Local Storage et transactions

Async Local Storage et transactions

Aaaah les transactions : ce truc indispensable dans toute application sérieuse, et qui génère plein de boilerplate dans les codebases Javascript. Dommage, on n'a pas de leaky abstractions comme le @Transactional de Spring (balle perdue au passage).

Dans ce post, j'aimerais vous partager une abstraction que j'utilise dans (presque) tous mes projets pour simplifier tout ça, et éviter que les transactions ne débordent de partout.

Avec Prisma, une transaction, ça s'écrit comme ça :

await prisma.$transaction(async tx => {
  // Tout le code qui est ici est transactionnel
  tx.truc.create({ ... })
})

J'utilise ici Prisma, mais j'ai déjà utilisé cette abstraction sur des projets utilisant Drizzle ou TypeORM.

C'est quoi ton problème ?

Je code principalement en utilisant l'architecture hexagonale.

(Pas besoin de comprendre l'architecture hexagonale pour comprendre l'abstraction proposée dans cet article)

Une expression bien trop compliquée pour dire que les choix de technologies sont cachés derrière des interfaces dans mon code métier (use cases, domain model, etc).

Il est communément admis que dans ce type d'architecture, le use case (i.e. application service en DDD) porte la transaction.

La conséquence :

  • Tout le code "imbriqué" dans la transaction doit travailler sur celle-ci, mais "interdiction" de la passer en argument à la partie métier du code ;
  • On se retrouve donc à passer l'object tx un peu partout dans les paramètres des fonctions

Bref, pas très ergonomique comme DX.

Et tu nous proposes quoi ?

Je vous propose une abstraction à base d'Async Local Storage qui est à mon goût très pratique et qui me permet d'ajouter quelques fonctionnalités autour du concept de transactions.

These classes [AsyncLocalStorage] are used to associate state and propagate it throughout callbacks and promise chains. They allow storing data throughout the lifetime of a web request or any other asynchronous duration. It is similar to thread-local storage in other languages.

L'AsyncLocalStorage est aux fonctions asynchrones ce qu'est le Context React aux components.

Il nous permet de stocker des valeurs dans un store, dans lequel on pourra aller piocher plus tard dans une fonction qui partage le même contexte asynchrone.

Dans la pratique, c'est très simple :

interface Transaction {
  run<T>(callback: () => Promise<T>): Promise<T>
}

const als = new AsyncLocalStorage<PrismaClient>()

export class PrismaTransaction implements Transaction {
  constructor(private readonly prisma: PrismaClient) {}

  run<T>(callback: () => Promise<T>): Promise<T> {
    return this.prisma.$transaction((tx) => als.run(tx, callback));
  }
}

export const tx = () => als.getStore() ?? raise(new Error('No transaction available')

Vous pouvez donc maintenant rendre tout votre code métier "agnostique" de la transaction, et récupérer celle-ci dans les implémentations de vos repositories qui deviennent transactionnels.

async function doSomething(prisma, args) {
  await new PrismaTransaction(prisma).run(() =>
    doBusinessWorkflow.execute(args)
  )
}

class SomeEntityRepository {
  ofId(id: string) {
    // Ici l'appel à tx() permet de récupérer la transaction prisma
    // depuis le contexte asynchrone
    return tx().someEntity.findUnique({ id })
  }

  async save(entity: Entity) {
    await tx().someEntity.upsert({ ... })
  }
}

✅ Plus besoin d'instancier des repositories transactionnels à la main
✅ Plus besoin de passer la transaction en paramètre de toutes les fonctions jusqu'au repository

Vous voulez un dessert ?

Allez, je vous partage une amélioration que j'ai faite récemment sur mon abstraction.

J'ai souvent besoin de faire des trucs après que la transaction ait été commit. Et j'aimerais bien garder ce code dans mes use cases.

Solution : utiliser l'Async Local Storage pour stocker des callbacks à exécuter quand la transaction sera commitée.

En code, ça donne ça :

// Transaction.ts
type TransactionContext = {
  tx: PrismaClient;
  hooks: PostCommitHook[];
};
const als = new AsyncLocalStorage<TransactionContext>();

export const postCommit = (cb: PostCommitHook) => {
  als.getStore()?.hooks.push(cb);
};

export class PrismaTransaction implements Transaction {
  private readonly logger = new Logger(PrismaTransaction.name);

  constructor(private readonly prisma: BasePrismaClient) {}

  async run<T>(
    scope: TransactionScope<T>,
    options: TransactionOptions = {},
  ): Promise<T> {
    const hooks: PostCommitHook[] = [];
    const result = await this.prisma.$transaction(
      (tx) => store.run({ tx, hooks }, scope),
      { timeout: options.timeout, isolationLevel: options.isolationLevel },
    );
    this.runPostCommitHooks(hooks);

    return result;
  }

  private runPostCommitHooks(hooks: PostCommitHook[]) {
    for (const hook of hooks) {
      queueMicrotask(async () => {
        try {
          await hook();
        } catch (err) {
          this.logger.error({ err }, "Error in post-commit hook");
        }
      });
    }
  }
}

Le principe est simple : on ne stocke plus uniquement la transaction dans le store, mais également un tableau de callbacks.

Une fois la transaction terminée, on exécute chaque callback.

Les plus curieux auront vu que j'utilise queueMicrotask : pour éviter qu'une erreur lancée de manière synchrone dans un des callbacks ne soit propagée dans la fonction à l'initiative de la transaction. Celle-ci serait alors en erreur alors que la transaction a été commitée.

À l'utilisation, ça donne ça :

// Usecase.ts
class SomeWorkflow {
  execute(args) {
    // Faire quelque chose dans la transaction
    // ...

    postCommit(() => sendEmail(args.email))
}

// Controller.ts
async function httpHandler(req) {
  await new PrismaTransaction(prisma).run(() =>
    someWorfklow.execute(req.body)
  )
}

Le use case est exécuté dans une transaction. Et les traitements tels que l'envoi d'emails ou de notifications seront exécutés en dehors de la transaction, sans pour autant que le code ne s'éparpille 🤗

C'est la fin

Voila, vous savez tout ! Cette abstraction mêle plusieurs concepts "avancés" de Node (ALS, queueMicrotask), mais rassurez vous : vous n'aurez plus jamais à ouvrir ces fichiers, ni à vous occuper des transactions dans le code métier 🤗

Vive l'AsyncLocalStorage, super API !