Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
e9017c7
i18n: update and improve it-IT (#30297)
andreadegiovine Sep 21, 2026
7eebbbb
fix: stop the MinerU API key blocking a save in local mode (#30299)
silentoplayz Sep 21, 2026
9012bd1
fix: stop sending count to the Staan search API (#30303)
Classic298 Sep 21, 2026
aa65f55
fix: insert the whole emoji when its shortcode is a codepoint sequenc…
silentoplayz Sep 21, 2026
5399e39
fix: enforce the account form's required fields the way the admin use…
silentoplayz Sep 21, 2026
f16e9ea
feat: fall back to an available model when a chat's models are gone (…
Classic298 Sep 21, 2026
f5967ae
fix: restore chat input draft after reload in chats started from the …
Classic298 Sep 21, 2026
c2f11ed
fix: keep a workspace model's base model when cloning it from the adm…
silentoplayz Sep 21, 2026
dc4f5da
refac
tjbck Sep 21, 2026
7f703dc
refac
tjbck Sep 21, 2026
440d13e
fix: refresh the prompt editor after a version is set as production (…
silentoplayz Sep 21, 2026
f40318c
fix: keep the live prompt unchanged when a version is saved without S…
silentoplayz Sep 21, 2026
d9ea46f
fix: hide Insert into note on a note the viewer may only read (#30223)
silentoplayz Sep 21, 2026
7fa8673
fix: resolve this instance's own file URLs in edit_image regardless o…
Classic298 Sep 21, 2026
0180efe
refac
tjbck Sep 21, 2026
438d9db
fix: correct recurrence rule parsing for schedules and calendar event…
Classic298 Sep 21, 2026
6fb68e4
refac(images): request an unencoded body when fetching remote chat im…
Classic298 Sep 21, 2026
488b3c5
perf: stop note co-editing echoing every remote update back to the se…
Classic298 Sep 21, 2026
5fb869d
refac
tjbck Sep 21, 2026
49b2550
perf: stop round-tripping the whole chat to read or write one message…
Classic298 Sep 21, 2026
aceda89
perf: serve the shared model pool from a per-worker cache (#28176)
Classic298 Sep 21, 2026
a9541c1
refac
tjbck Sep 21, 2026
bc50026
fix: make the Delete Chat shortcut work whenever a chat is open, not …
silentoplayz Sep 21, 2026
c864e3a
fix: stop asking for chat variables a model's system prompt no longer…
silentoplayz Sep 21, 2026
754c4b5
refac
tjbck Sep 21, 2026
e8bd066
refac
tjbck Sep 21, 2026
9688d32
fix: keep the background image chosen while creating a folder from th…
silentoplayz Sep 21, 2026
94eea41
fix: surface searchapi errors, news results and redirect links (#30308)
Classic298 Sep 21, 2026
3fc1146
refac
tjbck Sep 21, 2026
419d248
refac: check the timer owner's role before running a due timer (#30220)
Classic298 Sep 21, 2026
30881db
refac
tjbck Sep 21, 2026
478d178
refac
tjbck Sep 21, 2026
e8c26f8
fix: stop the background memory review when memory is switched off (#…
Classic298 Sep 21, 2026
9fba284
refac
tjbck Sep 21, 2026
4b61b86
fix: apply the knowledge File content filter on the first click (#30211)
silentoplayz Sep 21, 2026
b988f06
fix: drop every local reference to a channel message that was deleted…
silentoplayz Sep 21, 2026
bf47645
chore: format
tjbck Sep 21, 2026
c07fa08
refac
tjbck Sep 21, 2026
ee3ece1
refac
tjbck Sep 21, 2026
46ea826
refac: keep rendered diagrams and SVG on same-origin resources (#30271)
Classic298 Sep 21, 2026
9530cc1
i18n: restore placeholder names that were translated or lost their br…
Na5co Sep 21, 2026
33df4a7
chore: changelog (#29874)
Classic298 Sep 21, 2026
ca75b4d
refac
tjbck Sep 21, 2026
344ea53
doc: changelog
tjbck Sep 21, 2026
fecbeac
Merge pull request #30354 from Classic298/i18n-en-us-fallback
Classic298 Sep 22, 2026
6f6792d
fix: pass MCP tool images to the model, not only to the UI (#30358)
Classic298 Sep 22, 2026
f956f7a
fix: send only the file name to Docling instead of the full storage p…
Classic298 Sep 22, 2026
6b36f62
fix: stop KeyError 'model' traceback on every new chat from initial t…
Classic298 Sep 22, 2026
0a2e9a4
fix: detect the real image type of bare base64 generated images (#30359)
Classic298 Sep 22, 2026
69fcad0
i18n: complete Indonesian (id-ID) translations (#30328)
DarRahman Sep 22, 2026
845b791
i18n: translate Persian counters and preserve prompt variable (#30329)
MiRHaDi Sep 22, 2026
f2702e1
fix: fall back to the user's system prompt when the chat's own is cle…
silentoplayz Sep 22, 2026
e93a59f
refac
tjbck Sep 22, 2026
fe56ab2
fix: page Chroma get() so hybrid search works on collections over 32k…
Classic298 Sep 22, 2026
1a74a9f
perf: per-room channel delivery for the socket.io Redis manager (#28818)
Classic298 Sep 22, 2026
d603b57
fix: FireFox regional setting (#30377)
Classic298 Sep 22, 2026
15f350b
fix: keep an open chat's tools when clicking a folder name (#30376)
Classic298 Sep 22, 2026
2f92635
fix: keep the model system prompt on Ollama tool-call follow-ups (#30…
Classic298 Sep 22, 2026
24c01bd
fix: stop voice call hanging in "speaking" when TTS fails (#30372)
Classic298 Sep 22, 2026
d66e5dc
fix: make the admin analytics tab scrollable inside the settings moda…
Classic298 Sep 23, 2026
9db1a51
fix: refresh an expiring OAuth token once when requests race (#30426)
Classic298 Sep 23, 2026
abd60d3
fix: evaluate automation schedules on Windows with PostgreSQL (#30424)
Classic298 Sep 23, 2026
c86c46e
i18n: Update catalan translation.json (#30415)
aleixdorca Sep 24, 2026
53c7ed3
i18n: add missing french translation in fr_FR (#30403)
CosmicFlavour Sep 24, 2026
b3bb82e
refac: scope ydoc update save scheduling to note documents (#30395)
Classic298 Sep 24, 2026
869a5b1
fix: stop voice mode listening after leaving the chat page (#30405) (…
Classic298 Sep 24, 2026
9e51fe4
fix: read S3 files with long non-ASCII names (#30418)
Classic298 Sep 24, 2026
95d6af4
fix: mark imported and cloned chats as read (#30754)
silentoplayz Sep 24, 2026
a7d81eb
fix: keep a user's chats listed in the admin chats view after deletin…
silentoplayz Sep 24, 2026
7e2bea8
fix: load the leaderboard activity chart for model ids that contain a…
silentoplayz Sep 24, 2026
0ab3a2b
fix: restore tag rows after unarchiving all chats and remove unused o…
silentoplayz Sep 24, 2026
4caf255
fix: refresh an expiring OAuth token once across workers and replicas…
Classic298 Sep 24, 2026
a973e77
refac: folder file checks (#30442)
Classic298 Sep 24, 2026
443736e
refactor: use secrets module for generated secret key (#30441)
Classic298 Sep 24, 2026
2867f72
refac: sync channel room on member removal (#30446)
Classic298 Sep 24, 2026
9db6898
feat: allow reordering admin models while a search or filter is activ…
Classic298 Sep 24, 2026
744ce6c
fix: send the configured USER_AGENT on the Attach Webpage pre-check (…
Classic298 Sep 24, 2026
e13e523
feat: bundle python-pptx and python-docx for the Pyodide code interpr…
Classic298 Sep 24, 2026
60ded56
fix: load models before resolving automation model defaults (#30379)
Classic298 Sep 24, 2026
4e4b324
fix: skip the pyodide plt.show patch when matplotlib is not loaded (#…
Classic298 Sep 24, 2026
1b5a8ac
fix: keep model default features when comparing multiple models (#30383)
Classic298 Sep 24, 2026
7924ce7
fix: unhandled play() rejection wedges the Read Aloud button; auto-pl…
Classic298 Sep 24, 2026
f7ce402
fix: keep requested MCP OAuth scope when DCR response omits it (#30384)
Classic298 Sep 24, 2026
b6191a0
fix: stop storing MCP image/audio base64 in file metadata (#30419)
Classic298 Sep 24, 2026
f729416
fix: show ComfyUI images saved by the Save Image (Advanced) node (#30…
Classic298 Sep 24, 2026
14c5b65
refac: tighten details and image patterns in background task message …
Classic298 Sep 24, 2026
e14f245
fix: keep the M mute shortcut working during voice calls (#30421)
Classic298 Sep 24, 2026
d9dff09
refac: build the SQLite LIKE regex from wildcard-split segments (#30393)
Classic298 Sep 24, 2026
aea7d34
refac
tjbck Sep 24, 2026
e67d7f9
fix: stop Interface settings save from overwriting the default model …
silentoplayz Sep 24, 2026
5f8d8f0
fix: resume the send queue after a queued message is deleted or edite…
silentoplayz Sep 24, 2026
a046e07
fix: save a file edit made through write access to a shared knowledge…
silentoplayz Sep 24, 2026
e6e9fc9
fix: keep the composer's + and Integrations buttons visible at larger…
Classic298 Sep 24, 2026
ff89756
refac
tjbck Sep 24, 2026
bbfa876
fix: stop the token and API key copy buttons from saving the account …
silentoplayz Sep 24, 2026
e02af3d
fix: send the query embedding as a vector in external pgvector retrieval
macodev00 Sep 24, 2026
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
248 changes: 248 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions backend/open_webui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import base64
import os
import random
import secrets
import sys
from pathlib import Path
from typing import Annotated
Expand Down Expand Up @@ -45,7 +45,7 @@ def serve(
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
KEY_FILE.write_bytes(base64.b64encode(secrets.token_bytes(key_length)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()

Expand Down
36 changes: 3 additions & 33 deletions backend/open_webui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1675,43 +1675,13 @@ def reachable(host: str, port: int) -> bool:

DEFAULT_PINNED_MODELS = os.getenv('DEFAULT_PINNED_MODELS', None)

# None uses the frontend's localized defaults; an empty list disables suggestions.
try:
default_prompt_suggestions = JSONCodec.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]'))
DEFAULT_PROMPT_SUGGESTIONS = JSONCodec.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', 'null'))
except Exception as e:
log.exception(f'Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}')
default_prompt_suggestions = []
if default_prompt_suggestions == []:
default_prompt_suggestions = [
{
'title': ['Help me study', 'vocabulary for a college entrance exam'],
'content': "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.",
},
{
'title': ['Give me ideas', "for what to do with my kids' art"],
'content': "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.",
},
{
'title': ['Tell me a fun fact', 'about the Roman Empire'],
'content': 'Tell me a random fun fact about the Roman Empire',
},
{
'title': ['Show me a code snippet', "of a website's sticky header"],
'content': "Show me a code snippet of a website's sticky header in CSS and JavaScript.",
},
{
'title': [
'Explain options trading',
"if I'm familiar with buying and selling stocks",
],
'content': "Explain options trading in simple terms if I'm familiar with buying and selling stocks.",
},
{
'title': ['Overcome procrastination', 'give me tips'],
'content': 'Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?',
},
]
DEFAULT_PROMPT_SUGGESTIONS = None

DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
DEFAULT_PROMPT_SUGGESTIONS_I18N = {}

try:
Expand Down
18 changes: 17 additions & 1 deletion backend/open_webui/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ def parse_section(section):
except ValueError:
REDIS_RESPONSE_STREAM_TTL = 3600

# Seconds a task survives without a heartbeat. 0 disables expiry.
try:
REDIS_TASK_TTL = int(os.getenv('REDIS_TASK_TTL', '300'))
if REDIS_TASK_TTL != 0 and REDIS_TASK_TTL < 60:
REDIS_TASK_TTL = 300
except ValueError:
REDIS_TASK_TTL = 300

REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '')
REDIS_SENTINEL_PORT = os.getenv('REDIS_SENTINEL_PORT', '26379')

Expand Down Expand Up @@ -485,6 +493,12 @@ def parse_section(section):
WEBSOCKET_REDIS_URL = os.getenv('WEBSOCKET_REDIS_URL', REDIS_URL)
WEBSOCKET_REDIS_CLUSTER = os.getenv('WEBSOCKET_REDIS_CLUSTER', str(REDIS_CLUSTER)).lower() == 'true'

# publishes room-targeted emits on per-room redis channels so instances skip
# messages for rooms without local members; must be identical across the fleet
# (toggle with a full restart, not a rolling one), set false for the previous
# shared-channel-only delivery
WEBSOCKET_REDIS_ROOM_CHANNELS = os.getenv('WEBSOCKET_REDIS_ROOM_CHANNELS', 'True').lower() == 'true'

websocket_redis_lock_timeout = os.getenv('WEBSOCKET_REDIS_LOCK_TIMEOUT', '60')

try:
Expand Down Expand Up @@ -1048,7 +1062,9 @@ def _parse_ssl_env(value: str) -> 'bool | _ssl.SSLContext':
# Opt in to CPython's in-place string append optimization for streamed responses.
# Off by default for a staged rollout. Only a host already out of memory can lose
# text here; the default path (a full copy per chunk) raises there too.
ENABLE_CHAT_RESPONSE_STREAM_INPLACE_APPEND = os.getenv('ENABLE_CHAT_RESPONSE_STREAM_INPLACE_APPEND', 'False').lower() == 'true'
ENABLE_CHAT_RESPONSE_STREAM_INPLACE_APPEND = (
os.getenv('ENABLE_CHAT_RESPONSE_STREAM_INPLACE_APPEND', 'False').lower() == 'true'
)

# When enabled, uses a hardcoded extension-to-MIME dictionary as a last-resort
# fallback when both mimetypes.guess_type() and file.meta.content_type fail to
Expand Down
15 changes: 10 additions & 5 deletions backend/open_webui/internal/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,23 +348,28 @@ def like(pattern, value, escape=None):
if compiled is False:
return False
if compiled is None:
regex = []
segments = ['']
escaped = False
for char in pattern:
if escape and not escaped and char == escape:
escaped = True
continue
regex.append(
'.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char)
)
if not escaped and char == '%':
segments.append('')
else:
segments[-1] += '.' if not escaped and char == '_' else re.escape(char)
escaped = False
if escaped:
compiled = False
if len(compiled_patterns) >= 512:
compiled_patterns.clear()
compiled_patterns[key] = compiled
return False
compiled = re.compile(''.join(regex), re.DOTALL)
# Atomic groups pin each middle segment to its first match, so '%' never backtracks.
regex = segments[0] + ''.join(f'(?>.*?{segment})' for segment in segments[1:-1])
if len(segments) > 1:
regex += '.*' + segments[-1]
compiled = re.compile(regex, re.DOTALL)
if len(compiled_patterns) >= 512:
compiled_patterns.clear()
compiled_patterns[key] = compiled
Expand Down
28 changes: 24 additions & 4 deletions backend/open_webui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
seed_registered_defaults,
)
from open_webui.constants import ERROR_MESSAGES, TASKS
from open_webui.utils.recurrence import RecurrenceEvaluationTimeout
from open_webui.env import (
USE_SLIM,
AIOHTTP_CLIENT_SESSION_SSL,
Expand Down Expand Up @@ -107,12 +108,14 @@
MAX_BODY_LOG_SIZE,
# Redis
REDIS_KEY_PREFIX,
REDIS_TASK_TTL,
REDIS_URL,
RESET_CONFIG_ON_START,
SAFE_MODE,
SCIM_TOKEN,
VERSION,
WEBSOCKET_HEARTBEAT_INTERVAL,
WEBSOCKET_MANAGER,
# Admin Account Runtime Creation
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_NAME,
Expand Down Expand Up @@ -189,6 +192,7 @@
get_user_id_from_session_pool,
periodic_session_pool_cleanup,
periodic_usage_pool_cleanup,
redis_event_listener,
)
from open_webui.socket.main import (
app as socket_app,
Expand All @@ -200,6 +204,7 @@
list_task_ids_by_item_id,
list_tasks,
redis_task_command_listener,
redis_task_heartbeat,
stop_item_tasks,
stop_task,
) # Import from tasks.py
Expand Down Expand Up @@ -259,7 +264,7 @@
encrypt_data,
get_oauth_client_info_with_dynamic_client_registration,
get_oauth_client_info_with_static_credentials,
recover_static_oauth_client_metadata,
recover_oauth_client_metadata,
resolve_oauth_client_info,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
Expand Down Expand Up @@ -386,6 +391,11 @@ async def lifespan(app: FastAPI):

if app.state.redis is not None:
app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app))
if REDIS_TASK_TTL > 0:
app.state.redis_task_heartbeat = asyncio.create_task(redis_task_heartbeat(app))

if WEBSOCKET_MANAGER == 'redis':
app.state.redis_event_listener = asyncio.create_task(redis_event_listener())

app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup())
app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup())
Expand Down Expand Up @@ -473,6 +483,12 @@ async def lifespan(app: FastAPI):
if hasattr(app.state, 'redis_task_command_listener'):
app.state.redis_task_command_listener.cancel()

if hasattr(app.state, 'redis_task_heartbeat'):
app.state.redis_task_heartbeat.cancel()

if hasattr(app.state, 'redis_event_listener'):
app.state.redis_event_listener.cancel()

app.state.periodic_usage_pool_cleanup.cancel()
app.state.periodic_session_pool_cleanup.cancel()
app.state.scheduler_worker_loop.cancel()
Expand All @@ -495,6 +511,12 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)


@app.exception_handler(RecurrenceEvaluationTimeout)
async def recurrence_timeout_handler(request: Request, exc: RecurrenceEvaluationTimeout):
return JSONResponse(status_code=400, content={'detail': str(exc)})


# Used by readiness checks to gate traffic until startup work is done.
app.state.startup_complete = False

Expand Down Expand Up @@ -614,9 +636,7 @@ async def initialize_runtime_config(app: FastAPI):
if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'):
try:
oauth_client_info = resolve_oauth_client_info(tool_server_connection)
oauth_client_info = await recover_static_oauth_client_metadata(
tool_server_connection, oauth_client_info
)
oauth_client_info = await recover_oauth_client_metadata(tool_server_connection, oauth_client_info)
oauth_client_info = apply_connection_oauth_options(tool_server_connection, oauth_client_info)
app.state.oauth_client_manager.add_client(
f'mcp:{server_id}',
Expand Down
75 changes: 58 additions & 17 deletions backend/open_webui/models/access_grants.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,7 @@ async def get_users_with_access(
Get all users who have the specified permission on a resource.
Returns a list of UserModel instances.
"""
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel, Users
from open_webui.models.users import Users

async with get_async_db_context(db) as db:
result = await db.execute(
Expand All @@ -699,27 +698,69 @@ async def get_users_with_access(
)
grants = result.scalars().all()

# Check for public access
for grant in grants:
if grant.principal_type == 'user' and grant.principal_id == '*':
result = await Users.get_users(filter={'roles': ['!pending']}, db=db)
return result.get('users', [])

user_ids_with_access = set()

for grant in grants:
if grant.principal_type == 'user':
user_ids_with_access.add(grant.principal_id)
elif grant.principal_type == 'group':
group_user_ids = await Groups.get_group_user_ids_by_id(grant.principal_id, db=db)
if group_user_ids:
user_ids_with_access.update(group_user_ids)
user_ids_with_access = await self.get_user_ids_by_access_grants(grants, permission, db=db)

if not user_ids_with_access:
return []

return await Users.get_users_by_user_ids(list(user_ids_with_access), db=db)

async def get_user_ids_by_access_grants(
self,
access_grants: list[AccessGrantModel],
permission: str = 'read',
db: AsyncSession | None = None,
) -> set[str]:
"""Get user IDs with the specified permission, including public and group grants."""
from open_webui.models.groups import Groups
from open_webui.models.users import Users

async with get_async_db_context(db) as db:
user_ids = set()
group_ids = []
for grant in access_grants:
if grant.permission != permission:
continue
if grant.principal_type == PRINCIPAL_TYPE_USER:
if grant.principal_id == WILDCARD_PRINCIPAL_ID:
result = await Users.get_users(filter={'roles': ['!pending']}, db=db)
return {user.id for user in result.get('users', [])}
user_ids.add(grant.principal_id)
elif grant.principal_type == PRINCIPAL_TYPE_GROUP:
group_ids.append(grant.principal_id)

if group_ids:
group_user_ids = await Groups.get_group_user_ids_by_ids(group_ids, db=db)
for members in group_user_ids.values():
user_ids.update(members)
return user_ids

async def get_revoked_user_ids_by_resource(
self,
resource_type: str,
resource_id: str,
previous_access_grants: list[AccessGrantModel],
permission: str = 'read',
db: AsyncSession | None = None,
) -> set[str]:
"""Get user IDs that lost the specified permission after a resource's grants changed."""
async with get_async_db_context(db) as db:
access_grants = await self.get_grants_by_resource(resource_type, resource_id, db=db)
previous_principals = {
(grant.principal_type, grant.principal_id)
for grant in previous_access_grants
if grant.permission == permission
}
principals = {
(grant.principal_type, grant.principal_id) for grant in access_grants if grant.permission == permission
}
if previous_principals <= principals or (PRINCIPAL_TYPE_USER, WILDCARD_PRINCIPAL_ID) in principals:
return set()

previous_user_ids = await self.get_user_ids_by_access_grants(previous_access_grants, permission, db=db)
user_ids = await self.get_user_ids_by_access_grants(access_grants, permission, db=db)
return previous_user_ids - user_ids

def has_permission_filter(
self,
db,
Expand Down
12 changes: 10 additions & 2 deletions backend/open_webui/models/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ async def claim_due(self, now_ns: int, limit: int = 10, db: Optional[AsyncSessio
rows = result.scalars().all()

from open_webui.utils.automations import next_run_ns
from open_webui.utils.recurrence import RecurrenceEvaluationTimeout

# Batch-fetch user timezones so rescheduling respects each
# user's local timezone instead of falling back to server time.
Expand All @@ -323,13 +324,20 @@ async def claim_due(self, now_ns: int, limit: int = 10, db: Optional[AsyncSessio
tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids)))
timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()}

claimed = []
for row in rows:
try:
next_run_at = await next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id))
except RecurrenceEvaluationTimeout:
log.warning('Skipping automation %s: recurrence evaluation timed out', row.id)
continue
row.last_run_at = now_ns
row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id))
row.next_run_at = next_run_at
claimed.append(row)

await db.commit()

return [AutomationModel.model_validate(r) for r in rows]
return [AutomationModel.model_validate(r) for r in claimed]


####################
Expand Down
Loading
Loading