Yielding accessors and throwing callers

Years ago I discovered that the part of yielding accessors after the yield was not running when an error was thrown from the yielded-to code, and was alarmed… you might even say outraged. Now I notice that SE-474 specifies that the code after yield does run in that case, which would have satisfied my 2020 self.

The only problem: I have, since the late 1990s, been mistaken about the importance of cleanup. TLDR; When an error is thrown correct code is obliged to discard any partially-mutated state, so with two rare exceptions, trying to uphold invariants is misguided. For a more complete discussion, see here. I feel obliged to say something now just in case there are no reasons other than what I posted back in 2020 to run the post-yield code. I note also that running that code robs some accessors, like that of Dictionary, of commit-or-rollback semantics, because the ephemeral projected value will be written back even in the case of failure.

Fortunately for my fragile ego this means I'm far more right about the irrelevance of try marking than I thought previously. But I know everybody loves the sense of security they get from it, so I don't think that changes anything.

3 Likes

Previously I was on the other side regarding the explicit try annotations, indeed for the extra sense of security. But indeed, there seems to be not so much we can do about restoring invariants other than discarding mutated state.

There is one place which I found was not covered completely by your model, which is when state is external to the program, modified via side-effects. We cannot use a trick like dropping the mutated copy of a (possibly persistent) data structure because the “mutation” of the state happens inherently in-place (we cannot copy the whole world, at least for now).

An example is creating a set of files on disk (shader graph file + shader pipeline configuration file). The two files should either be both created, or if one fails to get created, the other one should be cleaned up. We could try surrounding everything with do/catch blocks, but as the number of possible failure points increases, we have to undo more and more stuff in reverse order after each operation has failed:

/// Creates 3 files or none. Returns true iff success.
func create3(){
   do { try createA() } catch {
      return false
   }

   do { try createB() } catch {
     try? deleteA()
   }

   do { try createC() } catch {
      try? deleteB()
      try? deleteA()
   }
}

We can notice a pattern here, and I don't think it is productive to use do/catch in functions with more than 1-2 possible possible failure points when the state is manipulated only through side-effects.

I wrote a composable transactional undo/redo library that covers this problem well (see full usage example and library implementation).

public struct SelectAndBringToFront: CompositeActor {
    public static func compositeExecute(
        instruction shapeID: ShapeID?, on model: inout Document,
        executor: inout CommandExecutor<Document>
    ) throws {
        // Arbitrary control flow is allowed...
        if let shapeID = shapeID {
            // First bring to front, then select
            _ = try executor.execute(
                command: BringToFrontCommand.self, instruction: shapeID, on: &model)
            _ = try executor.execute(
                command: SelectShapeCommand.self, instruction: [shapeID], on: &model)
        } else {
            // Just deselect if instruction is nil
            _ = try executor.execute(
                command: SelectShapeCommand.self, instruction: [], on: &model)
        }
    }
}

In this model, every possibly failing operation needs to be a command. Once a command is successfully executed, a Command object is created and added to a list inside executor. In case some command fails its execution by throwing an exception, the composite command's execute function catches that exception and undoes all previous successful operations in the list backwards. Then it rethrows the error so parent commands can also unwind, or the top-level executor can handle the error explicitly (e.g. logging).

Note that you can create a new top-level executor in any scope, so this technique can be applied anywhere locally, given that you implement your most pritive failing operations as an undoable command.

Maybe a language feature or some custom operators could make this even more usable.

Omitting the try keyword could work if all possibly failing side-effecting operations are only implemented in terms of undoable commands. Otherwise it's indeed too easy to forget about not handling the error. I would suggest that if you don't know how to undo a side-effecting operation, return a Result type, otherwise implement an undoable command.

What do you think, are there any use cases / error handling patterns that wouldn't fit this model?

You can create a persistent data structure on disk. See modern filesystems like ZFS, see Git, see databases. Your obligation is always to discard any partially mutated state, whether it's on disk or in memory. There's a reason none of these systems use an “undo” model for discarding state. As I've mentioned to you elsewhere, that model adds a lot of code and opportunities for errors, and adding transactionality hierarchically, below the level of the whole invariant you are trying to maintain, is costly with no benefit. And on disk, there's the problem that the undo operation can fail.

1 Like