1

I have the following scenario:

  • I want to delete a row
  • There may be a number of rows in the same table that reference that row. If a row is deleted, all the other rows that reference is need to be updated with either a different value or null to avoid "dangling pointers" in the DB
  • If either the delete or any of the updates go wrong for whatever reason, I need to rollback the DB to the original state

First and foremost, Postgres doesn't support nested transactions

My initial take was something the lines of this, using checkpoints

      postgres.use { s =>
            s.prepare(updateGenreParent).flatMap { ps =>
              s.transaction.use { t =>
                me.headOption match {
                  case None => throw UnknownGenreError
                  case Some(dg) =>
                    dg.parent match {
                      case None => ???
                      case Some(p) => // Node is middle, realign the tree
                        val savepoint = t.savepoint
                        myChildren
                          .traverse(c => {
                            for {
                              _  <- Logger[F].println("Updating parent for " + c.uuid + " With " + p)
                              sp <- savepoint
                              _ <- ps.execute(Some(GenreParentId(p.value)) ~ c.toplevel ~ c.uuid).recoverWith {
                                case e: Exception =>
                                  Logger[F].println(e) *> t.rollback(sp)
                              }
                            } yield ()
                          })
                          .flatMap(r => {
                            s.prepare(deleteById).flatMap { ps2 =>
                              savepoint.flatMap(
                                sp =>
                                  ps2.execute(id).as(id).recoverWith {
                                    case e: Exception =>
                                      Logger[F].println(e) *> t.rollback(sp) *> ApplicativeError[F, Throwable]
                                        .raiseError(e)
                                  }
                              )
                            }
                          })
                    }
                }
              }
            }
          }
        }

I can confirm this updates and deletes as needed however I have no proof that the rollback does what it is supposed to. In the Skunk documentation, the basic rollback is demonstrated using constraints which isn't a great fit for this table or a delete query. I would somehow need to make postgres unreachable after the updates but before the delete.

In the Typelevel discord, it was suggested that having nested prepare/transaction statements is not a good idea, as it interleaves unrelated logic, which can cause some trouble. So essentially,

 s.prepare(updateGenreParent).flatMap { ps =>
    s.transaction.use { t =>
      val savepoint = t.savepoint
      ....... 
      s.prepare(deleteById).flatMap { ps2 => .... restore(savepoint)... }
    }
 }

Is not recommended. Normally one would chain the transactions with flatMap, but that results in the delete query losing track of the original savepoint.

So my question is what is the right way of performing multiple rollbacks like this in Scala + Cats + Skunk ?

The only other option I can think of is keeping track of all the changes that have been made and creating a retry loop with something like Defer to manually update all the rows to their original state. This of course creates a small loop as now you have to worry about those transactions failing and so on.

EDIT: Postgres logs from the query

2023-05-17 23:05:10.535 EDT [46120] LOG:  statement: RESET ALL
2023-05-17 23:05:10.716 EDT [46120] LOG:  statement: BEGIN
2023-05-17 23:05:10.799 EDT [46120] LOG:  statement: SAVEPOINT savepoint_6
2023-05-17 23:05:10.882 EDT [46120] LOG:  execute statement_5/portal_7:
              UPDATE genres
              SET parent_id=$1, top_level=$2
              WHERE uuid=$3

2023-05-17 23:05:10.882 EDT [46120] DETAIL:  parameters: $1 = NULL, $2 = NULL, $3 = 'b82fb0c0-a8d4-434e-849b-f026ff198713'
2023-05-17 23:05:10.947 EDT [46120] LOG:  execute statement_5/portal_9:
              UPDATE genres
              SET parent_id=$1, top_level=$2
              WHERE uuid=$3

2023-05-17 23:05:10.947 EDT [46120] DETAIL:  parameters: $1 = NULL, $2 = NULL, $3 = 'b82fb0c0-a8d4-434e-849b-f026ff198713'
2023-05-17 23:05:10.977 EDT [46120] LOG:  execute statement_5/portal_11:
              UPDATE genres
              SET parent_id=$1, top_level=$2
              WHERE uuid=$3

2023-05-17 23:05:10.977 EDT [46120] DETAIL:  parameters: $1 = 'b82fb0c0-a8d4-434e-849b-f026ff198713', $2 = 'b82fb0c0-a8d4-434e-849b-f026ff198713', $3 = 'e9bce582-d8e8-485c-a01d-04cee2cd26ca'
2023-05-17 23:05:11.082 EDT [46120] LOG:  execute statement_12/portal_14: DELETE FROM genres WHERE uuid = $1
2023-05-17 23:05:11.082 EDT [46120] DETAIL:  parameters: $1 = '467005e0-84fd-4729-a5cc-6fe1cd75787b'
2023-05-17 23:05:11.098 EDT [46120] LOG:  statement: COMMIT
2023-05-17 23:05:11.130 EDT [46120] LOG:  statement: UNLISTEN *
2023-05-17 23:05:11.146 EDT [46120] LOG:  statement: RESET ALL

sinanspd
  • 2,589
  • 3
  • 19
  • 37
  • 1
    It's not clear to me why this isn't just one transaction. You say you want to restore things to the original state if anything goes wrong, well that's what a transaction will do. It also sounds like you are trying to reimplement foreign keys in your own code which seems unlikely to be a wise choice. – Richard Huxton May 17 '23 at 06:07
  • @RichardHuxton part of the question was regarding the internals of the `skunk` library and whether the `prepare` function results in a new transaction or appends the previous. That being said, I added the postgres logs to the question, looking at them and how there is one `commit` at the end, I do believe this is one transaction already. But please correct me if I am wrong – sinanspd May 18 '23 at 06:10
  • Regarding the foreign keys, my knowledge of postgres is limited so please excuse any misconception. I believe what you are referring to is to create a self-referencial foreign key that would basically prevent the `delete` from executing if the `parent` references aren't successfully updated? This is a great suggestion and I will implement it, however afaik the foreign key won't help with the actual updates or the rollbacks. Is my understand in that regard correct ? – sinanspd May 18 '23 at 06:12
  • One BEGIN+COMMIT=one transaction yes, and that SAVEPOINT is useless since it is right after the BEGIN. If you can get rid of it do so, they aren't free. Foreign keys can cascade on updates too, but I can't tell if that is what you are trying to do. It isn't clear to me what sensible action (other than rolling back the entire transaction) you want to take if one of your updates fails (presumably due to you having a bad id or something). – Richard Huxton May 18 '23 at 06:38
  • @RichardHuxton thank you so much for the input. I wanted to see if the savepoint is sound & apparently not. `BEGIN` etc. are generated by the library so I'll have to play around a bit to figure out how to move it outside the BEGIN block. RE: Foreign key. I am reading a bit about `UPDATE CASCADE` and it doesn't seem to be good fit imo. What happens on `DELETE parent` is `for all CHILD, CHILD.PARENT = PARENT.PARENT`. Think of it as removing a node from a binary tree & I don't think I can signal a custom action to `UPDATE`. So I think rolling back the entire thing on failure is all I can hope for – sinanspd May 18 '23 at 06:47
  • Moving it outside the transaction doesn't make any sense. Perhaps find a book you like on PostgreSQL/databases and get the basics. You could do the custom behaviour you want with triggers, but it might be more understandable just to let the foreign-keys prevent deletion of a node that still has any children. – Richard Huxton May 18 '23 at 06:50
  • Alright thank you. I will read a bit more about checkpointing. If you would like to move your comment about the checkpoint + triggers to an answer, I am happy to accept it as it addresses everything in the question. – sinanspd May 18 '23 at 06:53
  • not sure it really counts as an answer - maybe once you're happy with your setup you can post details as an answer – Richard Huxton May 18 '23 at 07:23

0 Answers0