-
Notifications
You must be signed in to change notification settings - Fork 22
feat(runtime-sdk): Add serialize option in WSGI entrypoint
#240
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
base: main
Are you sure you want to change the base?
Changes from all commits
d524fb6
1f7480d
da586e8
19084ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import asyncio | ||
| from weakref import ReferenceType, WeakKeyDictionary, ref | ||
|
|
||
| # Use weakref so that event loops can be garbage collected | ||
| # when they are no longer in use. | ||
| _locks: WeakKeyDictionary[asyncio.AbstractEventLoop, ReferenceType[asyncio.Lock]] = ( | ||
| WeakKeyDictionary() | ||
| ) | ||
|
|
||
|
|
||
| def _get_lock() -> asyncio.Lock: | ||
| """Return the serialization lock for the currently running event loop.""" | ||
| loop = asyncio.get_running_loop() | ||
| lock_ref = _locks.get(loop) | ||
| lock = lock_ref() if lock_ref is not None else None | ||
| if lock is None: | ||
| lock = asyncio.Lock() | ||
| _locks[loop] = ref(lock) | ||
| return lock | ||
|
|
||
|
|
||
| class RequestLock: | ||
| """Manage one request's ownership of the shared serialization lock.""" | ||
|
|
||
| def __init__(self, enabled: bool) -> None: | ||
| self._enabled = enabled | ||
| self._lock: asyncio.Lock | None = None | ||
| self._release_on_exit = True | ||
|
|
||
| async def __aenter__(self) -> "RequestLock": | ||
| if self._enabled: | ||
| self._lock = _get_lock() | ||
| await self._lock.acquire() | ||
| return self | ||
|
|
||
| async def __aexit__(self, _exc_type, _exc, _traceback) -> None: | ||
| if not self._enabled: | ||
| return | ||
| if self._release_on_exit: | ||
| self.release() | ||
|
|
||
| def defer_release(self) -> None: | ||
| """ | ||
| When there are background streams that need to be processed, | ||
| we need to defer the release of the lock until the streams are done. | ||
| """ | ||
| if not self._enabled: | ||
| return | ||
|
|
||
| self._release_on_exit = False | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't we need to set this back to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay I get it, RequestLock is only used once. |
||
|
|
||
| def release(self) -> None: | ||
| """Release this request's lock once.""" | ||
| if not self._enabled: | ||
| return | ||
|
|
||
| lock = self._lock | ||
| if lock is not None: | ||
| self._lock = None | ||
| lock.release() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import js | ||
|
|
||
| from workers import Context, Request, WorkerEntrypoint | ||
| from workers._serialize import RequestLock | ||
|
|
||
| logger = logging.getLogger("wsgi") | ||
| NULL_BODY_STATUSES = frozenset({101, 103, 204, 205, 304}) | ||
|
|
@@ -291,6 +292,7 @@ def process_request( | |
| req: "Request | js.Request", | ||
| env: Any, | ||
| body: "bytes | io.IOBase", | ||
| on_close: "Callable[[], None] | None" = None, | ||
| ) -> js.Response: | ||
| environ = build_environ(req, env, body) | ||
|
|
||
|
|
@@ -325,11 +327,16 @@ def start_response(status, response_headers, exc_info=None): | |
| result_iter = iter(result) | ||
|
|
||
| def close_all() -> None: | ||
| _close_iterable(result) | ||
| try: | ||
| environ["wsgi.input"].close() | ||
| except Exception: # noqa: BLE001 - best-effort cleanup | ||
| logger.exception("Failed to close wsgi.input") | ||
| _close_iterable(result) | ||
| finally: | ||
| try: | ||
| environ["wsgi.input"].close() | ||
| except Exception: # noqa: BLE001 - best-effort cleanup | ||
| logger.exception("Failed to close wsgi.input") | ||
| finally: | ||
| if on_close is not None: | ||
| on_close() | ||
|
Comment on lines
+331
to
+339
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slightly confusing here with the nested try/finallys. So what we do is:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It occurs to me that one general concern is if we have: try:
do_something()
finally:
cleanup()it's possible that run_finally = True
try:
do_something()
except BaseException as e:
# prevent finally block from preempting BaseExceptions
run_finally = isinstance(e, Exception)
raise
finally:
if run_finally:
cleanup() |
||
|
|
||
| # WSGI apps must call start_response before yielding the first body chunk, | ||
| # but some defer it until the first non-empty chunk is produced. Pull that | ||
|
|
@@ -367,25 +374,36 @@ async def fetch( | |
| env: Any, | ||
| # Accepted for parity with asgi.fetch; WSGI has no use for it. | ||
| ctx: Context | None = None, | ||
| *, | ||
| serialize: bool = False, | ||
| ) -> js.Response: | ||
| logger.debug("WSGI request: %s %s", req.method, req.url) | ||
| # Prefer lazily streaming the body through `wsgi.input` (no full buffering); | ||
| # fall back to pre-buffering when `run_sync`/JSPI isn't available. | ||
| body: bytes | io.IOBase | None = _make_wsgi_input(req) | ||
| if body is None: | ||
| body = await _read_body(req) | ||
| try: | ||
| return process_request(app, req, env, body) | ||
| except Exception: | ||
| logger.exception("WSGI request failed") | ||
| raise | ||
|
|
||
|
|
||
| def entrypoint(app: Any) -> type[WorkerEntrypoint]: | ||
| async with RequestLock(serialize) as request_lock: | ||
| try: | ||
| # Prefer lazily streaming the body through `wsgi.input` (no full buffering); | ||
| # fall back to pre-buffering when `run_sync`/JSPI isn't available. | ||
| body: bytes | io.IOBase | None = _make_wsgi_input(req) | ||
| if body is None: | ||
| body = await _read_body(req) | ||
| response = process_request( | ||
| app, | ||
| req, | ||
| env, | ||
| body, | ||
| on_close=request_lock.release, | ||
| ) | ||
| request_lock.defer_release() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we unconditionally call
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe could use a comment. |
||
| return response | ||
| except Exception: | ||
| logger.exception("WSGI request failed") | ||
| raise | ||
|
|
||
|
|
||
| def entrypoint(app: Any, *, serialize: bool = False) -> type[WorkerEntrypoint]: | ||
| """Create the default Worker entrypoint for a WSGI application.""" | ||
|
|
||
| class Default(WorkerEntrypoint): | ||
| async def fetch(self, request): | ||
| return await fetch(app, request, self.env) | ||
| return await fetch(app, request, self.env, serialize=serialize) | ||
|
|
||
| return Default | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps this should be called
MaybeRequestLock? We could get rid of all theif self._enabled:guards by doing: