Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Code review question] How to do teardown? #37

Open
sonthonaxrk opened this issue Jun 9, 2019 · 5 comments
Open

[Code review question] How to do teardown? #37

sonthonaxrk opened this issue Jun 9, 2019 · 5 comments

Comments

@sonthonaxrk
Copy link

sonthonaxrk commented Jun 9, 2019

Thanks for this project. I'm evaluating this as a way of managing dependencies in this large flask application I'm refactoring.

How would one manage cleanup of threadlocals, when you have to release things manually. In SQLAlchemy is this required, or anything that uses a threadpool.

I've made a working attempt (based off your example), but I'm not sure if it's the right direction.

class SQLAlchemyModule(Module):
    def __init__(self, app):
        self.app = app

    def configure(self, binder):
        db = self.configure_db()
        binder.bind(Session, to=self.create_session, scope=request_scope)

    def configure_db(self):
        connection_string = (
            'postgresql+psycopg2://{user}@{host}/{db}'
        ).format(
            user=self.app.config['PGSQL_USER'],
            host=self.app.config['PGSQL_HOST'],
            db=self.app.config['PGSQL_DB'],
        )

        self.engine = create_engine(
            connection_string,
            convert_unicode=True,
        )

        self.Session = scoped_session(sessionmaker(bind=self.engine))
        self.app.teardown_request(cleanup_session)

    def create_session(self) -> Session:
        return self.Session()

    def destroy_session(self, *args):
        self.Session.remove()

Is there anything I could do to make this class more idiomatic with Injector?

What I think would be really lovely, instead of binding to a function, you could bind to something with a yield statement.

So create_session would become

    @contextmanager
    def create_session(self) -> Session:
    try:
         yield self.Session()
    finally:
        self.Session.close()
@jstasiak
Copy link
Collaborator

jstasiak commented Jun 9, 2019

Your ideal scenario (the one with yield statement) looks interesting and I think we should explore this, but for now the code you pasted above is the best one can do I believe (I've used something like that before, it's not elegant but it does the job, so...).

@sonthonaxrk
Copy link
Author

@jstasiak

I made a stab at something that could do the job.

class ContextualProviderWrapper(CachedProviderWrapper):
    def __del__(self):
        for value in self._cache.values():
            if inspect.isgenerator(value):
                try:
                    next(value)
                except StopIteration:
                    pass

    def get(self, injector):
        key = id(injector)
        instance = self._old_provider.get(injector)
        if key not in self._cache:
            self._cache[key] = next(instance())

        return self._cache[key]

        
class ContextualRequestScope(RequestScope):
    def get(self, key, provider):
        try:
            return self._locals.scope[key]
        except KeyError:
            new_provider = ContextualProviderWrapper(provider)
            self._locals.scope[key] = new_provider
            return new_provider


contextual_request_scope = ScopeDecorator(ContextualRequestScope)

def setup_teardown():
     # set up
     yield
     # tear down

def configure(binder)
       binder.bind(
            bind_key,
            to=lambda: setup_teardown,
            scope=contextual_request_scope
        )

Works for me. But I'd love your review :)

@jstasiak
Copy link
Collaborator

jstasiak commented Jun 13, 2019

Seems like a nice little hack :)

Two notes to self:

  • I don't even remember what the CachedProviderWrapper thing is for and why I added it, I need to find out and either document or remove it
  • The Scope and Binder classes need some type hints so that it's clear what's what

As for your solution – my main concerns here would be:

  • Tying handling of generator-based providers to particular scope class
  • Nondeterministic cleanup timing (__del__ can be called at arbitrary point in the future)
  • I believe __del__ can be called effectively by any thread in your application (it'll be the one that triggers a garbage collection step), and that will be an issue as it won't operate on the correct thread-local storage.

While I believe a general-purpose scope-cleanup mechanism would be nice we're not there yet I'm afraid. I don't think cleaning things up manually here is too terrible all things considered.

@sonthonaxrk
Copy link
Author

sonthonaxrk commented Jun 16, 2019

The cached CachedProviderWrapper is there to prevent multiple calls to app.injector.get() from calling the bound function multiple times.

I'll try and add type hints.

About your concerns

Tying handling of generator-based providers to particular scope class

So we could use inspect.isgenerator to decide weather to treat the provider as a generator. This would give the user the flexibility to use either a yield or return.

Nondeterministic cleanup timing (del can be called at arbitrary point in the future)

I think this is deterministically bound to the lifespan werkzurg locals, and is called when werkzurg releases them. However I could invoke a cleanup function manually. I agree it's a bit of a codesmell.


If I submit a PR with these changes would you be happy to merge it?

@jstasiak
Copy link
Collaborator

The note about type hints was for myself – I need to add them (the relevant ones) to injector first and to flask_injector afterwards, it's a bit of a spaghetti situation, don't worry about it.

I don't think __del__ will be called deterministically in all scenarios – GC is nondeterministic on PyPy and, if an object is a part of a reference cycle, on CPython as well. The request scope's cleanup method is called on request teardown though, so you use something like:

class CleaningUpRequestScope(RequestScope):
    def cleanup(self) -> None:
        # run cleanup generators here
        super().cleanup()
    def get(self, key, provider):
        # save cleanup generators somewhere here
        # ...

# ...

FlaskInjector(request_scope_class=CleaningUpRequestScope)

This will give you full determinism.

I'm unlikely to accept a PR with this – among other things because passing a callable returning a generator to Binder.bind is violating its interface (once I add type hints there it'll be an explicit error if tool like mypy is used) and only working thanks to a custom scope. And changing Binder.bind interface to allow this would mean an injector-wide solution would need to be provided, which I don't think is necessarily practical or desired anyway.

Also – cleaning things up seems so application-specific that I wouldn't necessarily want to involve a dependency injection library with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants