Compare commits

...
Author SHA1 Message Date
zhaohao1004 784dd3a555 Optimize expired message cleanup batching 2026-06-17 19:53:12 +08:00
6 changed files with 626 additions and 76 deletions
+12
View File
@@ -1460,6 +1460,18 @@ class SandboxExpiredRecordsCleanConfig(BaseSettings):
description="Maximum number of records to process in each batch",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_SIZE: PositiveInt = Field(
description="Initial number of message candidates to scan in each expired records cleanup batch",
default=10000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_MAX_SIZE: PositiveInt = Field(
description="Maximum number of message candidates to scan in each expired records cleanup batch",
default=50000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_DELETE_BATCH_SIZE: PositiveInt = Field(
description="Maximum number of records to delete in each expired records cleanup transaction",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: PositiveInt = Field(
description="Maximum interval in milliseconds between batches",
default=200,
+3 -1
View File
@@ -40,7 +40,9 @@ def clean_messages():
service = MessagesCleanService.from_days(
policy=policy,
days=dify_config.SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS,
batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE,
batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_SIZE,
max_candidate_batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_MAX_SIZE,
delete_batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_DELETE_BATCH_SIZE,
)
stats = service.run()
@@ -70,6 +70,13 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
- Safe default: if tenant mapping or plan is missing, do NOT delete
"""
_graceful_period_days: int
_tenant_whitelist: Sequence[str]
_tenant_whitelist_set: set[str]
_plan_provider: Callable[[Sequence[str]], dict[str, SubscriptionPlan]]
_current_timestamp: int | None
_plan_cache: dict[str, SubscriptionPlan | None]
def __init__(
self,
plan_provider: Callable[[Sequence[str]], dict[str, SubscriptionPlan]],
@@ -79,8 +86,10 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
) -> None:
self._graceful_period_days = graceful_period_days
self._tenant_whitelist: Sequence[str] = tenant_whitelist or []
self._tenant_whitelist_set = set(self._tenant_whitelist)
self._plan_provider = plan_provider
self._current_timestamp = current_timestamp
self._plan_cache = {}
@override
def filter_message_ids(
@@ -101,9 +110,12 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
if not messages or not app_to_tenant:
return []
# Get unique tenant_ids and fetch subscription plans
tenant_ids = list(set(app_to_tenant.values()))
tenant_plans = self._plan_provider(tenant_ids)
# Get unique tenant_ids and fetch subscription plans. Plans are cached for the whole
# policy lifetime because message cleanup evaluates many adjacent batches from the same apps.
tenant_ids = sorted(
{tenant_id for tenant_id in app_to_tenant.values() if tenant_id not in self._tenant_whitelist_set}
)
tenant_plans = self._get_tenant_plans(tenant_ids)
if not tenant_plans:
return []
@@ -115,6 +127,28 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
tenant_plans=tenant_plans,
)
def _get_tenant_plans(self, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
"""
Return cached subscription plans for tenant ids.
Missing billing responses are cached as None and remain a safe non-delete decision.
Provider exceptions still propagate so transient billing failures do not silently change cleanup behavior.
"""
unique_tenant_ids = sorted(set(tenant_ids))
missing_tenant_ids = [tenant_id for tenant_id in unique_tenant_ids if tenant_id not in self._plan_cache]
if missing_tenant_ids:
fetched_plans = self._plan_provider(missing_tenant_ids)
for tenant_id in missing_tenant_ids:
self._plan_cache[tenant_id] = fetched_plans.get(tenant_id)
plans: dict[str, SubscriptionPlan] = {}
for tenant_id in unique_tenant_ids:
plan = self._plan_cache.get(tenant_id)
if plan is not None:
plans[tenant_id] = plan
return plans
def _filter_expired_sandbox_messages(
self,
messages: Sequence[SimpleMessage],
@@ -1,8 +1,9 @@
import datetime
import logging
import math
import random
import time
from collections.abc import Sequence
from collections.abc import Iterator, Sequence
from typing import TYPE_CHECKING, TypedDict, cast
import sqlalchemy as sa
@@ -37,6 +38,10 @@ if TYPE_CHECKING:
from opentelemetry.metrics import Counter, Histogram
_MIN_ELIGIBLE_HIT_RATE = 0.005
_HIT_RATE_EMA_ALPHA = 0.3
class MessagesCleanupMetrics:
"""
Records low-cardinality OpenTelemetry metrics for expired message cleanup jobs.
@@ -165,12 +170,24 @@ class MessagesCleanStatsDict(TypedDict):
total_deleted: int
class MessageDeleteResultDict(TypedDict):
messages_deleted: int
chunks: int
relations_ms: int
messages_ms: int
class MessagesCleanService:
"""
Service for cleaning expired messages based on retention policies.
Compatible with non cloud edition (billing disabled): all messages in the time range will be deleted.
If billing is enabled: only sandbox plan tenant messages are deleted (with whitelist and grace period support).
The scan cursor advances by candidate messages, not messages selected by the policy. This keeps cleanup moving
through windows dominated by paid, unknown, or grace-period tenants without asking SQL to find enough eligible rows.
Candidate scan batches can grow independently from delete batches, while deletes stay chunked into
small transactions.
"""
def __init__(
@@ -179,6 +196,8 @@ class MessagesCleanService:
end_before: datetime.datetime,
start_from: datetime.datetime | None = None,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> None:
@@ -189,14 +208,28 @@ class MessagesCleanService:
policy: The policy that determines which messages to delete
end_before: End time (exclusive) of the range
start_from: Optional start time (inclusive) of the range
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
"""
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
self._policy = policy
self._end_before = end_before
self._start_from = start_from
self._batch_size = batch_size
self._candidate_batch_size = batch_size
self._delete_batch_size = delete_batch_size or batch_size
self._max_candidate_batch_size = max(max_candidate_batch_size or batch_size, self._candidate_batch_size)
self._dry_run = dry_run
self._metrics = MessagesCleanupMetrics(
dry_run=dry_run,
@@ -211,6 +244,8 @@ class MessagesCleanService:
start_from: datetime.datetime,
end_before: datetime.datetime,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> "MessagesCleanService":
@@ -223,7 +258,9 @@ class MessagesCleanService:
policy: The policy that determines which messages to delete
start_from: Start time (inclusive) of the range
end_before: End time (exclusive) of the range
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
@@ -239,11 +276,20 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
logger.info(
"clean_messages: start_from=%s, end_before=%s, batch_size=%s, policy=%s",
"clean_messages: start_from=%s, end_before=%s, batch_size=%s, "
"max_candidate_batch_size=%s, delete_batch_size=%s, policy=%s",
start_from,
end_before,
batch_size,
max_candidate_batch_size,
delete_batch_size,
policy.__class__.__name__,
)
@@ -252,6 +298,8 @@ class MessagesCleanService:
end_before=end_before,
start_from=start_from,
batch_size=batch_size,
max_candidate_batch_size=max_candidate_batch_size,
delete_batch_size=delete_batch_size,
dry_run=dry_run,
task_label=task_label,
)
@@ -262,6 +310,8 @@ class MessagesCleanService:
policy: MessagesCleanPolicy,
days: int = 30,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> "MessagesCleanService":
@@ -271,7 +321,9 @@ class MessagesCleanService:
Args:
policy: The policy that determines which messages to delete
days: Number of days to look back from now
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
@@ -287,13 +339,22 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
end_before = naive_utc_now() - datetime.timedelta(days=days)
logger.info(
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
"clean_messages: days=%s, end_before=%s, batch_size=%s, "
"max_candidate_batch_size=%s, delete_batch_size=%s, policy=%s",
days,
end_before,
batch_size,
max_candidate_batch_size,
delete_batch_size,
policy.__class__.__name__,
)
@@ -302,6 +363,8 @@ class MessagesCleanService:
end_before=end_before,
start_from=None,
batch_size=batch_size,
max_candidate_batch_size=max_candidate_batch_size,
delete_batch_size=delete_batch_size,
dry_run=dry_run,
task_label=task_label,
)
@@ -333,10 +396,10 @@ class MessagesCleanService:
Time range is [start_from, end_before)
Steps:
1. Iterate messages using cursor pagination (by created_at, id)
2. Query app_id -> tenant_id mapping
1. Iterate candidate messages using cursor pagination (by created_at, id)
2. Resolve app_id -> tenant_id mapping with a job-level cache
3. Delegate to policy to determine which messages to delete
4. Batch delete messages and their relations
4. Delete messages and their relations in small chunks
Returns:
Dict with statistics: batches, filtered_messages, total_deleted
@@ -348,19 +411,23 @@ class MessagesCleanService:
"total_deleted": 0,
}
# Cursor-based pagination using (created_at, id) to avoid infinite loops
# and ensure proper ordering with time-based filtering
_cursor: tuple[datetime.datetime, str] | None = None
cursor: tuple[datetime.datetime, str] | None = None
app_to_tenant_cache: dict[str, str | None] = {}
current_candidate_batch_size = self._candidate_batch_size
smoothed_hit_rate: float | None = None
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
logger.info(
"clean_messages: start cleaning messages (dry_run=%s), start_from=%s, end_before=%s",
"clean_messages: start cleaning messages (dry_run=%s), start_from=%s, end_before=%s, "
"candidate_batch_size=%s, max_candidate_batch_size=%s, delete_batch_size=%s",
self._dry_run,
self._start_from,
self._end_before,
self._candidate_batch_size,
self._max_candidate_batch_size,
self._delete_batch_size,
)
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
while True:
stats["batches"] += 1
batch_start = time.monotonic()
@@ -369,25 +436,25 @@ class MessagesCleanService:
batch_deleted_messages = 0
# Step 1: Fetch a batch of messages using cursor
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
with session_factory.begin() as session:
fetch_messages_start = time.monotonic()
msg_stmt = (
select(Message.id, Message.app_id, Message.created_at)
.where(Message.created_at < self._end_before)
.order_by(Message.created_at, Message.id)
.limit(self._batch_size)
.limit(current_candidate_batch_size)
)
if self._start_from:
msg_stmt = msg_stmt.where(Message.created_at >= self._start_from)
# Apply cursor condition: (created_at, id) > (last_created_at, last_message_id)
if _cursor:
if cursor:
msg_stmt = msg_stmt.where(
tuple_(Message.created_at, Message.id)
> tuple_(
sa.literal(_cursor[0], type_=sa.DateTime()),
sa.literal(_cursor[1], type_=Message.id.type),
sa.literal(cursor[0], type_=sa.DateTime()),
sa.literal(cursor[1], type_=Message.id.type),
)
)
@@ -397,13 +464,13 @@ class MessagesCleanService:
for msg_id, app_id, msg_created_at in raw_messages
]
logger.info(
"clean_messages (batch %s): fetched %s messages in %sms",
"clean_messages (batch %s): fetched %s candidate messages with limit %s in %sms",
stats["batches"],
len(messages),
current_candidate_batch_size,
int((time.monotonic() - fetch_messages_start) * 1000),
)
# Track total messages fetched across all batches
stats["total_messages"] += len(messages)
batch_scanned_messages = len(messages)
@@ -417,29 +484,50 @@ class MessagesCleanService:
)
break
# Update cursor to the last message's (created_at, id)
_cursor = (messages[-1].created_at, messages[-1].id)
# Advance by candidate rows before policy filtering. This avoids retrying the same paid/unknown slice.
cursor = (messages[-1].created_at, messages[-1].id)
# Step 2: Extract app_ids and query tenant_ids
app_ids = list({msg.app_id for msg in messages})
if not app_ids:
logger.info("clean_messages (batch %s): no app_ids found, skip", stats["batches"])
smoothed_hit_rate, current_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=0,
)
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
fetch_apps_start = time.monotonic()
app_stmt = select(App.id, App.tenant_id).where(App.id.in_(app_ids))
apps = list(session.execute(app_stmt).all())
app_to_tenant, app_cache_misses, apps_found = self._load_app_to_tenant_mapping(
session=session,
app_ids=app_ids,
app_to_tenant_cache=app_to_tenant_cache,
)
logger.info(
"clean_messages (batch %s): fetched %s apps for %s app_ids in %sms",
"clean_messages (batch %s): resolved %s apps for %s app_ids (cache_misses=%s, found=%s) in %sms",
stats["batches"],
len(apps),
len(app_to_tenant),
len(app_ids),
app_cache_misses,
apps_found,
int((time.monotonic() - fetch_apps_start) * 1000),
)
if not apps:
if not app_to_tenant:
logger.info("clean_messages (batch %s): no apps found, skip", stats["batches"])
smoothed_hit_rate, current_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=0,
)
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
@@ -448,19 +536,27 @@ class MessagesCleanService:
)
continue
# Build app_id -> tenant_id mapping
app_to_tenant: dict[str, str] = {app.id: app.tenant_id for app in apps}
# Step 3: Delegate to policy to determine which messages to delete
policy_start = time.monotonic()
message_ids_to_delete = self._policy.filter_message_ids(messages, app_to_tenant)
message_ids_to_delete = list(self._policy.filter_message_ids(messages, app_to_tenant))
batch_filtered_messages = len(message_ids_to_delete)
stats["filtered_messages"] += batch_filtered_messages
smoothed_hit_rate, next_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=batch_filtered_messages,
)
logger.info(
"clean_messages (batch %s): policy selected %s/%s messages in %sms",
"clean_messages (batch %s): policy selected %s/%s messages in %sms "
"(smoothed_hit_rate=%.4f, next_candidate_batch_size=%s)",
stats["batches"],
len(message_ids_to_delete),
batch_filtered_messages,
len(messages),
int((time.monotonic() - policy_start) * 1000),
smoothed_hit_rate,
next_candidate_batch_size,
)
current_candidate_batch_size = next_candidate_batch_size
if not message_ids_to_delete:
logger.info("clean_messages (batch %s): no messages to delete, skip", stats["batches"])
@@ -472,47 +568,32 @@ class MessagesCleanService:
)
continue
stats["filtered_messages"] += len(message_ids_to_delete)
batch_filtered_messages = len(message_ids_to_delete)
# Step 4: Batch delete messages and their relations
if not self._dry_run:
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
delete_relations_start = time.monotonic()
# Delete related records first
self._batch_delete_message_relations(session, message_ids_to_delete)
delete_relations_ms = int((time.monotonic() - delete_relations_start) * 1000)
delete_result = self._delete_messages_in_chunks(
session_factory=session_factory,
message_ids=message_ids_to_delete,
)
# Delete messages
delete_messages_start = time.monotonic()
delete_stmt = delete(Message).where(Message.id.in_(message_ids_to_delete))
delete_result = cast(CursorResult, session.execute(delete_stmt))
messages_deleted = delete_result.rowcount
delete_messages_ms = int((time.monotonic() - delete_messages_start) * 1000)
commit_ms = 0
stats["total_deleted"] += delete_result["messages_deleted"]
batch_deleted_messages = delete_result["messages_deleted"]
stats["total_deleted"] += messages_deleted
batch_deleted_messages = messages_deleted
logger.info(
"clean_messages (batch %s): processed %s candidate messages, deleted %s messages in %s chunks",
stats["batches"],
len(messages),
delete_result["messages_deleted"],
delete_result["chunks"],
)
logger.info(
"clean_messages (batch %s): relations %sms, messages %sms, batch total %sms",
stats["batches"],
delete_result["relations_ms"],
delete_result["messages_ms"],
int((time.monotonic() - batch_start) * 1000),
)
logger.info(
"clean_messages (batch %s): processed %s messages, deleted %s messages",
stats["batches"],
len(messages),
messages_deleted,
)
logger.info(
"clean_messages (batch %s): relations %sms, messages %sms, commit %sms, batch total %sms",
stats["batches"],
delete_relations_ms,
delete_messages_ms,
commit_ms,
int((time.monotonic() - batch_start) * 1000),
)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("clean_messages (batch %s): sleeping for %.2fms", stats["batches"], sleep_ms)
time.sleep(sleep_ms / 1000)
self._sleep_after_batch(stats["batches"], batch_deleted_messages)
else:
# Log random sample of message IDs that would be deleted (up to 10)
sample_size = min(10, len(message_ids_to_delete))
@@ -544,6 +625,105 @@ class MessagesCleanService:
return stats
@staticmethod
def _load_app_to_tenant_mapping(
*,
session: Session,
app_ids: Sequence[str],
app_to_tenant_cache: dict[str, str | None],
) -> tuple[dict[str, str], int, int]:
unique_app_ids = sorted(set(app_ids))
missing_app_ids = [app_id for app_id in unique_app_ids if app_id not in app_to_tenant_cache]
found_apps = 0
if missing_app_ids:
app_stmt = select(App.id, App.tenant_id).where(App.id.in_(missing_app_ids))
apps = list(session.execute(app_stmt).all())
found_app_ids: set[str] = set()
for app_id, tenant_id in apps:
app_to_tenant_cache[app_id] = tenant_id
found_app_ids.add(app_id)
found_apps = len(found_app_ids)
for app_id in set(missing_app_ids) - found_app_ids:
app_to_tenant_cache[app_id] = None
app_to_tenant: dict[str, str] = {}
for app_id in unique_app_ids:
tenant_id = app_to_tenant_cache.get(app_id)
if tenant_id is not None:
app_to_tenant[app_id] = tenant_id
return app_to_tenant, len(missing_app_ids), found_apps
def _adjust_candidate_batch_size(
self,
*,
smoothed_hit_rate: float | None,
candidate_count: int,
eligible_count: int,
) -> tuple[float, int]:
if candidate_count <= 0:
next_smoothed_hit_rate = smoothed_hit_rate or 0.0
return next_smoothed_hit_rate, self._candidate_batch_size_for_hit_rate(next_smoothed_hit_rate)
hit_rate = eligible_count / candidate_count
if smoothed_hit_rate is None:
next_smoothed_hit_rate = hit_rate
else:
next_smoothed_hit_rate = (_HIT_RATE_EMA_ALPHA * hit_rate) + ((1 - _HIT_RATE_EMA_ALPHA) * smoothed_hit_rate)
return next_smoothed_hit_rate, self._candidate_batch_size_for_hit_rate(next_smoothed_hit_rate)
def _candidate_batch_size_for_hit_rate(self, hit_rate: float) -> int:
effective_hit_rate = max(hit_rate, _MIN_ELIGIBLE_HIT_RATE)
desired_batch_size = math.ceil(self._delete_batch_size / effective_hit_rate)
lower_bound = min(self._delete_batch_size, self._max_candidate_batch_size)
return min(max(desired_batch_size, lower_bound), self._max_candidate_batch_size)
def _delete_messages_in_chunks(
self,
*,
session_factory: sessionmaker[Session],
message_ids: Sequence[str],
) -> MessageDeleteResultDict:
result: MessageDeleteResultDict = {
"messages_deleted": 0,
"chunks": 0,
"relations_ms": 0,
"messages_ms": 0,
}
for message_id_chunk in self._iter_message_id_chunks(message_ids, self._delete_batch_size):
result["chunks"] += 1
with session_factory.begin() as session:
delete_relations_start = time.monotonic()
self._batch_delete_message_relations(session, message_id_chunk)
result["relations_ms"] += int((time.monotonic() - delete_relations_start) * 1000)
delete_messages_start = time.monotonic()
delete_stmt = delete(Message).where(Message.id.in_(message_id_chunk))
delete_result = cast(CursorResult, session.execute(delete_stmt))
result["messages_deleted"] += delete_result.rowcount
result["messages_ms"] += int((time.monotonic() - delete_messages_start) * 1000)
return result
@staticmethod
def _iter_message_id_chunks(message_ids: Sequence[str], chunk_size: int) -> Iterator[Sequence[str]]:
for start_index in range(0, len(message_ids), chunk_size):
yield message_ids[start_index : start_index + chunk_size]
def _sleep_after_batch(self, batch_index: int, deleted_messages: int) -> None:
if deleted_messages <= 0:
return
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
sleep_ratio = min(1.0, deleted_messages / self._delete_batch_size)
sleep_ms = random.uniform(0, max_batch_interval_ms * sleep_ratio) # noqa: S311
logger.info("clean_messages (batch %s): sleeping for %.2fms", batch_index, sleep_ms)
time.sleep(sleep_ms / 1000)
@staticmethod
def _batch_delete_message_relations(session: Session, message_ids: Sequence[str]) -> None:
"""
@@ -1,11 +1,14 @@
import datetime
import math
import uuid
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from enums.cloud_plan import CloudPlan
from models import Tenant
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import (
@@ -15,7 +18,7 @@ from models.model import (
MessageAnnotation,
MessageFeedback,
)
from services.retention.conversation.messages_clean_policy import BillingDisabledPolicy
from services.retention.conversation.messages_clean_policy import BillingDisabledPolicy, BillingSandboxPolicy
from services.retention.conversation.messages_clean_service import MessagesCleanService
_NOW = datetime.datetime(2026, 1, 15, 12, 0, 0, tzinfo=datetime.UTC)
@@ -91,6 +94,35 @@ def _make_message(app_id: str, conversation_id: str, created_at: datetime.dateti
)
def _create_tenant_app_conversation(session: Session, name_suffix: str) -> tuple[str, str, str]:
tenant = Tenant(name=f"retention_it_tenant_{name_suffix}")
session.add(tenant)
session.flush()
app = App(
tenant_id=tenant.id,
name=f"Retention IT App {name_suffix}",
mode="chat",
enable_site=True,
enable_api=True,
)
session.add(app)
session.flush()
conv = Conversation(
app_id=app.id,
mode="chat",
name=f"test_conv_{name_suffix}",
status="normal",
from_source="console",
_inputs={},
)
session.add(conv)
session.flush()
return tenant.id, app.id, conv.id
class TestMessagesCleanServiceIntegration:
@pytest.fixture
def seed_messages(self, tenant_and_app):
@@ -285,6 +317,117 @@ class TestMessagesCleanServiceIntegration:
remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(msg_ids)))
assert remaining == 0
def test_candidate_cursor_advances_when_first_batch_has_no_eligible_messages(self, flask_req_ctx):
"""A paid-only candidate batch must not prevent later sandbox messages from being cleaned."""
del flask_req_ctx
with session_factory.create_session() as session:
paid_tenant_id, paid_app_id, paid_conv_id = _create_tenant_app_conversation(session, "paid")
free_tenant_id, free_app_id, free_conv_id = _create_tenant_app_conversation(session, "free")
paid_msg_1 = _make_message(paid_app_id, paid_conv_id, _OLD)
paid_msg_2 = _make_message(paid_app_id, paid_conv_id, _OLD + datetime.timedelta(seconds=1))
free_msg = _make_message(free_app_id, free_conv_id, _OLD + datetime.timedelta(seconds=2))
session.add_all([paid_msg_1, paid_msg_2, free_msg])
session.commit()
paid_message_ids = [paid_msg_1.id, paid_msg_2.id]
free_message_id = free_msg.id
app_ids = [paid_app_id, free_app_id]
conversation_ids = [paid_conv_id, free_conv_id]
tenant_ids = [paid_tenant_id, free_tenant_id]
plan_map = {
paid_tenant_id: {"plan": CloudPlan.PROFESSIONAL, "expiration_date": -1},
free_tenant_id: {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
}
plan_provider = MagicMock(
side_effect=lambda tenant_ids: {tenant_id: plan_map[tenant_id] for tenant_id in tenant_ids}
)
policy = BillingSandboxPolicy(plan_provider=plan_provider, graceful_period_days=21)
try:
with patch("services.retention.conversation.messages_clean_service.time.sleep"):
svc = MessagesCleanService.from_time_range(
policy=policy,
start_from=_OLD - datetime.timedelta(seconds=1),
end_before=_OLD + datetime.timedelta(seconds=3),
batch_size=2,
max_candidate_batch_size=2,
delete_batch_size=1,
)
stats = svc.run()
assert stats["total_messages"] == 3
assert stats["filtered_messages"] == 1
assert stats["total_deleted"] == 1
assert plan_provider.call_count == 2
assert set(plan_provider.call_args_list[0].args[0]) == {paid_tenant_id}
assert set(plan_provider.call_args_list[1].args[0]) == {free_tenant_id}
with session_factory.create_session() as session:
remaining_paid = session.scalar(
select(func.count()).select_from(Message).where(Message.id.in_(paid_message_ids))
)
remaining_free = session.scalar(
select(func.count()).select_from(Message).where(Message.id == free_message_id)
)
assert remaining_paid == 2
assert remaining_free == 0
finally:
with session_factory.create_session() as session:
session.execute(delete(Message).where(Message.id.in_([*paid_message_ids, free_message_id])))
session.execute(delete(Conversation).where(Conversation.id.in_(conversation_ids)))
session.execute(delete(App).where(App.id.in_(app_ids)))
session.execute(delete(Tenant).where(Tenant.id.in_(tenant_ids)))
session.commit()
def test_delete_batch_size_chunks_eligible_message_deletes(self, tenant_and_app):
"""Candidate scans can be larger than the delete transaction chunk size."""
data = tenant_and_app
app_id = data["app_id"]
conv_id = data["conversation_id"]
msg_ids: list[str] = []
with session_factory.create_session() as session:
for index in range(5):
msg = _make_message(app_id, conv_id, _OLD + datetime.timedelta(seconds=index))
session.add(msg)
session.flush()
msg_ids.append(msg.id)
session.commit()
try:
with (
patch.object(
MessagesCleanService,
"_batch_delete_message_relations",
wraps=MessagesCleanService._batch_delete_message_relations,
) as delete_relations,
patch("services.retention.conversation.messages_clean_service.time.sleep"),
):
svc = MessagesCleanService.from_time_range(
policy=BillingDisabledPolicy(),
start_from=_OLD - datetime.timedelta(seconds=1),
end_before=_OLD + datetime.timedelta(seconds=6),
batch_size=5,
max_candidate_batch_size=5,
delete_batch_size=2,
)
stats = svc.run()
assert stats["total_deleted"] == 5
assert delete_relations.call_count == 3
with session_factory.create_session() as session:
remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(msg_ids)))
assert remaining == 0
finally:
with session_factory.create_session() as session:
session.execute(delete(Message).where(Message.id.in_(msg_ids)))
session.commit()
def test_no_messages_in_range_returns_empty_stats(self, seed_messages):
"""A window entirely in the future must yield zero matches."""
far_future = _NOW + datetime.timedelta(days=365)
@@ -296,6 +296,64 @@ class TestBillingSandboxPolicyFilterMessageIds:
called_tenant_ids = set(plan_provider.call_args[0][0])
assert called_tenant_ids == {"tenant1", "tenant2"}
def test_plan_provider_reuses_job_level_cache(self):
"""Test that repeated tenants are not fetched from billing more than once."""
# Arrange
now = self.CURRENT_TIMESTAMP
tenant_plans = {
"tenant1": {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
"tenant2": {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
}
plan_provider = MagicMock(
side_effect=lambda tenant_ids: {tenant_id: tenant_plans[tenant_id] for tenant_id in tenant_ids}
)
policy = BillingSandboxPolicy(
plan_provider=plan_provider,
graceful_period_days=self.GRACEFUL_PERIOD_DAYS,
current_timestamp=now,
)
# Act
first_result = policy.filter_message_ids(
[make_simple_message("msg1", "app1")],
{"app1": "tenant1"},
)
second_result = policy.filter_message_ids(
[
make_simple_message("msg2", "app1"),
make_simple_message("msg3", "app2"),
],
{"app1": "tenant1", "app2": "tenant2"},
)
# Assert
assert set(first_result) == {"msg1"}
assert set(second_result) == {"msg2", "msg3"}
assert plan_provider.call_count == 2
assert set(plan_provider.call_args_list[0].args[0]) == {"tenant1"}
assert set(plan_provider.call_args_list[1].args[0]) == {"tenant2"}
def test_whitelisted_tenants_are_not_fetched_from_plan_provider(self):
"""Test that whitelisted tenants are skipped before billing plan lookup."""
# Arrange
plan_provider = make_plan_provider({})
policy = BillingSandboxPolicy(
plan_provider=plan_provider,
graceful_period_days=self.GRACEFUL_PERIOD_DAYS,
tenant_whitelist=["tenant1"],
current_timestamp=self.CURRENT_TIMESTAMP,
)
# Act
result = policy.filter_message_ids(
[make_simple_message("msg1", "app1")],
{"app1": "tenant1"},
)
# Assert
assert list(result) == []
plan_provider.assert_not_called()
def test_complex_mixed_scenario(self):
"""Test complex scenario with mixed plans, expirations, whitelist, and missing mappings."""
# Arrange
@@ -497,6 +555,22 @@ class TestMessagesCleanServiceFromTimeRange:
batch_size=-100,
)
with pytest.raises(ValueError, match="max_candidate_batch_size .* must be greater than 0"):
MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
max_candidate_batch_size=0,
)
with pytest.raises(ValueError, match="delete_batch_size .* must be greater than 0"):
MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
delete_batch_size=0,
)
def test_valid_params_creates_instance(self):
"""Test that valid parameters create a correctly configured instance."""
# Arrange
@@ -512,6 +586,8 @@ class TestMessagesCleanServiceFromTimeRange:
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
max_candidate_batch_size=5000,
delete_batch_size=200,
dry_run=dry_run,
)
@@ -521,6 +597,9 @@ class TestMessagesCleanServiceFromTimeRange:
assert service._start_from == start_from
assert service._end_before == end_before
assert service._batch_size == batch_size
assert service._candidate_batch_size == batch_size
assert service._max_candidate_batch_size == 5000
assert service._delete_batch_size == 200
assert service._dry_run == dry_run
def test_default_params(self):
@@ -539,6 +618,9 @@ class TestMessagesCleanServiceFromTimeRange:
# Assert
assert service._batch_size == 1000 # default
assert service._candidate_batch_size == 1000 # default
assert service._max_candidate_batch_size == 1000 # default
assert service._delete_batch_size == 1000 # default
assert service._dry_run is False # default
def test_explicit_task_label(self):
@@ -590,6 +672,12 @@ class TestMessagesCleanServiceFromDays:
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, batch_size=-500)
with pytest.raises(ValueError, match="max_candidate_batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, max_candidate_batch_size=0)
with pytest.raises(ValueError, match="delete_batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, delete_batch_size=0)
def test_valid_params_creates_instance(self):
"""Test that valid parameters create a correctly configured instance."""
# Arrange
@@ -606,6 +694,8 @@ class TestMessagesCleanServiceFromDays:
policy=policy,
days=days,
batch_size=batch_size,
max_candidate_batch_size=2500,
delete_batch_size=250,
dry_run=dry_run,
)
@@ -616,6 +706,9 @@ class TestMessagesCleanServiceFromDays:
assert service._start_from is None
assert service._end_before == expected_end_before
assert service._batch_size == batch_size
assert service._candidate_batch_size == batch_size
assert service._max_candidate_batch_size == 2500
assert service._delete_batch_size == 250
assert service._dry_run == dry_run
def test_default_params(self):
@@ -637,6 +730,92 @@ class TestMessagesCleanServiceFromDays:
assert service._metrics._base_attributes["task_label"] == "custom"
class TestMessagesCleanServiceBatchHelpers:
"""Unit tests for cache and adaptive batch helpers."""
def test_load_app_to_tenant_mapping_reuses_cache(self):
class ExecuteResult:
def __init__(self, rows: list[tuple[str, str]]) -> None:
self._rows = rows
def all(self) -> list[tuple[str, str]]:
return self._rows
class FakeSession:
execute_calls: int
def __init__(self) -> None:
self.execute_calls = 0
def execute(self, _stmt: object) -> ExecuteResult:
self.execute_calls += 1
return ExecuteResult([("app1", "tenant1")])
session = FakeSession()
cache: dict[str, str | None] = {}
first_mapping, first_cache_misses, first_found = MessagesCleanService._load_app_to_tenant_mapping(
session=session, # type: ignore[arg-type]
app_ids=["app1"],
app_to_tenant_cache=cache,
)
second_mapping, second_cache_misses, second_found = MessagesCleanService._load_app_to_tenant_mapping(
session=session, # type: ignore[arg-type]
app_ids=["app1"],
app_to_tenant_cache=cache,
)
assert first_mapping == {"app1": "tenant1"}
assert second_mapping == {"app1": "tenant1"}
assert first_cache_misses == 1
assert first_found == 1
assert second_cache_misses == 0
assert second_found == 0
assert session.execute_calls == 1
def test_candidate_batch_size_grows_for_low_hit_rate(self):
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=1000,
max_candidate_batch_size=50000,
delete_batch_size=1000,
)
smoothed_hit_rate, next_batch_size = service._adjust_candidate_batch_size(
smoothed_hit_rate=None,
candidate_count=10000,
eligible_count=55,
)
assert smoothed_hit_rate == 0.0055
assert next_batch_size == 50000
def test_candidate_batch_size_shrinks_when_hit_rate_is_high(self):
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=10000,
max_candidate_batch_size=50000,
delete_batch_size=1000,
)
_smoothed_hit_rate, next_batch_size = service._adjust_candidate_batch_size(
smoothed_hit_rate=None,
candidate_count=10000,
eligible_count=10000,
)
assert next_batch_size == 1000
def test_iter_message_id_chunks_uses_delete_batch_size(self):
chunks = list(MessagesCleanService._iter_message_id_chunks(["msg1", "msg2", "msg3", "msg4", "msg5"], 2))
assert chunks == [["msg1", "msg2"], ["msg3", "msg4"], ["msg5"]]
class TestMessagesCleanServiceRun:
"""Unit tests for MessagesCleanService.run instrumentation behavior."""