Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions backend/src/baserow/api/renderers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from typing import Any, Iterator

from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import BaseSerializer


def _iter_serializer_outputs(data: Any) -> Iterator[BaseSerializer]:
"""
Yields the serializer of every `ReturnList`/`ReturnDict` anywhere in the response
data tree. Serializer outputs can sit at the top level, inside plain lists (e.g.
the job and application APIs), or in deeper mappings (e.g. the kanban and calendar
grouped-row responses).
"""

stack = [data]
seen = set()
while stack:
node = stack.pop()
if not isinstance(node, (dict, list, tuple)) or id(node) in seen:
continue
seen.add(id(node))
serializer = getattr(node, "serializer", None)
if serializer is not None:
yield serializer
stack.extend(node.values() if isinstance(node, dict) else node)


def _release_serializer_references(serializer: BaseSerializer) -> None:
"""
Severs the references a DRF serializer graph keeps to the serialized payload. The
serializer skeleton itself stays cyclic (that is inherent to DRF and small), but
`instance`, `_args`, `_kwargs`, `_data`, and `_context` pin the fetched model
instances, the serialized output, the request, and arbitrary context payloads,
which is where the actual memory sits.
"""

current = serializer
seen = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
instance_dict = getattr(current, "__dict__", None)
if not isinstance(instance_dict, dict):
break
for attribute in ("instance", "initial_data", "_data", "_validated_data"):
if attribute in instance_dict:
instance_dict[attribute] = None
if "_args" in instance_dict:
instance_dict["_args"] = ()
for attribute in ("_kwargs", "_context"):
if attribute in instance_dict:
instance_dict[attribute] = {}
current = instance_dict.get("child")


class BaserowJSONRenderer(JSONRenderer):
"""
DRF creates several reference cycles for every request (see
https://github.com/encode/django-rest-framework/issues/7250, acknowledged
upstream but never fixed):

- `Serializer.data` returns a `ReturnList`/`ReturnDict` with a `serializer`
backreference, while the serializer keeps the same object in `_data`.
- `Field.bind` sets `field.parent`, and `BindingDict` keeps a `serializer`
backreference, so a serializer and its fields always form cycles, pinning
`serializer.instance` and the `_args`/`_kwargs` stashed by `Field.__new__`
(which contain the full page of fetched model instances).
- `APIView.dispatch` sets `view.response`, and `finalize_response` puts the
view, request, and the response itself into `response.renderer_context`,
so the response, the rendered content in `response._container`, and the
request always form cycles.

Because of those cycles, the per-request object graph (fetched rows, serialized
data, rendered bytes) can never be freed by reference counting; it lingers until
Python's cyclic garbage collector runs a full pass, which is allocation-triggered
and therefore never happens on an idle worker. For large responses that looks like
a memory leak.

The `serializer` backreference and the renderer context exist purely so that
renderers can inspect them while rendering. When this renderer produces the final
response it is their last consumer, so after producing the bytes it severs these
references, making the payload reference-count collectable the moment the handler
drops the response. The cleanup is skipped when another renderer (e.g. the
browsable API) delegates to this render and still needs the context afterwards.
"""

def render(self, data, accepted_media_type=None, renderer_context=None):
rendered = super().render(data, accepted_media_type, renderer_context)

response = (
renderer_context.get("response")
if isinstance(renderer_context, dict)
else None
)
if response is None or getattr(response, "accepted_renderer", None) is not self:
return rendered

for serializer in _iter_serializer_outputs(data):
_release_serializer_references(serializer)

view = renderer_context.get("view")
if view is not None:
view.response = None
view.request = None
view.args = ()
view.kwargs = {}
renderer_context.clear()
response.renderer_context = None

return rendered
8 changes: 6 additions & 2 deletions backend/src/baserow/config/asgi.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import django
from django.conf import settings
from django.core.asgi import get_asgi_application
from django.urls import re_path

from channels.routing import ProtocolTypeRouter, URLRouter

from baserow.config.helpers import (
BaserowASGIHandler,
ConcurrencyLimiterASGI,
check_lazy_loaded_libraries,
log_env_warnings,
Expand All @@ -16,7 +17,10 @@
# The telemetry instrumentation library setup needs to run prior to django's setup.
setup_telemetry(add_django_instrumentation=True)

django_asgi_app = get_asgi_application()
# Same as django.core.asgi.get_asgi_application, but with Baserow's ASGI handler that
# doesn't keep per-request memory alive in reference cycles.
django.setup(set_prefix=False)
django_asgi_app = BaserowASGIHandler()

# Check that libraries meant to be lazy-loaded haven't been imported at startup.
# This runs after Django is fully loaded, so it catches imports from all apps.
Expand Down
25 changes: 25 additions & 0 deletions backend/src/baserow/config/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys

from django.conf import settings
from django.core.handlers.asgi import ASGIHandler

from loguru import logger

Expand Down Expand Up @@ -58,6 +59,30 @@ async def __aexit__(self, exc_type, exc, traceback):
pass


class BaserowASGIHandler(ASGIHandler):
"""
Django's disconnect watcher raises `RequestAborted`, whose traceback ends up in a
reference cycle with the `handle` frame that pins the request and response in
memory until a full garbage collection pass. On Django 5.2 completing the watcher
normally is behavior-identical (`handle` ignores the result and cancels the
in-flight request via `asyncio.wait`), and no cycle is created.

Streaming responses are not covered: on mid-stream disconnect an iterator that
references the view can still keep the response in a cycle.

TODO: re-evaluate before Django >= 6.1, where only a raising watcher aborts the
in-flight request. `test_handle_cancels_in_flight_request_on_disconnect` fails
loudly in that case.
"""

async def listen_for_disconnect(self, receive):
message = await receive()
if message["type"] == "http.disconnect":
return
# This should never happen.
assert False, "Invalid ASGI message after request body: %s" % message["type"]


class ConcurrencyLimiterASGI:
"""
Helper wrapper on ASGI app to limit the number of requests handled
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@
"baserow.api.user_sources.authentication.UserSourceJSONWebTokenAuthentication",
"baserow.api.authentication.JSONWebTokenAuthentication",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_RENDERER_CLASSES": ("baserow.api.renderers.BaserowJSONRenderer",),
"DEFAULT_SCHEMA_CLASS": "baserow.api.openapi.AutoSchema",
}

Expand Down
146 changes: 146 additions & 0 deletions backend/tests/baserow/api/test_renderers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import gc
import weakref

from django.shortcuts import reverse

import pytest
from rest_framework import serializers
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.status import HTTP_200_OK

from baserow.api.renderers import BaserowJSONRenderer


@pytest.mark.django_db
def test_rendered_response_graph_is_reference_count_collectable(
api_client, data_fixture
):
"""
The BaserowJSONRenderer must sever the DRF reference cycles after rendering,
so that the whole per-request payload (fetched rows, serialized data, rendered
content) is freed by reference counting alone, without depending on a cyclic
garbage collection pass. This is what keeps the worker memory flat when large
values are listed.
"""

user, jwt_token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_text_field(table=table, name="Notes")
table.get_model().objects.create(**{field.db_column: "value"})

url = reverse("api:database:rows:list", kwargs={"table_id": table.id})

gc.collect()
gc.disable()
try:
response = api_client.get(url, HTTP_AUTHORIZATION=f"JWT {jwt_token}")
assert response.status_code == HTTP_200_OK

# The renderer must have cleared the cyclic renderer context, while the
# public response API stays usable.
assert response.renderer_context is None
results = response.data["results"]
assert results[0][f"field_{field.id}"] == "value"

# The serializer graph must no longer pin the fetched rows or the
# serialized output.
serializer = results.serializer
assert serializer.instance is None
assert serializer._data is None
assert serializer.child._args == ()

# The Django test client attaches `response.json = partial(...,
# response)`, a self-cycle that only exists in tests, not in the real
# request path, and would mask the behavior being verified here.
response.__dict__.pop("json", None)

response_ref = weakref.ref(response)
results_ref = weakref.ref(results)
del response, results, serializer

# The garbage collector is disabled, so these can only be dead if the
# object graph was freed by pure reference counting.
assert response_ref() is None
assert results_ref() is None
finally:
gc.enable()


def test_renderer_skips_cleanup_when_another_renderer_delegates_to_it():
class FakeView:
response = "sentinel-response"
request = "sentinel-request"

class FakeResponse:
accepted_renderer = BrowsableAPIRenderer()

view = FakeView()
response = FakeResponse()
renderer_context = {"view": view, "request": "request", "response": response}

BaserowJSONRenderer().render({"a": 1}, "application/json", renderer_context)

assert renderer_context["request"] == "request"
assert view.response == "sentinel-response"
assert view.request == "sentinel-request"


class _NameSerializer(serializers.Serializer):
name = serializers.CharField()


@pytest.mark.parametrize(
"wrap_data",
[
# Serializer output inside an ordinary list, e.g. the job and
# application APIs.
lambda data: [data],
# The kanban/calendar grouped-row shape.
lambda data: {"rows": {"1": {"count": 1, "results": data}}},
],
)
def test_renderer_releases_nested_serializer_outputs(wrap_data):
serializer = _NameSerializer(instance=[{"name": "a"}], many=True)
data = serializer.data

renderer = BaserowJSONRenderer()

class FakeResponse:
accepted_renderer = renderer

renderer.render(
wrap_data(data),
"application/json",
{"response": FakeResponse()},
)

assert serializer.instance is None
assert serializer._data is None
assert serializer.child._args == ()


def test_renderer_releases_serializer_context():
class Marker:
pass

marker = Marker()
serializer = _NameSerializer(
instance=[{"name": "a"}], many=True, context={"marker": marker}
)
data = serializer.data

renderer = BaserowJSONRenderer()

class FakeResponse:
accepted_renderer = renderer

renderer.render(data, "application/json", {"response": FakeResponse()})

marker_ref = weakref.ref(marker)
gc.disable()
try:
del marker
# Only dead without gc if the serializer no longer holds the context.
assert marker_ref() is None
finally:
gc.enable()
Loading
Loading