resthttppostdistributedidempotent

Making POST requests idempotent


I have been looking for a way to design my API so it will be idempotent, meaning that some of that is to make my POST request routes idempotent, and I stumbled upon this article.

(If I have understood something not the way it is, please correct me!)

In it, there is a good explanation of the general idea. but what is lacking are some examples of the way that he implemented it by himself.

Someone asked the writer of the article, how would he guarantee atomicity? so the writer added a code example.

Essentially, in his code example there are two cases,

the flow if everything goes well:

the flow if something inside the code goes wrong:

Notice that the transaction that is opened is for a certain DB, lets call him A. However, it is not relevant for the redis store that he also uses, meaning that the rollback of the transaction will only affect DB A.

So it covers the case when something happends inside the code that make it impossible to complete the transaction.

But what will happend if the machine, which the code runs on, will crash, while it is in a state when it has already executed the Set expire time to that key and it is now about to run the committing of the transaction?

In that case, the key will be available in the redis store, but the transaction has not been committed. This will result in a situation where the service is sure that the needed changes have already happen, but they didn't, the machine failed before it could finish it.

I need to design the API in such a way that if the change to the data or setting of the key and value in redis fail, that they will both roll back.

What is the solution to this problem?

How can I guarantee the atomicity of a changing the needed data in one database, and in the same time setting the key and the needed response in redis, and if any of them fails, rollback them both? (Including in a case that a machine crashes in the middle of the actions)

Please add a code example when answering! I'm using the same technologies as in the article (nodejs, redis, mongo - for the data itself)

Thanks :)


Solution

  • Per the code example you shared in your question, the behavior you want is to make sure there was no crash on the server between the moment where the idempotency key was set into the Redis saying this transaction already happened and the moment when the transaction is, in fact, persisted in your database.

    However, when using Redis and another database together you have two independent points of failure, and two actions being executed sequentially in different moments (and even if they are executed asynchronously at the same time there is no guarantee the server won’t crash before any of them completed).

    What you can do instead is include in your transaction an insert statement to a table holding relevant information on this request, including the idempotent key. As the ACID properties ensure atomicity, it guarantees either all the statements on the transaction to be executed successfully or none of them, which means your idempotency key will be available in your database if the transaction succeeded.

    You can still use Redis as it’s gonna provide faster results than your database.

    A code example is provided below, but it might be good to think about how relevant is the failure between insert to Redis and database to your business (could it be treated with another strategy?) to avoid over-engineering.

    async function execute(idempotentKey) {
      try {
        // append to the query statement an insert into executions table.
        // this will be persisted with the transaction
        query = ```
            UPDATE firsttable SET ...;
            UPDATE secondtable SET ...;
            INSERT INTO executions (idempotent_key, success) VALUES (:idempotent_key, true);
        ```;
    
        const db = await dbConnection();
        await db.beginTransaction();
        await db.execute(query);
    
        // we're setting a key on redis with a value: "false".
        await redisClient.setAsync(idempotentKey, false, 'EX', process.env.KEY_EXPIRE_TIME);
    
        /*
          if server crashes exactly here, idempotent key will be on redis with false as value.
          in this case, there are two possibilities: commit to database suceeded or not.
          if on next request redis provides a false value, query database to verify if transaction was executed.
        */
    
        await db.commit();
    
        // you can now set key value to true, meaning commit suceeded and you won't need to query database to verify that.
        await redis.setAsync(idempotentKey, true);
      } catch (err) {
        await db.rollback();
        throw err;
      }
    }