feat: update finance exchange and source refresh jobs
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 31s
CI/CD Pipeline / Build and Push Images (push) Successful in 34s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Successful in 33s

This commit is contained in:
2026-08-09 12:23:36 +02:00
parent 4da56f3823
commit a198f7e960
19 changed files with 266 additions and 52 deletions

View File

@@ -169,12 +169,16 @@ class BackgroundJob(TimestampMixin, models.Model):
self.status = JobStatus.SUCCESS
self.progress = 100
self.result = result
self.error = ""
self.traceback = ""
self.completed_at = timezone.now()
self.save(
update_fields=[
"status",
"progress",
"result",
"error",
"traceback",
"completed_at",
"updated_at",
]

View File

@@ -752,21 +752,21 @@ class BackgroundJobService(BaseReadOnlyService):
cls,
*,
max_age_minutes: int,
pending_max_age_minutes: int = 24 * 60,
task_names: set[str] | None = None,
meta_sources: set[str] | None = None,
) -> int:
"""Mark old active jobs as failed after worker restarts or hard kills."""
from apps.core.models import JobStatus
cutoff = timezone.now() - timedelta(minutes=max_age_minutes)
queryset = (
cls.get_queryset()
.filter(
status__in=[JobStatus.PENDING, JobStatus.STARTED, JobStatus.RETRY],
)
.filter(
Q(started_at__isnull=False, started_at__lt=cutoff)
| Q(started_at__isnull=True, created_at__lt=cutoff)
now = timezone.now()
cutoff = now - timedelta(minutes=max_age_minutes)
pending_cutoff = now - timedelta(minutes=pending_max_age_minutes)
queryset = cls.get_queryset().filter(
Q(status=JobStatus.PENDING, created_at__lt=pending_cutoff)
| Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__lt=cutoff,
)
)
if task_names:

View File

@@ -785,6 +785,10 @@ class StateCorpExchangeService:
if not case_number or decision_date is None:
continue
claim_amount = cls._serialize_decimal(record.amount)
if claim_amount is None:
claim_amount = str(payload.get("claim_amount") or "").strip() or None
items.append(
{
"organization_inn": cls._digits(record.inn),
@@ -795,6 +799,7 @@ class StateCorpExchangeService:
).strip(),
"status": str(payload.get("status") or record.status or "").strip(),
"decision_date": decision_date.isoformat(),
"claim_amount": claim_amount,
}
)
return items

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from apps.core.models import JobStatus
from apps.core.response import api_error_response, api_response
from apps.core.services import BackgroundJobService
from apps.parsers.models import (
@@ -46,7 +47,8 @@ SYSTEM_LOGS_TAG = "System Logs"
ACTIVE_JOB_STATUSES = {"pending", "started", "retry"}
SUCCESS_LOAD_STATUSES = {"success", "skipped"}
ERROR_LOAD_STATUSES = {"failed", "failure", "error"}
STALE_ACTIVE_MAX_AGE_MINUTES = 90
STALE_ACTIVE_MAX_AGE_MINUTES = 4 * 60
STALE_PENDING_MAX_AGE_MINUTES = 24 * 60
PARSING_SETTINGS_CACHE_KEY = "parsers:frontend_compat:parsing_settings"
PARSING_SETTINGS_FIELDS = {
@@ -297,15 +299,34 @@ def _active_tasks_for_definition(
for source_key in definition.source_keys
if source_key in PARSER_SOURCES
]
now = timezone.now()
active_cutoff = now - timedelta(
minutes=int(
getattr(
settings,
"PARSER_STALE_LOAD_MAX_AGE_MINUTES",
STALE_ACTIVE_MAX_AGE_MINUTES,
)
)
)
pending_cutoff = now - timedelta(
minutes=int(
getattr(
settings,
"PARSER_STALE_PENDING_JOB_MAX_AGE_MINUTES",
STALE_PENDING_MAX_AGE_MINUTES,
)
)
)
queryset = (
BackgroundJobService.get_queryset()
.filter(task_name__in=task_names)
.filter(
task_name__in=task_names,
status__in=ACTIVE_JOB_STATUSES,
)
.filter(
Q(started_at__isnull=False, started_at__gte=_stale_cutoff())
| Q(started_at__isnull=True, created_at__gte=_stale_cutoff())
Q(status=JobStatus.PENDING, created_at__gte=pending_cutoff)
| Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__gte=active_cutoff,
)
)
)
return [_serialize_active_job(job) for job in queryset.order_by("-created_at")[:10]]

View File

@@ -0,0 +1,39 @@
import json
from django.db import migrations
CLEANUP_TASK_NAME = "parser:cleanup-stale-loads"
ACTIVE_MAX_AGE_MINUTES = 4 * 60
PENDING_MAX_AGE_MINUTES = 24 * 60
def extend_stale_parser_job_timeouts(apps, schema_editor):
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=CLEANUP_TASK_NAME).update(
kwargs=json.dumps(
{
"max_age_minutes": ACTIVE_MAX_AGE_MINUTES,
"pending_max_age_minutes": PENDING_MAX_AGE_MINUTES,
}
)
)
def restore_stale_parser_job_timeouts(apps, schema_editor):
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=CLEANUP_TASK_NAME).update(
kwargs=json.dumps({"max_age_minutes": 90})
)
class Migration(migrations.Migration):
dependencies = [
("parsers", "0029_neutralize_external_collection_labels"),
]
operations = [
migrations.RunPython(
extend_stale_parser_job_timeouts,
reverse_code=restore_stale_parser_job_timeouts,
),
]

View File

@@ -945,6 +945,7 @@ class SourceCardItemSerializer(serializers.Serializer):
"""Подисточник внутри агрегированной карточки."""
code = serializers.CharField(read_only=True)
refresh_key = serializers.CharField(read_only=True)
title = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
parser_source = serializers.CharField(read_only=True, allow_null=True)

View File

@@ -419,11 +419,14 @@ class ParserLoadLogService(BaseService[ParserLoadLog]):
cls,
*,
max_age_minutes: int,
pending_max_age_minutes: int = 24 * 60,
) -> int:
"""Закрыть зависшие in_progress логи без живой свежей BackgroundJob."""
from apps.core.models import BackgroundJob, JobStatus
cutoff = timezone.now() - timedelta(minutes=max_age_minutes)
now = timezone.now()
cutoff = now - timedelta(minutes=max_age_minutes)
pending_cutoff = now - timedelta(minutes=pending_max_age_minutes)
stale_logs = list(
cls.model.objects.filter(
status=ParserLoadLog.Status.IN_PROGRESS,
@@ -442,8 +445,11 @@ class ParserLoadLogService(BaseService[ParserLoadLog]):
meta__source=log.source,
).filter(Q(meta__batch_id=log.batch_id) | Q(meta__batch_id__isnull=True))
fresh_jobs = active_jobs.filter(
Q(started_at__isnull=False, started_at__gte=cutoff)
| Q(started_at__isnull=True, created_at__gte=cutoff)
Q(status=JobStatus.PENDING, created_at__gte=pending_cutoff)
| Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__gte=cutoff,
)
)
if fresh_jobs.exists():
continue
@@ -451,8 +457,11 @@ class ParserLoadLogService(BaseService[ParserLoadLog]):
cls.mark_failed(log, stale_message)
updated += 1
stale_jobs = active_jobs.filter(
Q(started_at__isnull=False, started_at__lt=cutoff)
| Q(started_at__isnull=True, created_at__lt=cutoff)
Q(status=JobStatus.PENDING, created_at__lt=pending_cutoff)
| Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__lt=cutoff,
)
)
for job in stale_jobs.order_by("created_at"):
job.fail(error=stale_message)

View File

@@ -31,7 +31,8 @@ from rest_framework.exceptions import ValidationError
SUCCESSFUL_LOAD_STATUSES = {"success", "skipped"}
ACTIVE_JOB_STATUSES = [JobStatus.PENDING, JobStatus.STARTED, JobStatus.RETRY]
STALE_ACTIVE_MAX_AGE_MINUTES = 90
STALE_ACTIVE_MAX_AGE_MINUTES = 4 * 60
STALE_PENDING_MAX_AGE_MINUTES = 24 * 60
SOURCE_CARD_STATS_CACHE_TIMEOUT_SECONDS = 7 * 24 * 60 * 60
@@ -55,6 +56,7 @@ class SourceItemDefinition:
title: str
description: str
parser_source: str | None = None
refresh_key: str | None = None
@dataclass(frozen=True)
@@ -218,6 +220,7 @@ SOURCE_CARD_DEFINITIONS: tuple[SourceCardDefinition, ...] = (
"Реестр промышленной продукции, произведенной на территории РФ."
),
parser_source=ParserLoadLog.Source.INDUSTRIAL_PRODUCTS,
refresh_key="mpt_products",
),
SourceItemDefinition(
code="manufactures",
@@ -769,17 +772,12 @@ class SourceCardService:
active_tasks_by_slug: dict[str, list[dict[str, Any]]] = {
slug: [] for slug in task_names_by_slug
}
cutoff = cls._stale_cutoff()
queryset = (
BackgroundJobService.get_queryset()
.filter(
task_name__in=list(slugs_by_task_name),
status__in=ACTIVE_JOB_STATUSES,
)
.filter(
Q(started_at__isnull=False, started_at__gte=cutoff)
| Q(started_at__isnull=True, created_at__gte=cutoff)
)
.filter(cls._fresh_active_job_filter())
.order_by("-created_at")
)
@@ -1157,6 +1155,7 @@ class SourceCardService:
return {
"code": item.code,
"refresh_key": item.refresh_key or item.code,
"title": item.title,
"description": item.description,
"parser_source": item.parser_source,
@@ -1288,17 +1287,12 @@ class SourceCardService:
def _get_active_tasks(
cls, definition: SourceCardDefinition
) -> list[dict[str, Any]]:
cutoff = cls._stale_cutoff()
queryset = (
BackgroundJobService.get_queryset()
.filter(
task_name__in=definition.task_names,
status__in=ACTIVE_JOB_STATUSES,
)
.filter(
Q(started_at__isnull=False, started_at__gte=cutoff)
| Q(started_at__isnull=True, created_at__gte=cutoff)
)
.filter(cls._fresh_active_job_filter())
)
return [
cls._serialize_job(job) for job in queryset.order_by("-created_at")[:10]
@@ -1349,6 +1343,31 @@ class SourceCardService:
)
return timezone.now() - timedelta(minutes=max_age_minutes)
@classmethod
def _fresh_active_job_filter(cls) -> Q:
now = timezone.now()
active_max_age_minutes = int(
getattr(
settings,
"PARSER_STALE_LOAD_MAX_AGE_MINUTES",
STALE_ACTIVE_MAX_AGE_MINUTES,
)
)
pending_max_age_minutes = int(
getattr(
settings,
"PARSER_STALE_PENDING_JOB_MAX_AGE_MINUTES",
STALE_PENDING_MAX_AGE_MINUTES,
)
)
return Q(
status=JobStatus.PENDING,
created_at__gte=now - timedelta(minutes=pending_max_age_minutes),
) | Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__gte=now - timedelta(minutes=active_max_age_minutes),
)
@classmethod
def _is_stale_load(cls, latest_load: ParserLoadLog | None) -> bool:
if latest_load is None or latest_load.status != "in_progress":

View File

@@ -88,7 +88,8 @@ REGISTRY_INSPECTIONS_CHECKO_LIMIT = 1000
REGISTRY_CONTRACTS_CHECKO_LIMIT = 1000
REGISTRY_ENRICHMENT_BATCH_SIZE = 250
FSTEC_CHECKO_IDENTITY_LOOKUP_LIMIT = 1000
PARSER_STALE_LOAD_MAX_AGE_MINUTES = 90
PARSER_STALE_LOAD_MAX_AGE_MINUTES = 4 * 60
PARSER_STALE_PENDING_JOB_MAX_AGE_MINUTES = 24 * 60
PARSER_SOFT_TIME_LIMIT_SECONDS = 15 * 60
PARSER_TIME_LIMIT_SECONDS = 20 * 60
INDUSTRIAL_PRODUCTS_SOFT_TIME_LIMIT_SECONDS = 45 * 60
@@ -3604,7 +3605,10 @@ def parse_fstec_registers(
@shared_task
def cleanup_stale_parser_loads(max_age_minutes: int | None = None) -> dict:
def cleanup_stale_parser_loads(
max_age_minutes: int | None = None,
pending_max_age_minutes: int | None = None,
) -> dict:
"""Закрыть stale in_progress загрузки и jobs после рестартов worker/deploy."""
if max_age_minutes is None:
max_age_minutes = getattr(
@@ -3612,14 +3616,22 @@ def cleanup_stale_parser_loads(max_age_minutes: int | None = None) -> dict:
"PARSER_STALE_LOAD_MAX_AGE_MINUTES",
PARSER_STALE_LOAD_MAX_AGE_MINUTES,
)
if pending_max_age_minutes is None:
pending_max_age_minutes = getattr(
settings,
"PARSER_STALE_PENDING_JOB_MAX_AGE_MINUTES",
PARSER_STALE_PENDING_JOB_MAX_AGE_MINUTES,
)
source_values = {descriptor.source for descriptor in PARSER_SOURCES.values()}
task_names = {descriptor.task_name for descriptor in PARSER_SOURCES.values()}
task_names.add("apps.parsers.tasks.scan_fns_directory")
marked_failed = ParserLoadLogService.mark_stale_in_progress_failed(
max_age_minutes=int(max_age_minutes)
max_age_minutes=int(max_age_minutes),
pending_max_age_minutes=int(pending_max_age_minutes),
)
marked_jobs_failed = BackgroundJobService.mark_stale_active_jobs_failed(
max_age_minutes=int(max_age_minutes),
pending_max_age_minutes=int(pending_max_age_minutes),
task_names=task_names,
meta_sources=source_values,
)
@@ -3628,6 +3640,7 @@ def cleanup_stale_parser_loads(max_age_minutes: int | None = None) -> dict:
"marked_failed": marked_failed,
"marked_jobs_failed": marked_jobs_failed,
"max_age_minutes": int(max_age_minutes),
"pending_max_age_minutes": int(pending_max_age_minutes),
}

View File

@@ -151,6 +151,10 @@ TASKS_BY_NAME = {
"apps.parsers.tasks.parse_trudvsem_vacancies": tasks.parse_trudvsem_vacancies,
}
PARSER_SOURCE_ALIASES = {
"industrial_products": "mpt_products",
}
class MultipartFormSwaggerAutoSchema(SwaggerAutoSchema):
"""Document mixed JSON/multipart upload endpoints as form-data in Swagger."""
@@ -2642,21 +2646,25 @@ class ParserRunView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, source_key: str):
descriptor = PARSER_SOURCES.get(source_key)
canonical_source_key = PARSER_SOURCE_ALIASES.get(source_key, source_key)
descriptor = PARSER_SOURCES.get(canonical_source_key)
if descriptor is None:
return _source_not_found_response(source_key)
serializer = ParserRunRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
task = TASKS_BY_NAME[descriptor.task_name]
task_kwargs = build_task_kwargs(
source_key, serializer.validated_data, request.user.id
canonical_source_key, serializer.validated_data, request.user.id
)
task_id = str(uuid.uuid4())
BackgroundJobService.create_job(
task_id=task_id,
task_name=descriptor.task_name,
user_id=request.user.id,
meta={"source_key": source_key, "source": descriptor.source},
meta={
"source_key": canonical_source_key,
"source": descriptor.source,
},
)
async_result = task.apply_async(kwargs=task_kwargs, task_id=task_id)
return api_response(

View File

@@ -661,6 +661,7 @@ class TestCompanyDatasetService:
@staticmethod
def _refresh_financial_lines(*, record, index: int) -> None:
report_year = timezone.localdate().year
expected = {
("1", "1600", "Баланс (актив)", 10_000_000 + index * 100_000),
("1", "1300", "Капитал и резервы", 4_000_000 + index * 50_000),
@@ -669,12 +670,12 @@ class TestCompanyDatasetService:
}
expected_keys = []
for form_code, line_code, line_name, period_end in expected:
expected_keys.append((form_code, line_code, 2025))
expected_keys.append((form_code, line_code, report_year))
OrganizationSourceFinancialLine.objects.update_or_create(
source_record=record,
form_code=form_code,
line_code=line_code,
year=2025,
year=report_year,
defaults={
"line_name": line_name,
"period_start": period_end - 100_000,