Schedule stale gallery enrichment
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "006_add_details_fetched_at"
|
||||||
|
down_revision: Union[str, None] = "005_add_car_seen_sold_timestamps"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"MOBILEDE_cars",
|
||||||
|
sa.Column("details_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_MOBILEDE_cars_details_fetched_at",
|
||||||
|
"MOBILEDE_cars",
|
||||||
|
["details_fetched_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_MOBILEDE_cars_details_fetched_at", table_name="MOBILEDE_cars")
|
||||||
|
op.drop_column("MOBILEDE_cars", "details_fetched_at")
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
MOBILEDE_SELECTIVE_DETAIL_ENRICH_ENABLED: ${MOBILEDE_SELECTIVE_DETAIL_ENRICH_ENABLED:-false}
|
MOBILEDE_SELECTIVE_DETAIL_ENRICH_ENABLED: ${MOBILEDE_SELECTIVE_DETAIL_ENRICH_ENABLED:-false}
|
||||||
MOBILEDE_DETAIL_ENRICH_IMAGES_ENABLED: ${MOBILEDE_DETAIL_ENRICH_IMAGES_ENABLED:-false}
|
MOBILEDE_DETAIL_ENRICH_IMAGES_ENABLED: ${MOBILEDE_DETAIL_ENRICH_IMAGES_ENABLED:-false}
|
||||||
MOBILEDE_DETAIL_TIMEOUT_SECONDS: ${MOBILEDE_DETAIL_TIMEOUT_SECONDS:-10}
|
MOBILEDE_DETAIL_TIMEOUT_SECONDS: ${MOBILEDE_DETAIL_TIMEOUT_SECONDS:-10}
|
||||||
|
MOBILEDE_BEAT_ENRICH_ENABLED: ${MOBILEDE_BEAT_ENRICH_ENABLED:-true}
|
||||||
|
MOBILEDE_ENRICH_INTERVAL_SECONDS: ${MOBILEDE_ENRICH_INTERVAL_SECONDS:-900}
|
||||||
|
MOBILEDE_ENRICH_LOCK_TTL_SECONDS: ${MOBILEDE_ENRICH_LOCK_TTL_SECONDS:-3600}
|
||||||
MOBILEDE_STARTUP_SYNC_ENABLED: ${MOBILEDE_STARTUP_SYNC_ENABLED:-true}
|
MOBILEDE_STARTUP_SYNC_ENABLED: ${MOBILEDE_STARTUP_SYNC_ENABLED:-true}
|
||||||
MOBILEDE_RUNTIME_CONFIG_FILE: ${MOBILEDE_RUNTIME_CONFIG_FILE:-/app/runtime_config.json}
|
MOBILEDE_RUNTIME_CONFIG_FILE: ${MOBILEDE_RUNTIME_CONFIG_FILE:-/app/runtime_config.json}
|
||||||
# Автовосстановление worker.
|
# Автовосстановление worker.
|
||||||
@@ -195,6 +198,37 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
|
# Отдельный worker для detail/gallery enrichment.
|
||||||
|
worker-images:
|
||||||
|
<<: *worker-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
|
MOBILEDE_STARTUP_SYNC_ENABLED: "false"
|
||||||
|
MOBILEDE_SELF_HEAL_ENABLED: "false"
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
stop_grace_period: 60s
|
||||||
|
command: >
|
||||||
|
celery -A mobilede_scraper.worker.celery_app worker
|
||||||
|
--loglevel=info --concurrency=1 --pool=${CELERY_WORKER_POOL:-prefork}
|
||||||
|
--pidfile=/tmp/celery-images-worker.pid
|
||||||
|
-Q mobilede_images --max-tasks-per-child=20
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -f /tmp/celery-images-worker.pid && kill -0 $(cat /tmp/celery-images-worker.pid)"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 90s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# Beat сервис.
|
# Beat сервис.
|
||||||
beat:
|
beat:
|
||||||
<<: *worker-service
|
<<: *worker-service
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from ..deps import get_persistence
|
|||||||
from ...storage.db import PersistenceService
|
from ...storage.db import PersistenceService
|
||||||
from ...storage.models import SyncRun
|
from ...storage.models import SyncRun
|
||||||
from ...worker.celery_app import celery_app
|
from ...worker.celery_app import celery_app
|
||||||
from ...worker.constants import MOBILEDE_SYNC_QUEUE
|
from ...worker.constants import MOBILEDE_IMAGES_QUEUE, MOBILEDE_SYNC_QUEUE
|
||||||
from ...worker.tasks import (
|
from ...worker.tasks import (
|
||||||
mobilede_enrich_images_batch_task,
|
mobilede_enrich_images_batch_task,
|
||||||
mobilede_sync_detail_task,
|
mobilede_sync_detail_task,
|
||||||
@@ -45,9 +45,10 @@ class MobileDeSyncDetailRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MobileDeEnrichImagesRequest(BaseModel):
|
class MobileDeEnrichImagesRequest(BaseModel):
|
||||||
limit: int = 50
|
|
||||||
lane: str = "mobile_de_cars"
|
lane: str = "mobile_de_cars"
|
||||||
|
batch_size: int = 10
|
||||||
max_existing_images: int = 1
|
max_existing_images: int = 1
|
||||||
|
staleness_hours: int = 168
|
||||||
delay_seconds: float = 1.0
|
delay_seconds: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
@@ -89,12 +90,12 @@ def start_mobilede_sync_detail(body: MobileDeSyncDetailRequest):
|
|||||||
def start_mobilede_enrich_images(body: MobileDeEnrichImagesRequest):
|
def start_mobilede_enrich_images(body: MobileDeEnrichImagesRequest):
|
||||||
result = mobilede_enrich_images_batch_task.apply_async(
|
result = mobilede_enrich_images_batch_task.apply_async(
|
||||||
kwargs=body.model_dump(),
|
kwargs=body.model_dump(),
|
||||||
queue=MOBILEDE_SYNC_QUEUE,
|
queue=MOBILEDE_IMAGES_QUEUE,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"task_id": result.id,
|
"task_id": result.id,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"queue": MOBILEDE_SYNC_QUEUE,
|
"queue": MOBILEDE_IMAGES_QUEUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -345,9 +345,38 @@ class RuntimeMobileDeSegment:
|
|||||||
return " / ".join(parts) or self.make_id or "mobilede-segment"
|
return " / ".join(parts) or self.make_id or "mobilede-segment"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RuntimeMobileDeEnrichmentConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
batch_size: int = 10
|
||||||
|
max_existing_images: int = 1
|
||||||
|
staleness_hours: int = 168
|
||||||
|
delay_seconds: float = 1.0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> "RuntimeMobileDeEnrichmentConfig":
|
||||||
|
data = data or {}
|
||||||
|
enabled = _optional_bool(data.get("enabled"))
|
||||||
|
batch_size = _optional_int(data.get("batch_size"))
|
||||||
|
max_existing_images = _optional_int(data.get("max_existing_images"))
|
||||||
|
staleness_hours = _optional_int(data.get("staleness_hours"))
|
||||||
|
try:
|
||||||
|
delay_seconds = float(data.get("delay_seconds", 1.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
delay_seconds = 1.0
|
||||||
|
return cls(
|
||||||
|
enabled=True if enabled is None else enabled,
|
||||||
|
batch_size=max(1, 10 if batch_size is None else batch_size),
|
||||||
|
max_existing_images=max(0, 1 if max_existing_images is None else max_existing_images),
|
||||||
|
staleness_hours=max(0, 168 if staleness_hours is None else staleness_hours),
|
||||||
|
delay_seconds=max(0.0, delay_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RuntimeMobileDeConfig:
|
class RuntimeMobileDeConfig:
|
||||||
segments: tuple[RuntimeMobileDeSegment, ...] = ()
|
segments: tuple[RuntimeMobileDeSegment, ...] = ()
|
||||||
|
enrichment: RuntimeMobileDeEnrichmentConfig = field(default_factory=RuntimeMobileDeEnrichmentConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "RuntimeMobileDeConfig":
|
def from_dict(cls, data: dict[str, Any] | None) -> "RuntimeMobileDeConfig":
|
||||||
@@ -361,7 +390,10 @@ class RuntimeMobileDeConfig:
|
|||||||
segment = RuntimeMobileDeSegment.from_dict(item)
|
segment = RuntimeMobileDeSegment.from_dict(item)
|
||||||
if segment is not None:
|
if segment is not None:
|
||||||
segments.append(segment)
|
segments.append(segment)
|
||||||
return cls(segments=tuple(segments))
|
return cls(
|
||||||
|
segments=tuple(segments),
|
||||||
|
enrichment=RuntimeMobileDeEnrichmentConfig.from_dict(data.get("enrichment")),
|
||||||
|
)
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
return not self.segments
|
return not self.segments
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from contextlib import contextmanager
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
from sqlalchemy import create_engine, delete, func, or_, select, text, update
|
from sqlalchemy import case, create_engine, delete, func, or_, select, text, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
@@ -185,6 +185,8 @@ class PersistenceService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _car_payload(record: CarRecord) -> dict[str, object]:
|
def _car_payload(record: CarRecord) -> dict[str, object]:
|
||||||
payload = record.model_dump(mode="python")
|
payload = record.model_dump(mode="python")
|
||||||
|
if record.details_confirmed:
|
||||||
|
payload["details_fetched_at"] = datetime.now(timezone.utc)
|
||||||
return {key: value for key, value in payload.items() if key in CAR_DB_FIELDS}
|
return {key: value for key, value in payload.items() if key in CAR_DB_FIELDS}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -194,6 +196,8 @@ class PersistenceService:
|
|||||||
record: CarRecord,
|
record: CarRecord,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
prepared = dict(payload)
|
prepared = dict(payload)
|
||||||
|
if not record.details_confirmed:
|
||||||
|
prepared["details_fetched_at"] = car.details_fetched_at
|
||||||
if record.preserve_existing_details and not record.details_confirmed:
|
if record.preserve_existing_details and not record.details_confirmed:
|
||||||
for key, fallback_values in DETAIL_VALUE_FALLBACKS.items():
|
for key, fallback_values in DETAIL_VALUE_FALLBACKS.items():
|
||||||
if prepared.get(key) in fallback_values:
|
if prepared.get(key) in fallback_values:
|
||||||
@@ -907,24 +911,36 @@ class PersistenceService:
|
|||||||
self,
|
self,
|
||||||
prefix: str | tuple[str, ...] = MOBILEDE_ORIGIN_PREFIXES,
|
prefix: str | tuple[str, ...] = MOBILEDE_ORIGIN_PREFIXES,
|
||||||
*,
|
*,
|
||||||
limit: int = 50,
|
limit: int | None = None,
|
||||||
max_existing_images: int = 1,
|
max_existing_images: int = 1,
|
||||||
|
stale_before: datetime | None = None,
|
||||||
) -> list[tuple[int, str, str, int]]:
|
) -> list[tuple[int, str, str, int]]:
|
||||||
|
if limit is not None and int(limit) <= 0:
|
||||||
|
return []
|
||||||
prefixes = (prefix,) if isinstance(prefix, str) else tuple(prefix)
|
prefixes = (prefix,) if isinstance(prefix, str) else tuple(prefix)
|
||||||
with self.session_scope() as session:
|
with self.session_scope() as session:
|
||||||
image_count = func.count(Image.id)
|
image_count = func.count(Image.id)
|
||||||
result = session.execute(
|
conditions = [
|
||||||
select(Car.id, Car.origin_id, Car.origin_url, image_count.label("image_count"))
|
|
||||||
.outerjoin(Image, Image.car_id == Car.id)
|
|
||||||
.where(
|
|
||||||
_origin_prefix_filter(Car.origin_id, prefixes),
|
_origin_prefix_filter(Car.origin_id, prefixes),
|
||||||
Car.is_sold == False, # noqa: E712
|
Car.is_sold == False, # noqa: E712
|
||||||
)
|
]
|
||||||
.group_by(Car.id, Car.origin_id, Car.origin_url)
|
if stale_before is not None:
|
||||||
|
conditions.append(or_(Car.details_fetched_at.is_(None), Car.details_fetched_at <= stale_before))
|
||||||
|
stmt = (
|
||||||
|
select(Car.id, Car.origin_id, Car.origin_url, image_count.label("image_count"))
|
||||||
|
.outerjoin(Image, Image.car_id == Car.id)
|
||||||
|
.where(*conditions)
|
||||||
|
.group_by(Car.id, Car.origin_id, Car.origin_url, Car.details_fetched_at)
|
||||||
.having(image_count <= max(0, int(max_existing_images)))
|
.having(image_count <= max(0, int(max_existing_images)))
|
||||||
.order_by(Car.last_seen_at.desc())
|
.order_by(
|
||||||
.limit(max(1, int(limit)))
|
case((Car.details_fetched_at.is_(None), 0), else_=1).asc(),
|
||||||
|
Car.details_fetched_at.asc(),
|
||||||
|
Car.id.asc(),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(int(limit))
|
||||||
|
result = session.execute(stmt)
|
||||||
return [
|
return [
|
||||||
(int(row[0]), str(row[1]), str(row[2]), int(row[3] or 0))
|
(int(row[0]), str(row[1]), str(row[2]), int(row[3] or 0))
|
||||||
for row in result
|
for row in result
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class Car(Base):
|
|||||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
||||||
sold_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
sold_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
details_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
images: Mapped[list["Image"]] = relationship("Image", back_populates="car", cascade="all, delete-orphan")
|
images: Mapped[list["Image"]] = relationship("Image", back_populates="car", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class CarRecord(BaseModel):
|
|||||||
first_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
first_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
last_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
last_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
sold_at: datetime | None = None
|
sold_at: datetime | None = None
|
||||||
|
details_fetched_at: datetime | None = None
|
||||||
skip_image_sync: bool = False
|
skip_image_sync: bool = False
|
||||||
details_confirmed: bool = False
|
details_confirmed: bool = False
|
||||||
images_confirmed: bool = False
|
images_confirmed: bool = False
|
||||||
@@ -91,5 +92,6 @@ class CarRead(BaseModel):
|
|||||||
first_seen_at: datetime | None = None
|
first_seen_at: datetime | None = None
|
||||||
last_seen_at: datetime | None = None
|
last_seen_at: datetime | None = None
|
||||||
sold_at: datetime | None = None
|
sold_at: datetime | None = None
|
||||||
|
details_fetched_at: datetime | None = None
|
||||||
images: list[ImageRead] = Field(default_factory=list)
|
images: list[ImageRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .constants import (
|
|||||||
GLOBAL_DB_PROGRESS_TS_KEY,
|
GLOBAL_DB_PROGRESS_TS_KEY,
|
||||||
GLOBAL_PROGRESS_TS_KEY,
|
GLOBAL_PROGRESS_TS_KEY,
|
||||||
MOBILEDE_RUNTIME_SEGMENTS_TASK,
|
MOBILEDE_RUNTIME_SEGMENTS_TASK,
|
||||||
|
MOBILEDE_IMAGES_QUEUE,
|
||||||
MOBILEDE_SYNC_QUEUE,
|
MOBILEDE_SYNC_QUEUE,
|
||||||
MOBILEDE_SYNC_TASK_NAME,
|
MOBILEDE_SYNC_TASK_NAME,
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,7 @@ def _env_bool(name: str, default: bool) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
MOBILEDE_BEAT_SYNC_ENABLED = _env_bool("MOBILEDE_BEAT_SYNC_ENABLED", True)
|
MOBILEDE_BEAT_SYNC_ENABLED = _env_bool("MOBILEDE_BEAT_SYNC_ENABLED", True)
|
||||||
|
MOBILEDE_BEAT_ENRICH_ENABLED = _env_bool("MOBILEDE_BEAT_ENRICH_ENABLED", True)
|
||||||
|
|
||||||
|
|
||||||
def _runtime_sync_kwargs() -> dict[str, bool | float]:
|
def _runtime_sync_kwargs() -> dict[str, bool | float]:
|
||||||
@@ -49,6 +51,10 @@ def _runtime_sync_expires_seconds() -> float:
|
|||||||
return settings.celery.beat_sync_interval_minutes * 60.0
|
return settings.celery.beat_sync_interval_minutes * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_enrich_interval_seconds() -> float:
|
||||||
|
return max(60.0, float(os.getenv("MOBILEDE_ENRICH_INTERVAL_SECONDS", "900")))
|
||||||
|
|
||||||
|
|
||||||
def _has_fresh_active_progress(
|
def _has_fresh_active_progress(
|
||||||
redis_client: Redis,
|
redis_client: Redis,
|
||||||
*,
|
*,
|
||||||
@@ -155,8 +161,7 @@ if _hard > _max_hard:
|
|||||||
|
|
||||||
beat_schedule = {}
|
beat_schedule = {}
|
||||||
if MOBILEDE_BEAT_SYNC_ENABLED:
|
if MOBILEDE_BEAT_SYNC_ENABLED:
|
||||||
beat_schedule = {
|
beat_schedule["periodic-mobilede-sync-search"] = {
|
||||||
"periodic-mobilede-sync-search": {
|
|
||||||
"task": MOBILEDE_RUNTIME_SEGMENTS_TASK,
|
"task": MOBILEDE_RUNTIME_SEGMENTS_TASK,
|
||||||
"schedule": _runtime_sync_expires_seconds(),
|
"schedule": _runtime_sync_expires_seconds(),
|
||||||
"args": (),
|
"args": (),
|
||||||
@@ -166,6 +171,16 @@ if MOBILEDE_BEAT_SYNC_ENABLED:
|
|||||||
"expires": _runtime_sync_expires_seconds(),
|
"expires": _runtime_sync_expires_seconds(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if MOBILEDE_BEAT_ENRICH_ENABLED:
|
||||||
|
beat_schedule["periodic-mobilede-enrich-images"] = {
|
||||||
|
"task": MOBILEDE_ENRICH_IMAGES_TASK,
|
||||||
|
"schedule": _runtime_enrich_interval_seconds(),
|
||||||
|
"args": (),
|
||||||
|
"kwargs": {},
|
||||||
|
"options": {
|
||||||
|
"queue": MOBILEDE_IMAGES_QUEUE,
|
||||||
|
"expires": _runtime_enrich_interval_seconds(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
@@ -195,7 +210,7 @@ celery_app.conf.update(
|
|||||||
MOBILEDE_RUNTIME_SEGMENTS_TASK: {"queue": MOBILEDE_SYNC_QUEUE},
|
MOBILEDE_RUNTIME_SEGMENTS_TASK: {"queue": MOBILEDE_SYNC_QUEUE},
|
||||||
MOBILEDE_SYNC_TASK_NAME: {"queue": MOBILEDE_SYNC_QUEUE},
|
MOBILEDE_SYNC_TASK_NAME: {"queue": MOBILEDE_SYNC_QUEUE},
|
||||||
MOBILEDE_SYNC_DETAIL_TASK: {"queue": MOBILEDE_SYNC_QUEUE},
|
MOBILEDE_SYNC_DETAIL_TASK: {"queue": MOBILEDE_SYNC_QUEUE},
|
||||||
MOBILEDE_ENRICH_IMAGES_TASK: {"queue": MOBILEDE_SYNC_QUEUE},
|
MOBILEDE_ENRICH_IMAGES_TASK: {"queue": MOBILEDE_IMAGES_QUEUE},
|
||||||
"mobilede_scraper.worker.tasks.*": {"queue": MOBILEDE_SYNC_QUEUE},
|
"mobilede_scraper.worker.tasks.*": {"queue": MOBILEDE_SYNC_QUEUE},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
MOBILEDE_SYNC_QUEUE = "mobilede_sync"
|
MOBILEDE_SYNC_QUEUE = "mobilede_sync"
|
||||||
|
MOBILEDE_IMAGES_QUEUE = "mobilede_images"
|
||||||
|
MOBILEDE_ENRICH_IMAGES_LOCK_KEY = "mobilede:locks:enrich_images"
|
||||||
MOBILEDE_SEARCH_CURSOR_KEY = "mobilede:state:search_next_page"
|
MOBILEDE_SEARCH_CURSOR_KEY = "mobilede:state:search_next_page"
|
||||||
MOBILEDE_SEGMENT_CURSOR_KEY_FMT = "mobilede:state:search_next_page:{segment_key}"
|
MOBILEDE_SEGMENT_CURSOR_KEY_FMT = "mobilede:state:search_next_page:{segment_key}"
|
||||||
MOBILEDE_RUNTIME_SEGMENT_INDEX_KEY = "mobilede:state:runtime_segment_index"
|
MOBILEDE_RUNTIME_SEGMENT_INDEX_KEY = "mobilede:state:runtime_segment_index"
|
||||||
|
|||||||
@@ -2119,6 +2119,13 @@ def _expand_mobilede_search_url_segment(
|
|||||||
if query_keys & existing_range_keys:
|
if query_keys & existing_range_keys:
|
||||||
return [segment]
|
return [segment]
|
||||||
|
|
||||||
|
if not allow_adaptive_planning:
|
||||||
|
logger.info(
|
||||||
|
"mobile.de URL segment fast-start enabled: using search_url as-is for %s",
|
||||||
|
_mobilede_short_segment_label(segment),
|
||||||
|
)
|
||||||
|
return [segment]
|
||||||
|
|
||||||
max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
|
max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
|
||||||
root_segment = _mobilede_try_keep_root_segment_unsplit(
|
root_segment = _mobilede_try_keep_root_segment_unsplit(
|
||||||
segment,
|
segment,
|
||||||
@@ -2128,13 +2135,6 @@ def _expand_mobilede_search_url_segment(
|
|||||||
if root_segment is not None:
|
if root_segment is not None:
|
||||||
return root_segment
|
return root_segment
|
||||||
|
|
||||||
if not allow_adaptive_planning:
|
|
||||||
logger.info(
|
|
||||||
"mobile.de URL segment fast-start enabled: using search_url as-is for %s",
|
|
||||||
_mobilede_short_segment_label(segment),
|
|
||||||
)
|
|
||||||
return [segment]
|
|
||||||
|
|
||||||
if allow_probe_planning:
|
if allow_probe_planning:
|
||||||
probe_planned = _expand_mobilede_search_url_segment_by_probe(segment)
|
probe_planned = _expand_mobilede_search_url_segment_by_probe(segment)
|
||||||
if probe_planned is not None:
|
if probe_planned is not None:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import signal
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -2431,7 +2431,7 @@ def mobilede_sync_detail_task(self, listing_id: str, lane: str = "mobile_de_cars
|
|||||||
|
|
||||||
@shared_task(
|
@shared_task(
|
||||||
name="mobilede.enrich_images_batch",
|
name="mobilede.enrich_images_batch",
|
||||||
queue=MOBILEDE_SYNC_QUEUE,
|
queue=MOBILEDE_IMAGES_QUEUE,
|
||||||
bind=True,
|
bind=True,
|
||||||
max_retries=1,
|
max_retries=1,
|
||||||
default_retry_delay=120,
|
default_retry_delay=120,
|
||||||
@@ -2439,21 +2439,62 @@ def mobilede_sync_detail_task(self, listing_id: str, lane: str = "mobile_de_cars
|
|||||||
)
|
)
|
||||||
def mobilede_enrich_images_batch_task(
|
def mobilede_enrich_images_batch_task(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
|
||||||
lane: str = "mobile_de_cars",
|
lane: str = "mobile_de_cars",
|
||||||
max_existing_images: int = 1,
|
batch_size: int | None = None,
|
||||||
delay_seconds: float = 1.0,
|
max_existing_images: int | None = None,
|
||||||
|
staleness_hours: int | None = None,
|
||||||
|
delay_seconds: float | None = None,
|
||||||
):
|
):
|
||||||
|
runtime_config = RuntimeConfig.from_file(Settings().runtime_config_file)
|
||||||
|
enrichment = runtime_config.mobilede.enrichment
|
||||||
|
if not enrichment.enabled:
|
||||||
|
return {
|
||||||
|
"status": "disabled",
|
||||||
|
"candidates": 0,
|
||||||
|
"enriched": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
effective_batch_size = enrichment.batch_size if batch_size is None else max(1, int(batch_size))
|
||||||
|
effective_max_images = (
|
||||||
|
enrichment.max_existing_images
|
||||||
|
if max_existing_images is None
|
||||||
|
else max(0, int(max_existing_images))
|
||||||
|
)
|
||||||
|
effective_staleness_hours = (
|
||||||
|
enrichment.staleness_hours
|
||||||
|
if staleness_hours is None
|
||||||
|
else max(0, int(staleness_hours))
|
||||||
|
)
|
||||||
|
effective_delay = enrichment.delay_seconds if delay_seconds is None else max(0.0, float(delay_seconds))
|
||||||
|
|
||||||
|
redis_client = _get_redis()
|
||||||
|
owner_token = str(getattr(self.request, "id", None) or uuid.uuid4().hex)
|
||||||
|
lock_ttl = max(300, int(float(os.getenv("MOBILEDE_ENRICH_LOCK_TTL_SECONDS", "3600"))))
|
||||||
|
if not _acquire_lock(redis_client, MOBILEDE_ENRICH_IMAGES_LOCK_KEY, owner_token, lock_ttl):
|
||||||
|
return {
|
||||||
|
"status": "locked",
|
||||||
|
"candidates": 0,
|
||||||
|
"enriched": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
persistence = _get_persistence()
|
persistence = _get_persistence()
|
||||||
scraper = MobileDeScraper(persistence=persistence)
|
scraper = MobileDeScraper(persistence=persistence)
|
||||||
|
try:
|
||||||
|
stale_before = datetime.now(timezone.utc) - timedelta(hours=effective_staleness_hours)
|
||||||
candidates = persistence.get_active_cars_batch_for_image_enrich(
|
candidates = persistence.get_active_cars_batch_for_image_enrich(
|
||||||
limit=limit,
|
max_existing_images=effective_max_images,
|
||||||
max_existing_images=max_existing_images,
|
stale_before=stale_before,
|
||||||
)
|
)
|
||||||
enriched = 0
|
enriched = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
for _car_id, origin_id, origin_url, image_count in candidates:
|
for batch_start in range(0, len(candidates), effective_batch_size):
|
||||||
|
batch = candidates[batch_start:batch_start + effective_batch_size]
|
||||||
|
for _car_id, origin_id, origin_url, image_count in batch:
|
||||||
listing_id = _mobilede_extract_listing_id(origin_url) or str(origin_id).rsplit(":", 1)[-1]
|
listing_id = _mobilede_extract_listing_id(origin_url) or str(origin_id).rsplit(":", 1)[-1]
|
||||||
if not listing_id:
|
if not listing_id:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
@@ -2477,8 +2518,8 @@ def mobilede_enrich_images_batch_task(
|
|||||||
exc,
|
exc,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
if delay_seconds:
|
if effective_delay:
|
||||||
time.sleep(max(0.0, float(delay_seconds)))
|
time.sleep(effective_delay)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"candidates": len(candidates),
|
"candidates": len(candidates),
|
||||||
@@ -2486,6 +2527,12 @@ def mobilede_enrich_images_batch_task(
|
|||||||
"failed": failed,
|
"failed": failed,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
_release_lock_if_owner(
|
||||||
|
redis_client,
|
||||||
|
MOBILEDE_ENRICH_IMAGES_LOCK_KEY,
|
||||||
|
owner_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
@shared_task(
|
||||||
|
|||||||
@@ -102,6 +102,13 @@
|
|||||||
"exclude_body_types": []
|
"exclude_body_types": []
|
||||||
},
|
},
|
||||||
"mobilede": {
|
"mobilede": {
|
||||||
|
"enrichment": {
|
||||||
|
"enabled": true,
|
||||||
|
"batch_size": 10,
|
||||||
|
"max_existing_images": 1,
|
||||||
|
"staleness_hours": 168,
|
||||||
|
"delay_seconds": 1.0
|
||||||
|
},
|
||||||
"segments": []
|
"segments": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,39 @@ class TestPersistenceServiceIntegration(unittest.TestCase):
|
|||||||
self.assertEqual(car.body_type, "COUPE")
|
self.assertEqual(car.body_type, "COUPE")
|
||||||
self.assertEqual([image.fullres_image for image in images], ["https://example.test/refreshed.jpg"])
|
self.assertEqual([image.fullres_image for image in images], ["https://example.test/refreshed.jpg"])
|
||||||
|
|
||||||
|
def test_detail_timestamp_is_set_and_preserved_by_search_update(self) -> None:
|
||||||
|
search = self._record("mobile.de:detail-ts")
|
||||||
|
search.details_confirmed = False
|
||||||
|
search.images_confirmed = False
|
||||||
|
search.preserve_existing_details = True
|
||||||
|
self.persistence.upsert_car(search)
|
||||||
|
|
||||||
|
with self.persistence.session_scope() as session:
|
||||||
|
car = session.execute(select(Car).where(Car.origin_id == search.origin_id)).scalar_one()
|
||||||
|
self.assertIsNone(car.details_fetched_at)
|
||||||
|
|
||||||
|
detail = self._record("mobile.de:detail-ts", price=1500)
|
||||||
|
before = datetime.now(timezone.utc)
|
||||||
|
self.persistence.upsert_car(detail)
|
||||||
|
|
||||||
|
with self.persistence.session_scope() as session:
|
||||||
|
car = session.execute(select(Car).where(Car.origin_id == search.origin_id)).scalar_one()
|
||||||
|
fetched_at = self._as_utc(car.details_fetched_at)
|
||||||
|
|
||||||
|
self.assertIsNotNone(fetched_at)
|
||||||
|
self.assertGreaterEqual(fetched_at, before)
|
||||||
|
|
||||||
|
search_again = self._record("mobile.de:detail-ts", price=2000)
|
||||||
|
search_again.details_confirmed = False
|
||||||
|
search_again.images_confirmed = False
|
||||||
|
search_again.preserve_existing_details = True
|
||||||
|
self.persistence.upsert_car(search_again)
|
||||||
|
|
||||||
|
with self.persistence.session_scope() as session:
|
||||||
|
car = session.execute(select(Car).where(Car.origin_id == search.origin_id)).scalar_one()
|
||||||
|
|
||||||
|
self.assertEqual(self._as_utc(car.details_fetched_at), fetched_at)
|
||||||
|
|
||||||
def test_start_sync_run_marks_stale_running_runs_as_failed(self) -> None:
|
def test_start_sync_run_marks_stale_running_runs_as_failed(self) -> None:
|
||||||
first_run_id = self.persistence.start_sync_run("lane-a")
|
first_run_id = self.persistence.start_sync_run("lane-a")
|
||||||
second_run_id = self.persistence.start_sync_run("lane-b")
|
second_run_id = self.persistence.start_sync_run("lane-b")
|
||||||
@@ -328,6 +361,45 @@ class TestPersistenceServiceIntegration(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([(origin_id, image_count) for _id, origin_id, _url, image_count in candidates], [("mobile.de:low", 1)])
|
self.assertEqual([(origin_id, image_count) for _id, origin_id, _url, image_count in candidates], [("mobile.de:low", 1)])
|
||||||
|
|
||||||
|
def test_image_enrich_selector_prefers_unfetched_then_oldest_stale(self) -> None:
|
||||||
|
unfetched = self._record("mobile.de:unfetched")
|
||||||
|
unfetched.details_confirmed = False
|
||||||
|
stale_old = self._record("mobile.de:stale-old")
|
||||||
|
stale_new = self._record("mobile.de:stale-new")
|
||||||
|
fresh = self._record("mobile.de:fresh")
|
||||||
|
for record in (unfetched, stale_old, stale_new, fresh):
|
||||||
|
record.images = []
|
||||||
|
self.persistence.upsert_car(record)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.persistence.session_scope() as session:
|
||||||
|
cars = {
|
||||||
|
car.origin_id: car
|
||||||
|
for car in session.execute(select(Car)).scalars().all()
|
||||||
|
}
|
||||||
|
cars[stale_old.origin_id].details_fetched_at = now - timedelta(days=10)
|
||||||
|
cars[stale_new.origin_id].details_fetched_at = now - timedelta(days=8)
|
||||||
|
cars[fresh.origin_id].details_fetched_at = now - timedelta(days=1)
|
||||||
|
|
||||||
|
candidates = self.persistence.get_active_cars_batch_for_image_enrich(
|
||||||
|
limit=10,
|
||||||
|
max_existing_images=1,
|
||||||
|
stale_before=now - timedelta(days=7),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[origin_id for _id, origin_id, _url, _count in candidates],
|
||||||
|
[unfetched.origin_id, stale_old.origin_id, stale_new.origin_id],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.persistence.get_active_cars_batch_for_image_enrich(
|
||||||
|
limit=0,
|
||||||
|
max_existing_images=1,
|
||||||
|
stale_before=now,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from mobilede_scraper.core.runtime_config import RuntimeMobileDeEnrichmentConfig
|
||||||
|
from mobilede_scraper.worker import tasks
|
||||||
|
from mobilede_scraper.worker.celery_app import MOBILEDE_ENRICH_IMAGES_TASK, beat_schedule, celery_app
|
||||||
|
from mobilede_scraper.worker.constants import MOBILEDE_IMAGES_QUEUE
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileDeEnrichment(unittest.TestCase):
|
||||||
|
def test_runtime_config_defaults_and_overrides(self) -> None:
|
||||||
|
defaults = RuntimeMobileDeEnrichmentConfig.from_dict(None)
|
||||||
|
self.assertTrue(defaults.enabled)
|
||||||
|
self.assertEqual(defaults.batch_size, 10)
|
||||||
|
self.assertEqual(defaults.max_existing_images, 1)
|
||||||
|
self.assertEqual(defaults.staleness_hours, 168)
|
||||||
|
|
||||||
|
configured = RuntimeMobileDeEnrichmentConfig.from_dict({
|
||||||
|
"enabled": False,
|
||||||
|
"batch_size": 5,
|
||||||
|
"max_existing_images": 2,
|
||||||
|
"staleness_hours": 24,
|
||||||
|
"delay_seconds": 0.25,
|
||||||
|
})
|
||||||
|
self.assertFalse(configured.enabled)
|
||||||
|
self.assertEqual(configured.batch_size, 5)
|
||||||
|
self.assertEqual(configured.max_existing_images, 2)
|
||||||
|
self.assertEqual(configured.staleness_hours, 24)
|
||||||
|
self.assertEqual(configured.delay_seconds, 0.25)
|
||||||
|
|
||||||
|
def test_celery_routes_and_schedules_enrichment_on_images_queue(self) -> None:
|
||||||
|
routes = celery_app.conf.task_routes
|
||||||
|
self.assertEqual(routes[MOBILEDE_ENRICH_IMAGES_TASK]["queue"], MOBILEDE_IMAGES_QUEUE)
|
||||||
|
schedule = beat_schedule["periodic-mobilede-enrich-images"]
|
||||||
|
self.assertEqual(schedule["task"], MOBILEDE_ENRICH_IMAGES_TASK)
|
||||||
|
self.assertEqual(schedule["options"]["queue"], MOBILEDE_IMAGES_QUEUE)
|
||||||
|
self.assertGreater(schedule["options"]["expires"], 0)
|
||||||
|
|
||||||
|
def test_task_uses_runtime_staleness_and_continues_after_failure(self) -> None:
|
||||||
|
enrichment = SimpleNamespace(
|
||||||
|
enabled=True,
|
||||||
|
batch_size=1,
|
||||||
|
max_existing_images=1,
|
||||||
|
staleness_hours=48,
|
||||||
|
delay_seconds=0.0,
|
||||||
|
)
|
||||||
|
runtime_config = SimpleNamespace(mobilede=SimpleNamespace(enrichment=enrichment))
|
||||||
|
redis_client = MagicMock()
|
||||||
|
redis_client.set.return_value = True
|
||||||
|
redis_client.eval.return_value = 1
|
||||||
|
persistence = MagicMock()
|
||||||
|
persistence.get_active_cars_batch_for_image_enrich.return_value = [
|
||||||
|
(1, "mobile.de:111", "https://suchen.mobile.de/fahrzeuge/details.html?id=111", 0),
|
||||||
|
(2, "mobile.de:222", "https://suchen.mobile.de/fahrzeuge/details.html?id=222", 1),
|
||||||
|
]
|
||||||
|
scraper = MagicMock()
|
||||||
|
scraper.sync_detail.side_effect = [{"upsert": {"action": "updated"}}, RuntimeError("blocked")]
|
||||||
|
|
||||||
|
before = datetime.now(timezone.utc) - timedelta(hours=48, seconds=2)
|
||||||
|
with (
|
||||||
|
patch.object(tasks, "Settings", return_value=SimpleNamespace(runtime_config_file="runtime_config.json")),
|
||||||
|
patch.object(tasks.RuntimeConfig, "from_file", return_value=runtime_config),
|
||||||
|
patch.object(tasks, "_get_redis", return_value=redis_client),
|
||||||
|
patch.object(tasks, "_get_persistence", return_value=persistence),
|
||||||
|
patch.object(tasks, "MobileDeScraper", return_value=scraper),
|
||||||
|
):
|
||||||
|
result = tasks.mobilede_enrich_images_batch_task.run()
|
||||||
|
after = datetime.now(timezone.utc) - timedelta(hours=48) + timedelta(seconds=2)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "success")
|
||||||
|
self.assertEqual(result["candidates"], 2)
|
||||||
|
self.assertEqual(result["enriched"], 1)
|
||||||
|
self.assertEqual(result["failed"], 1)
|
||||||
|
selector_kwargs = persistence.get_active_cars_batch_for_image_enrich.call_args.kwargs
|
||||||
|
self.assertNotIn("limit", selector_kwargs)
|
||||||
|
self.assertEqual(selector_kwargs["max_existing_images"], 1)
|
||||||
|
self.assertGreaterEqual(selector_kwargs["stale_before"], before)
|
||||||
|
self.assertLessEqual(selector_kwargs["stale_before"], after)
|
||||||
|
self.assertEqual(scraper.sync_detail.call_count, 2)
|
||||||
|
redis_client.eval.assert_called()
|
||||||
|
|
||||||
|
def test_task_skips_when_enrichment_lock_is_held(self) -> None:
|
||||||
|
enrichment = SimpleNamespace(
|
||||||
|
enabled=True,
|
||||||
|
batch_size=1,
|
||||||
|
max_existing_images=1,
|
||||||
|
staleness_hours=48,
|
||||||
|
delay_seconds=0.0,
|
||||||
|
)
|
||||||
|
runtime_config = SimpleNamespace(mobilede=SimpleNamespace(enrichment=enrichment))
|
||||||
|
redis_client = MagicMock()
|
||||||
|
redis_client.set.return_value = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(tasks, "Settings", return_value=SimpleNamespace(runtime_config_file="runtime_config.json")),
|
||||||
|
patch.object(tasks.RuntimeConfig, "from_file", return_value=runtime_config),
|
||||||
|
patch.object(tasks, "_get_redis", return_value=redis_client),
|
||||||
|
patch.object(tasks, "_get_persistence") as get_persistence,
|
||||||
|
):
|
||||||
|
result = tasks.mobilede_enrich_images_batch_task.run()
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "locked")
|
||||||
|
get_persistence.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from mobilede_scraper.worker import tasks
|
from mobilede_scraper.worker import planner, tasks
|
||||||
|
|
||||||
|
|
||||||
class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
|
class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
|
||||||
@@ -553,6 +553,12 @@ class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
|
|||||||
previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
|
previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
|
||||||
try:
|
try:
|
||||||
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
|
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
|
||||||
|
with (
|
||||||
|
patch.object(planner, "_mobilede_try_keep_root_segment_unsplit", return_value=None),
|
||||||
|
patch.object(planner, "_expand_mobilede_search_url_segment_by_probe", return_value=None),
|
||||||
|
patch.object(planner, "_mobilede_probe_total", return_value=None),
|
||||||
|
patch.object(planner, "_mobilede_preplan_runtime_segments", side_effect=lambda items: items),
|
||||||
|
):
|
||||||
segments = tasks._build_mobilede_runtime_segments(settings)
|
segments = tasks._build_mobilede_runtime_segments(settings)
|
||||||
finally:
|
finally:
|
||||||
if previous is None:
|
if previous is None:
|
||||||
@@ -578,6 +584,12 @@ class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = True
|
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = True
|
||||||
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
|
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
|
||||||
|
with (
|
||||||
|
patch.object(planner, "_mobilede_try_keep_root_segment_unsplit", return_value=None),
|
||||||
|
patch.object(planner, "_expand_mobilede_search_url_segment_by_probe", return_value=None),
|
||||||
|
patch.object(planner, "_mobilede_probe_total", return_value=None),
|
||||||
|
patch.object(planner, "_mobilede_preplan_runtime_segments", side_effect=lambda items: items),
|
||||||
|
):
|
||||||
segments = tasks._build_mobilede_runtime_segments(settings)
|
segments = tasks._build_mobilede_runtime_segments(settings)
|
||||||
finally:
|
finally:
|
||||||
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
|
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
|
||||||
|
|||||||
Reference in New Issue
Block a user