Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
44 changes: 6 additions & 38 deletions api/attributes/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@

from ayon_server.api.dependencies import AttributeName, CurrentUser
from ayon_server.api.responses import EmptyResponse
from ayon_server.api.system import require_server_restart
from ayon_server.attributes.models import (
AttributeModel,
AttributePatchModel,
AttributePutModel,
)
from ayon_server.attributes.validate_attribute_data import validate_attribute_data
from ayon_server.entities import ProjectEntity
from ayon_server.events import EventStream
from ayon_server.exceptions import ForbiddenException, NotFoundException
from ayon_server.lib.postgres import Postgres
from ayon_server.types import OPModel
Expand All @@ -39,8 +38,7 @@ class SetAttributeListModel(GetAttributeListModel):
async def save_attribute(attribute: AttributeModel) -> None:
"""Save attribute configuration to the database.

Additionally performs validation of the attribute data and updates
the enumerator in the running instance.
Additionally performs validation of the attribute data.
"""
query = """
INSERT INTO attributes
Expand All @@ -60,25 +58,6 @@ async def save_attribute(attribute: AttributeModel) -> None:
attribute.data.dict(exclude_none=True),
)

# TODO: The following code does not support horizontal scaling!!
# Notify other instances instead and reload the attribute library

if (enum := attribute.data.enum) is not None:
for name, field in ProjectEntity.model.attrib_model.__fields__.items():
if name != attribute.name:
continue

field_enum = field.field_info.extra.get("enum")
if field_enum is None:
continue
field_enum.clear()
field_enum.extend(enum)

for name, field in ProjectEntity.model.attrib_model.__fields__.items():
if name != attribute.name:
continue
field_enum = field.field_info.extra.get("enum")


async def list_raw_attributes() -> list[dict[str, Any]]:
"""Return a list of attributes as they are stored in the DB"""
Expand Down Expand Up @@ -153,7 +132,7 @@ async def set_attribute_list(
for attr in new_attributes:
await save_attribute(attr)

await require_server_restart()
await EventStream.dispatch("server.attributes_updated")
Comment thread
BigRoy marked this conversation as resolved.
return EmptyResponse()


Expand All @@ -180,9 +159,7 @@ async def set_attribute_config(
raise ForbiddenException("Only administrators are allowed to modify attributes")
attribute = AttributeModel(name=attribute_name, **payload.dict())
await save_attribute(attribute)
await require_server_restart(
None, "Restart the server to apply the attribute changes."
)
await EventStream.dispatch("server.attributes_updated")
return EmptyResponse()
Comment thread
BigRoy marked this conversation as resolved.


Expand All @@ -197,8 +174,6 @@ async def patch_attribute_config(
patch_payload = payload.dict(exclude_unset=True)
patch_data = patch_payload.pop("data", {})

requires_restart = False

if "scope" in patch_payload or any(
k in patch_data
for k in (
Expand All @@ -216,8 +191,6 @@ async def patch_attribute_config(
"inherit",
)
):
requires_restart = True

if not user.is_admin:
raise ForbiddenException(
"Only administrators are allowed to modify attribute configuration"
Expand All @@ -236,10 +209,7 @@ async def patch_attribute_config(

await save_attribute(attribute)

if requires_restart:
await require_server_restart(
None, "Restart the server to apply the attribute changes."
)
await EventStream.dispatch("server.attributes_updated")
return EmptyResponse()
Comment thread
BigRoy marked this conversation as resolved.


Expand All @@ -251,7 +221,5 @@ async def delete_attribute(
raise ForbiddenException("Only administrators are allowed to delete attributes")

await remove_attribute(attribute_name)
await require_server_restart(
None, "Restart the server to apply the attribute changes."
)
await EventStream.dispatch("server.attributes_updated")
return EmptyResponse()
Comment thread
BigRoy marked this conversation as resolved.
2 changes: 2 additions & 0 deletions ayon_server/api/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import semver

from ayon_server.addons import AddonLibrary
from ayon_server.entities.core import attribute_library
from ayon_server.api.frontend import init_frontend
from ayon_server.api.messaging import messaging
from ayon_server.api.static import addon_static_router
Expand Down Expand Up @@ -110,6 +111,7 @@ async def lifespan(app: "FastAPI"):
f.write(str(os.getpid()))

await ayon_init()
EventStream.subscribe("server.attributes_updated", attribute_library.reload_handler, True)
await load_access_groups()
await CloudUtils.clear_cloud_info_cache()

Expand Down
57 changes: 56 additions & 1 deletion ayon_server/entities/core/attrib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import collections
import functools
import threading
from typing import Any
from typing import Any, Callable

from ayon_server.lib.postgres import Postgres
from ayon_server.logging import logger
Expand All @@ -29,6 +29,8 @@ def __init__(self) -> None:
# in the same format as the attributes endpoint
self.info_data: list[Any] = []

self._invalidation_callbacks: list[Callable[[], None]] = []

# We need to load attribute data in a separate thread
# with a separate event loop, because the main event loop
# is already running and we cannot run another one
Expand Down Expand Up @@ -146,5 +148,58 @@ def by_name_scoped(self, entity_type: str, name: str) -> dict[str, Any]:
return attr
raise KeyError(f"Attribute {name} not found for entity type {entity_type}")

def register_invalidation_callback(self, callback: Callable[[], None]) -> None:
"""Register a callback to be called when attributes are reloaded."""
self._invalidation_callbacks.append(callback)

async def reload(self) -> None:
"""Reload attributes from the database and invalidate all cached models.

Fetches fresh data from the database, updates the in-memory attribute
lists in-place (preserving list object identity so ModelSet.attributes
references remain valid), and invalidates all cached Pydantic models
so they are regenerated on next access.
"""
query = "SELECT * FROM public.attributes ORDER BY position"
result = await Postgres.fetch(query)

Comment on lines +155 to +165

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AttributeLibrary.reload() can run concurrently for back-to-back server.attributes_updated events (global handlers are spawned via asyncio.create_task in api/messaging.py). Because reload() awaits the DB fetch, an earlier reload can finish after a later one and overwrite newer attribute data with a stale snapshot. Please serialize reloads (e.g., an async lock created inside reload() on first use, or a coalescing/debounce mechanism) so only one reload runs at a time and the latest state always wins.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle this? :)

# Build new data in a temporary structure first to minimize
# the window of inconsistency
new_data: collections.defaultdict[str, list[Any]] = collections.defaultdict(list)
new_info_data: list[Any] = []

Comment thread
BigRoy marked this conversation as resolved.
for row in result:
new_info_data.append(row)
for scope in row["scope"]:
attrd = {"name": row["name"], **row["data"]}
if (scope != "project") and ("default" in attrd):
del attrd["default"]
new_data[scope].append(attrd)

# Update self.data in-place to preserve list object identity.
# ModelSet instances hold direct references to these list objects,
# so we must mutate them rather than replace them.
all_scopes = set(self.data.keys()) | set(new_data.keys())
for scope in all_scopes:
self.data[scope].clear()
self.data[scope].extend(new_data.get(scope, []))

self.info_data = new_info_data

# Clear functools caches since the underlying data has changed
AttributeLibrary.inheritable_attributes.cache_clear()
AttributeLibrary.by_name.cache_clear()
AttributeLibrary.by_name_scoped.cache_clear()

# Invalidate all registered ModelSet Pydantic model caches
for callback in self._invalidation_callbacks:
callback()

logger.info("Attribute library reloaded")

async def reload_handler(self, event: Any = None) -> None:
"""Event handler adapter for reload(), for use with EventStream.subscribe."""
await self.reload()


attribute_library = AttributeLibrary()
14 changes: 14 additions & 0 deletions ayon_server/entities/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
version_fields,
workfile_fields,
)
from ayon_server.entities.core.attrib import attribute_library
from ayon_server.entities.models.generator import generate_model
from ayon_server.types import (
ENTITY_ID_EXAMPLE,
Expand Down Expand Up @@ -85,6 +86,19 @@ def __init__(
self._patch_model: type[BaseModel] | None = None
self._attrib_model: type[BaseModel] | None = None

attribute_library.register_invalidation_callback(self.invalidate)

def invalidate(self) -> None:
"""Invalidate all cached Pydantic models.

Forces regeneration of all cached models on next access. Called
by AttributeLibrary.reload() when attributes are updated live.
"""
self._attrib_model = None
self._model = None
self._post_model = None
self._patch_model = None

@property
def attrib_model(self) -> type[BaseModel]:
"""Return the attribute model."""
Expand Down
13 changes: 13 additions & 0 deletions ayon_server/events/default_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import TYPE_CHECKING

import aiocache

from ayon_server.lib.redis import Redis
from ayon_server.logging import logger

Expand All @@ -16,8 +18,19 @@ async def clear_settings_cache(event: "EventModel"):
await Redis.delete_ns("all-settings")


async def clear_attribute_info_cache(event: "EventModel"):
"""Clear the in-memory aiocache for the /info attributes response.

Called on all nodes via global hook so each instance flushes its own
local cache immediately when attributes are updated.
"""
logger.trace("Clearing attribute info cache")
await aiocache.caches.get("default").clear()
Comment on lines +21 to +28

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear_attribute_info_cache calls aiocache.caches.get("default").clear(), which flushes all in-process aiocache entries (permissions, actions, metrics, etc.), not just the /api/info attributes cache. This can cause avoidable cache stampedes/extra DB work after every attribute update. Consider invalidating only the get_attributes() cached key (e.g., by giving it an explicit key/namespace and deleting that key) or using a dedicated cache alias for /api/info data.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine for now?



DEFAULT_HOOKS: list[tuple[str, HandlerType, bool]] = [
("settings.changed", clear_settings_cache, False),
("bundle.created", clear_settings_cache, False),
("bundle.updated", clear_settings_cache, False),
("server.attributes_updated", clear_attribute_info_cache, True),
]
Loading