Deno: Resource Management Story

Created on 25 Jul 2018  Â·  11Comments  Â·  Source: denoland/deno

Hey,

Thinking about the I/O stuff made me realize that since we don't have defer in JavaScript and we have exceptions so:

func main() {
    f, err := os.Open("/tmp/test.txt")
    if err != nil {
      return
    }
    defer f.Close();
    n, err := fmt.Fprintln(f, "data")
    // skip checking n and `err` for brevity but code should
}

Could be:

async function main() {
   const f = await createFile('/tmp/test.txt'); // ignore API bikeshed
   try {
     const n = await f.write(data);
   } finally {
     f.close();
  }
}

Which unlike defer doesn't really stack up too nicely with multiple files:

async function main() {
   const f = await createFile('/tmp/test.txt');
   try {
     try {
       const f2 = await createFile('/tmp/test2.txt');
       await f.write('data');
       await f2.write('data'); // or Promise.all and do it concurrently
     } finally {
        f2.close(); // by the way - is this synchronous?
     }
   } finally {
     f.close();
  }
}

This sort of code is very hard to write correctly - especially if resources are acquired concurrently (we don't await before the first createFile finishes to do the second) and some of them fail.

We can do other stuff instead for resource management:

  • We can expose disposers for resource management.
  • We can expose a using function from deno and have a "real" resource" story.
  • Something else?

Here is what the above example looks like with exposing a using:

import { using } from 'deno' 

async function main() {
  await using(createFile('/tmp/test.txt'), createFile('/tmp/test2.txt'), async (test1, test2) => {
     // when the promise this returns resolves - both files are closed
  }); // can .catch here to handle errors or wrap with try/catch
}

We wrote some prior art in bluebird in here - C# also does this with using (I asked before defining using in bluebird and got an interesting perspective from Eric). Python has with and Java has try with resource.

Since this is a problem "browser TypeScript" doesn't really have commonly I suspect it will be a few years before a solution might come from TC39 if at all - issues were opened in TypeScript but it was deemed out of scope. Some CCs to get the discussion started:

  • @spion who worked on defer for bluebird coroutines and using with me.
  • @littledan for a TC39 reference and knowing if there is interest in solving this at a language level.
  • @1st1 who did the work on asynchronous contexts for Python

We also did some discussion in Node but there is no way I'm aware of for Node to do this nicely that won't be super risky - Deno seems like the perfect candidate for a safer API for resource management.

@ry if you prefer this as a PR with a concrete proposal for either approach let me know. Alternatively if you prefer the discussion to happen at a later future point let me know.

Most helpful comment

What do you think is the correct way to handle a context exit terminating?

Sorry, rereading that it wasn't clear - I'm asking what to do if __aexit__ throws an error when trying to clean up a resource where the async with raised an exception itself (so there are two exceptions).

Is there anything reasonable to do in this case?

Both synchronous and asynchronous context manager protocols work exactly the same in Python. The only difference is that synchronous protocol calls __enter__() and __exit__(), whereas asynchronous awaits on __aenter__() and __aexit__().

Now I'll try to showcase all error cases and explain how Python handles them using a synchronous protocol. Sorry if you know all of this stuff already!

For example, for the given code:

class Foo:
    def __enter__(self):
        1 / 0

    def __exit__(self, *e):
        print('exiting')

with Foo():
    pass

Python will output

Traceback (most recent call last):
  File "t.py", line 8, in <module>
    with Foo():
  File "t.py", line 3, in __enter__
    1 / 0
ZeroDivisionError: division by zero

I.e. any exception that happens while we are entering the context manager doesn't get intercepted by __exit__ or __aexit__ methods.


Now let's make the wrapped code to raise an error:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)

with Foo():
    1 / 0

Now the output is this:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10945cd48>)
Traceback (most recent call last):
  File "t.py", line 9, in <module>
    1 / 0
ZeroDivisionError: division by zero

So an exception has occurred, got passed to the __exit__ block, and was propagated after __exit__ has completed.


Now, if we add return True to our __exit__ method, the exception will be ignored:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        return True

with Foo():
    1 / 0

Running it will show you:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x107394d48>)

Now, we're getting to the meat of your question. Let's raise another error in __exit__:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        1 + 'aaa'

with Foo():
    1 / 0

Now we have:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10e03ddc8>)
Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
  File "t.py", line 7, in __exit__
    1 + 'aaa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

So another exception—TypeError—occurred while we were handling the original ZeroDivisionError. In cases like this, Python always propagates the latest exception, but it uses the special __context__ attribute on it to point to the other exception that was unhandled at that point. Therefore, Python is able to render the full report of what happened.


This mechanism duplicates the behaviour of how try..except works on Python:

try:
    1/0
except:
    1 + 'aa'

Will produce

Traceback (most recent call last):
  File "t.py", line 2, in <module>
    1/0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 4, in <module>
    1 + 'aa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Hopefully this explains how CMs work in Python (and I hope I correctly interpreted your question!) :)

All 11 comments

Here are some examples of how co.defer works in generators. Its hacky without first-class syntax:

https://github.com/petkaantonov/bluebird/commit/f944db753ff25e43561fb6c65a6664417b9892cd#diff-3129285757f0c44eca9973432b3bb15aR704

The Go example would look like this:

funcion* test() {
    let f = yield openFile("/tmp/test.txt");
    co.defer(() => f.close());
    n = yield fmt.Fprintln(f, "data")
    return n;
}

Its possible to patch typescript to make co.defer work with async/await when the target is ES6, but not sure if thats desireable.

It would be much nicer if we had official syntax for all this :grinning:

As we were able to get a hold of @1st1 who built this for Python in Europython (yay, thanks a ton @ztane for getting the hold!). I have some directed questions:

  • What were the most challenging problems and bugs people had with implementing types with __aenter__ and __aexit__ ?
  • Are you happy with the API you ended up with? Would you have changed it today?
  • If you are familiar with TypeScript and its gradual type system (which are pretty similar to Python's new _ish_ types) - are there any specific type safety concerns with disposers?
  • What do you think is the correct way to handle a context exit terminating?
  • Python traditionally had some GC reliant semantics (like closing generators on GC). What should Deno's behavior be in this regard?
  • What were the most challenging problems and bugs people had with implementing types with __aenter__ and __aexit__ ?

Hm, nothing comes to mind. I don't think people have any problems implementing asynchronous context managers in Python.

  • Are you happy with the API you ended up with? Would you have changed it today?

Yes, quite happy. It mirrors the API for synchronous context managers in Python:

  • with a calls a.__enter__() and a.__exit__()
  • async with a calls await a.__aenter__() and await a.__aexit__()

As for changing/extending the API—we don't have any issues with the current one, so no.

  • If you are familiar with TypeScript and its gradual type system (which are pretty similar to Python's new ish types) - are there any specific type safety concerns with disposers?

I can only answer for Python: no, there are no type safety concerns as far as I know.

  • What do you think is the correct way to handle a context exit terminating?

Could you please clarify this one?

  • Python traditionally had some GC reliant semantics (like closing generators on GC). What should Deno's behavior be in this regard?

In general we don't care about GC when we create (async-)context managers in Python. Both with o and async with o call the corresponding enter and exit methods automatically and deterministically. Of course it's possible to implement __del__ method on any object in Python to do extra cleanups on GC, but using it for context managers is rather unusual.

On the language side, there is ongoing work in this area in @rbuckton's using proposal. It just achieved Stage 1 in TC39. See https://github.com/tc39-transfer/proposal-using-statement for more information.

Awesome @littledan - @rbuckton reading that proposal I'm not sure it's safe in terms of resource management - especially with multiple async resources acquired - Consider reading the debates in https://github.com/petkaantonov/bluebird/issues/65 and the Python async context manager work mentioned above.

Basically - safety with multiple resources is very hard - so using is very hard to write correctly with the current proposal.

If there is better place to provide feedback please do let me know and I'd love to help.

@1st1 thanks a lot, really appreciate it!

Hm, nothing comes to mind. I don't think people have any problems implementing asynchronous context managers in Python.

That's awesome to hear, thanks!

Yes, quite happy. It mirrors the API for synchronous context managers in Python:

We don't have one for JavaScript yet - but acquire and exit semantics like with sound very reasonable - or even just exit semantics like C#.

I can only answer for Python: no, there are no type safety concerns as far as I know.

Thanks, very helpful

What do you think is the correct way to handle a context exit terminating?

Sorry, rereading that it wasn't clear - I'm asking what to do if __aexit__ throws an error when trying to clean up a resource where the async with raised an exception itself (so there are two exceptions).

Is there anything reasonable to do in this case?

but using it for context managers is rather unusual.

Good :)

@benjamingr I encourage you to file issues on that proposal repository--that will make your feedback more visible to everyone engaging in TC39.

What do you think is the correct way to handle a context exit terminating?

Sorry, rereading that it wasn't clear - I'm asking what to do if __aexit__ throws an error when trying to clean up a resource where the async with raised an exception itself (so there are two exceptions).

Is there anything reasonable to do in this case?

Both synchronous and asynchronous context manager protocols work exactly the same in Python. The only difference is that synchronous protocol calls __enter__() and __exit__(), whereas asynchronous awaits on __aenter__() and __aexit__().

Now I'll try to showcase all error cases and explain how Python handles them using a synchronous protocol. Sorry if you know all of this stuff already!

For example, for the given code:

class Foo:
    def __enter__(self):
        1 / 0

    def __exit__(self, *e):
        print('exiting')

with Foo():
    pass

Python will output

Traceback (most recent call last):
  File "t.py", line 8, in <module>
    with Foo():
  File "t.py", line 3, in __enter__
    1 / 0
ZeroDivisionError: division by zero

I.e. any exception that happens while we are entering the context manager doesn't get intercepted by __exit__ or __aexit__ methods.


Now let's make the wrapped code to raise an error:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)

with Foo():
    1 / 0

Now the output is this:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10945cd48>)
Traceback (most recent call last):
  File "t.py", line 9, in <module>
    1 / 0
ZeroDivisionError: division by zero

So an exception has occurred, got passed to the __exit__ block, and was propagated after __exit__ has completed.


Now, if we add return True to our __exit__ method, the exception will be ignored:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        return True

with Foo():
    1 / 0

Running it will show you:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x107394d48>)

Now, we're getting to the meat of your question. Let's raise another error in __exit__:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        1 + 'aaa'

with Foo():
    1 / 0

Now we have:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10e03ddc8>)
Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
  File "t.py", line 7, in __exit__
    1 + 'aaa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

So another exception—TypeError—occurred while we were handling the original ZeroDivisionError. In cases like this, Python always propagates the latest exception, but it uses the special __context__ attribute on it to point to the other exception that was unhandled at that point. Therefore, Python is able to render the full report of what happened.


This mechanism duplicates the behaviour of how try..except works on Python:

try:
    1/0
except:
    1 + 'aa'

Will produce

Traceback (most recent call last):
  File "t.py", line 2, in <module>
    1/0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 4, in <module>
    1 + 'aa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Hopefully this explains how CMs work in Python (and I hope I correctly interpreted your question!) :)

Hopefully this explains how CMs work in Python (and I hope I correctly interpreted your question!) :)

Thanks, that clears everything entirely. The analogy for us would be to attach a .source or .origin property to the raised inner exception in a using which makes sense.

I made a repo with python-like with statements for deno: https://github.com/hayd/deno-using

We cannot automatically clean up resources. They must be destroyed by the caller.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ry picture ry  Â·  3Comments

ry picture ry  Â·  3Comments

xueqingxiao picture xueqingxiao  Â·  3Comments

kyeotic picture kyeotic  Â·  3Comments

justjavac picture justjavac  Â·  3Comments