stage-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/services/sync.py
ADDED
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from stage.classify import (
|
|
9
|
+
classify_role,
|
|
10
|
+
resolve_eligibility,
|
|
11
|
+
screen_degree_scope,
|
|
12
|
+
screen_is_cs_role,
|
|
13
|
+
screen_is_internship,
|
|
14
|
+
screen_location,
|
|
15
|
+
to_quarantined,
|
|
16
|
+
)
|
|
17
|
+
from stage.dedup import resolve_duplicates
|
|
18
|
+
from stage.domain import (
|
|
19
|
+
BucketPlan,
|
|
20
|
+
Company,
|
|
21
|
+
CompanyDeferred,
|
|
22
|
+
CompanyFailed,
|
|
23
|
+
CompanyFinished,
|
|
24
|
+
CompanyStarted,
|
|
25
|
+
CompanyUnchanged,
|
|
26
|
+
CompanyVisit,
|
|
27
|
+
DetailFetch,
|
|
28
|
+
HttpValidator,
|
|
29
|
+
Job,
|
|
30
|
+
PlannedRequest,
|
|
31
|
+
QuarantinedJob,
|
|
32
|
+
RateState,
|
|
33
|
+
RequestLogged,
|
|
34
|
+
Rotation,
|
|
35
|
+
RotationMember,
|
|
36
|
+
SourceBlocked,
|
|
37
|
+
SourceCapped,
|
|
38
|
+
SourceFailed,
|
|
39
|
+
SourceFinished,
|
|
40
|
+
SourceFresh,
|
|
41
|
+
SourceResting,
|
|
42
|
+
SourceRotated,
|
|
43
|
+
SourceRunStats,
|
|
44
|
+
SourceStarted,
|
|
45
|
+
SourceVisit,
|
|
46
|
+
SyncEvent,
|
|
47
|
+
SyncFinished,
|
|
48
|
+
SyncOutcome,
|
|
49
|
+
SyncRun,
|
|
50
|
+
SyncStarted,
|
|
51
|
+
UnroutableCompanies,
|
|
52
|
+
WorkdayCrawl,
|
|
53
|
+
WorkdayCrawlStep,
|
|
54
|
+
WorkdayFacet,
|
|
55
|
+
is_resting,
|
|
56
|
+
rotate,
|
|
57
|
+
)
|
|
58
|
+
from stage.http import (
|
|
59
|
+
CEILING_BACKSTOP,
|
|
60
|
+
HostBudget,
|
|
61
|
+
HostBudgetExceededError,
|
|
62
|
+
HttpClient,
|
|
63
|
+
HttpError,
|
|
64
|
+
RatePosture,
|
|
65
|
+
ValidatorCache,
|
|
66
|
+
resolve,
|
|
67
|
+
)
|
|
68
|
+
from stage.normalize import (
|
|
69
|
+
canonical_apply_url,
|
|
70
|
+
detect_language,
|
|
71
|
+
resolve_location,
|
|
72
|
+
resolve_term,
|
|
73
|
+
)
|
|
74
|
+
from stage.sources import (
|
|
75
|
+
Adapter,
|
|
76
|
+
FeedAdapter,
|
|
77
|
+
FetchResult,
|
|
78
|
+
adapter_for_platform,
|
|
79
|
+
get_adapters,
|
|
80
|
+
get_feeds,
|
|
81
|
+
)
|
|
82
|
+
from stage.sources.workday import WorkdayAdapter
|
|
83
|
+
from stage.storage import AsyncRepository, SourceBatch, SourceBatchResult
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NoSourcesSelectedError(Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bucket_postures(
|
|
91
|
+
grouped: Mapping[str, tuple[Adapter, list[Company]]], feeds: Mapping[str, FeedAdapter]
|
|
92
|
+
) -> dict[str, RatePosture]:
|
|
93
|
+
claims: list[tuple[tuple[str, ...], RatePosture]] = [
|
|
94
|
+
(
|
|
95
|
+
_adapter_buckets(adapter, companies),
|
|
96
|
+
resolve(
|
|
97
|
+
adapter.rate_profile, [company.rate_profile for company in companies]
|
|
98
|
+
).sized_for(len(companies), _reserve_for(adapter)),
|
|
99
|
+
)
|
|
100
|
+
for adapter, companies in grouped.values()
|
|
101
|
+
]
|
|
102
|
+
claims.extend(
|
|
103
|
+
(_bucket_keys(feed.hosts, feed.bucket_key), resolve(feed.rate_profile, []))
|
|
104
|
+
for feed in feeds.values()
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
postures: dict[str, RatePosture] = {}
|
|
108
|
+
for keys, posture in claims:
|
|
109
|
+
for bucket in keys:
|
|
110
|
+
stored = postures.get(bucket)
|
|
111
|
+
postures[bucket] = posture if stored is None else stored.strictest(posture)
|
|
112
|
+
return postures
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _bucket_keys(hosts: frozenset[str], bucket_key: str) -> tuple[str, ...]:
|
|
116
|
+
return (bucket_key,) if bucket_key else tuple(sorted(hosts))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _adapter_buckets(adapter: Adapter, companies: Sequence[Company]) -> tuple[str, ...]:
|
|
120
|
+
if adapter.bucket_key:
|
|
121
|
+
return (adapter.bucket_key,)
|
|
122
|
+
return tuple(sorted(adapter.hosts_for(companies) or adapter.hosts))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _plan_bounds(
|
|
126
|
+
adapter: Adapter,
|
|
127
|
+
source: str,
|
|
128
|
+
companies: Sequence[Company],
|
|
129
|
+
page_budgets: Mapping[str, int],
|
|
130
|
+
detail_budget: int,
|
|
131
|
+
) -> list[tuple[str, str, int, int]]:
|
|
132
|
+
members: dict[str, int] = {}
|
|
133
|
+
worst: dict[str, int] = {}
|
|
134
|
+
for company in companies:
|
|
135
|
+
for bucket in _adapter_buckets(adapter, [company]):
|
|
136
|
+
members[bucket] = members.get(bucket, 0) + 1
|
|
137
|
+
worst[bucket] = worst.get(bucket, 0) + page_budgets.get(company.registry_key, 0)
|
|
138
|
+
bounds = [(bucket, source, members[bucket], worst[bucket]) for bucket in sorted(members)]
|
|
139
|
+
if detail_budget and bounds:
|
|
140
|
+
bucket, name, count, total = bounds[0]
|
|
141
|
+
bounds[0] = (bucket, name, count, total + detail_budget)
|
|
142
|
+
return bounds
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _reserve_for(adapter: Adapter) -> int:
|
|
146
|
+
return getattr(adapter, "retry_reserve", 0) + adapter.detail_budget
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
DAILY_RUNS = 4
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _spent_today(repository: AsyncRepository, since: datetime) -> tuple[dict[str, int], bool]:
|
|
153
|
+
return await repository.requests_since(since)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _daily_allowance(posture: RatePosture, spent: int, has_history: bool, buckets: int = 1) -> int:
|
|
157
|
+
if not has_history:
|
|
158
|
+
return posture.max_requests_per_run
|
|
159
|
+
span = max(1, buckets)
|
|
160
|
+
cap = min(CEILING_BACKSTOP * 2, posture.max_requests_per_run * DAILY_RUNS) * span
|
|
161
|
+
return max(0, min(posture.max_requests_per_run, cap - spent))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _active_block(
|
|
165
|
+
rate_state: Mapping[str, RateState], buckets: Sequence[str], now: datetime
|
|
166
|
+
) -> RateState | None:
|
|
167
|
+
for bucket in buckets:
|
|
168
|
+
state = rate_state.get(bucket)
|
|
169
|
+
if state is not None and state.is_blocked(now):
|
|
170
|
+
return state
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _group_by_adapter(
|
|
175
|
+
companies: Sequence[Company],
|
|
176
|
+
) -> tuple[dict[str, tuple[Adapter, list[Company]]], list[Company]]:
|
|
177
|
+
grouped: dict[str, tuple[Adapter, list[Company]]] = {}
|
|
178
|
+
unroutable: list[Company] = []
|
|
179
|
+
for company in companies:
|
|
180
|
+
if not company.enabled:
|
|
181
|
+
continue
|
|
182
|
+
adapter = adapter_for_platform(company.platform)
|
|
183
|
+
if adapter is None:
|
|
184
|
+
unroutable.append(company)
|
|
185
|
+
continue
|
|
186
|
+
entry = grouped.setdefault(adapter.name, (adapter, []))
|
|
187
|
+
entry[1].append(company)
|
|
188
|
+
return grouped, unroutable
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _registry_boards(
|
|
192
|
+
companies: Sequence[Company],
|
|
193
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
194
|
+
boards: set[str] = set()
|
|
195
|
+
unreadable: set[str] = set()
|
|
196
|
+
covered: set[str] = set()
|
|
197
|
+
for company in companies:
|
|
198
|
+
adapter = adapter_for_platform(company.platform)
|
|
199
|
+
if adapter is None:
|
|
200
|
+
continue
|
|
201
|
+
covered.add(adapter.name)
|
|
202
|
+
try:
|
|
203
|
+
boards.add(adapter.board_key(company))
|
|
204
|
+
except Exception:
|
|
205
|
+
unreadable.add(adapter.name)
|
|
206
|
+
return tuple(sorted(covered - unreadable)), tuple(sorted(boards))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _select(
|
|
210
|
+
companies: Sequence[Company],
|
|
211
|
+
sources: Sequence[str] | None,
|
|
212
|
+
excluded: Sequence[str] | None = None,
|
|
213
|
+
) -> tuple[dict[str, tuple[Adapter, list[Company]]], dict[str, FeedAdapter], list[Company]]:
|
|
214
|
+
grouped, unroutable = _group_by_adapter(companies)
|
|
215
|
+
feeds = dict(get_feeds())
|
|
216
|
+
known = set(grouped) | set(feeds) | set(get_adapters())
|
|
217
|
+
if sources is not None:
|
|
218
|
+
selected = set(sources)
|
|
219
|
+
unknown = selected - known
|
|
220
|
+
if unknown:
|
|
221
|
+
raise NoSourcesSelectedError(
|
|
222
|
+
f"no enabled companies for source(s): {', '.join(sorted(unknown))}"
|
|
223
|
+
)
|
|
224
|
+
grouped = {name: value for name, value in grouped.items() if name in selected}
|
|
225
|
+
feeds = {name: value for name, value in feeds.items() if name in selected}
|
|
226
|
+
if excluded:
|
|
227
|
+
dropped = set(excluded)
|
|
228
|
+
unknown = dropped - known
|
|
229
|
+
if unknown:
|
|
230
|
+
raise NoSourcesSelectedError(
|
|
231
|
+
f"unknown source(s) to exclude: {', '.join(sorted(unknown))}"
|
|
232
|
+
)
|
|
233
|
+
grouped = {name: value for name, value in grouped.items() if name not in dropped}
|
|
234
|
+
feeds = {name: value for name, value in feeds.items() if name not in dropped}
|
|
235
|
+
if not grouped and not feeds:
|
|
236
|
+
detail = ""
|
|
237
|
+
if unroutable:
|
|
238
|
+
platforms = ", ".join(sorted({company.platform.value for company in unroutable}))
|
|
239
|
+
detail = f" ({len(unroutable)} enabled row(s) sit on unrouted platform(s): {platforms})"
|
|
240
|
+
raise NoSourcesSelectedError(
|
|
241
|
+
f"the registry has no enabled companies with a matching adapter{detail}"
|
|
242
|
+
)
|
|
243
|
+
return grouped, feeds, unroutable
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def normalize_batch(jobs: Sequence[Job]) -> tuple[tuple[Job, ...], tuple[QuarantinedJob, ...]]:
|
|
247
|
+
kept: list[Job] = []
|
|
248
|
+
rejected: list[QuarantinedJob] = []
|
|
249
|
+
for job in jobs:
|
|
250
|
+
location = resolve_location(job.location_raw)
|
|
251
|
+
term = resolve_term(
|
|
252
|
+
title=job.title_raw,
|
|
253
|
+
description=job.description,
|
|
254
|
+
structured_terms=job.signals.terms,
|
|
255
|
+
structured_season=job.signals.season,
|
|
256
|
+
)
|
|
257
|
+
role = classify_role(job.title_raw, job.description, job.signals.category)
|
|
258
|
+
language = detect_language(job.title_raw, job.description)
|
|
259
|
+
eligibility = resolve_eligibility(job)
|
|
260
|
+
normalized = replace(
|
|
261
|
+
job,
|
|
262
|
+
apply_url_canonical=canonical_apply_url(job.apply_url_raw),
|
|
263
|
+
location=location.bucket,
|
|
264
|
+
remote_scope=location.remote_scope,
|
|
265
|
+
term=term.term,
|
|
266
|
+
role=role.role,
|
|
267
|
+
language=language.language,
|
|
268
|
+
degree_requirement=eligibility.degree_requirement,
|
|
269
|
+
work_auth_flag=eligibility.work_auth_flag,
|
|
270
|
+
)
|
|
271
|
+
rejection = (
|
|
272
|
+
screen_is_internship(normalized)
|
|
273
|
+
or screen_location(normalized)
|
|
274
|
+
or screen_degree_scope(normalized)
|
|
275
|
+
or screen_is_cs_role(normalized)
|
|
276
|
+
)
|
|
277
|
+
if rejection is None:
|
|
278
|
+
kept.append(normalized)
|
|
279
|
+
else:
|
|
280
|
+
rejected.append(to_quarantined(normalized, rejection))
|
|
281
|
+
return tuple(kept), tuple(rejected)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _fetch_company(
|
|
285
|
+
adapter: Adapter,
|
|
286
|
+
company: Company,
|
|
287
|
+
client: HttpClient,
|
|
288
|
+
now: datetime,
|
|
289
|
+
facets: Mapping[tuple[str, str], WorkdayFacet],
|
|
290
|
+
details: Sequence[str],
|
|
291
|
+
crawls: Mapping[str, WorkdayCrawl],
|
|
292
|
+
page_budget: int,
|
|
293
|
+
final_workday_pass: bool,
|
|
294
|
+
) -> tuple[Company, FetchResult | None, str, float, bool, bool]:
|
|
295
|
+
started = time.perf_counter()
|
|
296
|
+
try:
|
|
297
|
+
if isinstance(adapter, WorkdayAdapter):
|
|
298
|
+
result = await adapter.fetch(
|
|
299
|
+
company,
|
|
300
|
+
client,
|
|
301
|
+
now,
|
|
302
|
+
facets,
|
|
303
|
+
details,
|
|
304
|
+
crawl=crawls.get(_safe_board_key(adapter, company)),
|
|
305
|
+
page_budget=page_budget,
|
|
306
|
+
)
|
|
307
|
+
crawl = result.workday_crawl
|
|
308
|
+
if (
|
|
309
|
+
final_workday_pass
|
|
310
|
+
and crawl is not None
|
|
311
|
+
and not crawl.complete
|
|
312
|
+
and not crawl.discard
|
|
313
|
+
):
|
|
314
|
+
message = (
|
|
315
|
+
"resumed once and still incomplete; postings stay open, crawl state discarded"
|
|
316
|
+
)
|
|
317
|
+
result = replace(
|
|
318
|
+
result,
|
|
319
|
+
degraded=f"{result.degraded}; {message}" if result.degraded else message,
|
|
320
|
+
workday_crawl=replace(crawl, discard=True),
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
result = await adapter.fetch(company, client, now, facets, details)
|
|
324
|
+
except HostBudgetExceededError as exc:
|
|
325
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
326
|
+
return company, None, f"{type(exc).__name__}: {exc}", elapsed, False, True
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
329
|
+
unreachable = isinstance(exc, HttpError)
|
|
330
|
+
return company, None, f"{type(exc).__name__}: {exc}", elapsed, True, unreachable
|
|
331
|
+
return company, result, "", (time.perf_counter() - started) * 1000, True, False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _keepable_validators(cache: ValidatorCache, skip_urls: set[str]) -> tuple[HttpValidator, ...]:
|
|
335
|
+
return tuple(validator for url, validator in cache.pending.items() if url not in skip_urls)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _advance_cursor(
|
|
339
|
+
settled: Sequence[RateState], rotation: Rotation, bucket: str, now: datetime
|
|
340
|
+
) -> tuple[RateState, ...]:
|
|
341
|
+
if not rotation.rotating:
|
|
342
|
+
return tuple(
|
|
343
|
+
state.with_cursor("", now)
|
|
344
|
+
if state.bucket == bucket and state.rotation_cursor
|
|
345
|
+
else state
|
|
346
|
+
for state in settled
|
|
347
|
+
)
|
|
348
|
+
updated = [
|
|
349
|
+
state.with_cursor(rotation.cursor, now) if state.bucket == bucket else state
|
|
350
|
+
for state in settled
|
|
351
|
+
]
|
|
352
|
+
if not any(state.bucket == bucket for state in updated):
|
|
353
|
+
updated.append(RateState(bucket=bucket, updated_at=now, rotation_cursor=rotation.cursor))
|
|
354
|
+
return tuple(updated)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _bucket_plans(
|
|
358
|
+
bounds: Sequence[tuple[str, str, int, int]], postures: Mapping[str, RatePosture]
|
|
359
|
+
) -> tuple[BucketPlan, ...]:
|
|
360
|
+
planned: dict[str, int] = {}
|
|
361
|
+
worst: dict[str, int] = {}
|
|
362
|
+
sources: dict[str, list[str]] = {}
|
|
363
|
+
for bucket, source, companies, worst_case in bounds:
|
|
364
|
+
planned[bucket] = planned.get(bucket, 0) + companies
|
|
365
|
+
worst[bucket] = worst.get(bucket, 0) + worst_case
|
|
366
|
+
sources.setdefault(bucket, []).append(source)
|
|
367
|
+
return tuple(
|
|
368
|
+
BucketPlan(
|
|
369
|
+
bucket=bucket,
|
|
370
|
+
sources=tuple(sorted(sources[bucket])),
|
|
371
|
+
planned=planned[bucket],
|
|
372
|
+
worst_case=worst[bucket],
|
|
373
|
+
ceiling=postures[bucket].max_requests_per_run
|
|
374
|
+
if bucket in postures
|
|
375
|
+
else RatePosture().max_requests_per_run,
|
|
376
|
+
)
|
|
377
|
+
for bucket in sorted(planned)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _safe_plan(adapter: Adapter, company: Company) -> tuple[tuple[str, ...], str]:
|
|
382
|
+
try:
|
|
383
|
+
return adapter.plan(company), ""
|
|
384
|
+
except Exception as exc:
|
|
385
|
+
return (), f"{type(exc).__name__}: {exc}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _is_resting(
|
|
389
|
+
adapter: Adapter,
|
|
390
|
+
company: Company,
|
|
391
|
+
visits: Mapping[str, SourceVisit],
|
|
392
|
+
now: datetime,
|
|
393
|
+
) -> bool:
|
|
394
|
+
visit = visits.get(_safe_board_key(adapter, company))
|
|
395
|
+
return visit is not None and is_resting(visit, now)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _partial_reason(
|
|
399
|
+
failed_sources: Sequence[str],
|
|
400
|
+
unroutable: Sequence[Company],
|
|
401
|
+
blocked_sources: Sequence[str],
|
|
402
|
+
deferred_sources: Sequence[str],
|
|
403
|
+
) -> str:
|
|
404
|
+
reasons = []
|
|
405
|
+
if failed_sources:
|
|
406
|
+
reasons.append(f"{len(failed_sources)} source(s) failed")
|
|
407
|
+
if blocked_sources:
|
|
408
|
+
reasons.append(f"{len(blocked_sources)} source(s) blocked")
|
|
409
|
+
if deferred_sources:
|
|
410
|
+
reasons.append(f"{len(deferred_sources)} source(s) stopped on their host budget")
|
|
411
|
+
if unroutable:
|
|
412
|
+
reasons.append(f"{len(unroutable)} row(s) no adapter could route")
|
|
413
|
+
return "; ".join(reasons)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _stalest_first(
|
|
417
|
+
adapter: Adapter, companies: Sequence[Company], last_success: Mapping[str, datetime | None]
|
|
418
|
+
) -> list[Company]:
|
|
419
|
+
def age(company: Company) -> tuple[int, datetime]:
|
|
420
|
+
seen = last_success.get(_safe_board_key(adapter, company))
|
|
421
|
+
return (1, seen) if seen is not None else (0, datetime.min.replace(tzinfo=UTC))
|
|
422
|
+
|
|
423
|
+
return sorted(companies, key=age)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _safe_board_key(adapter: Adapter, company: Company) -> str:
|
|
427
|
+
try:
|
|
428
|
+
return adapter.board_key(company)
|
|
429
|
+
except Exception:
|
|
430
|
+
return f"<unresolved>:{company.registry_key}"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _drain(client: HttpClient, source: str) -> list[RequestLogged]:
|
|
434
|
+
return [
|
|
435
|
+
RequestLogged(
|
|
436
|
+
source=source,
|
|
437
|
+
method=record.method,
|
|
438
|
+
url=record.url,
|
|
439
|
+
status=record.status,
|
|
440
|
+
elapsed_ms=record.elapsed_ms,
|
|
441
|
+
attempt=record.attempt,
|
|
442
|
+
error=record.error,
|
|
443
|
+
)
|
|
444
|
+
for record in client.drain_log()
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass(slots=True)
|
|
449
|
+
class FetchHarvest:
|
|
450
|
+
collected: list[Job] = field(default_factory=list)
|
|
451
|
+
closable: list[str] = field(default_factory=list)
|
|
452
|
+
unchanged: list[str] = field(default_factory=list)
|
|
453
|
+
skip_urls: set[str] = field(default_factory=set)
|
|
454
|
+
visits: list[CompanyVisit] = field(default_factory=list)
|
|
455
|
+
resolved_facets: list[WorkdayFacet] = field(default_factory=list)
|
|
456
|
+
forgotten_facets: list[WorkdayFacet] = field(default_factory=list)
|
|
457
|
+
workday_crawls: list[WorkdayCrawlStep] = field(default_factory=list)
|
|
458
|
+
detail_outcomes: list[DetailFetch] = field(default_factory=list)
|
|
459
|
+
errors: int = 0
|
|
460
|
+
deferred_boards: int = 0
|
|
461
|
+
succeeded: bool = False
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@dataclass(slots=True)
|
|
465
|
+
class RunLedger:
|
|
466
|
+
stats: list[SourceRunStats] = field(default_factory=list)
|
|
467
|
+
failed: list[str] = field(default_factory=list)
|
|
468
|
+
succeeded: list[bool] = field(default_factory=list)
|
|
469
|
+
blocked: list[str] = field(default_factory=list)
|
|
470
|
+
deferred: list[str] = field(default_factory=list)
|
|
471
|
+
plan_bounds: list[tuple[str, str, int, int]] = field(default_factory=list)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
async def _merge(
|
|
475
|
+
streams: Sequence[tuple[str, AsyncIterator[SyncEvent]]],
|
|
476
|
+
ledger: "RunLedger",
|
|
477
|
+
) -> AsyncIterator[SyncEvent]:
|
|
478
|
+
if len(streams) == 1:
|
|
479
|
+
source, stream = streams[0]
|
|
480
|
+
try:
|
|
481
|
+
async for event in stream:
|
|
482
|
+
yield event
|
|
483
|
+
except asyncio.CancelledError:
|
|
484
|
+
raise
|
|
485
|
+
except Exception as stream_exc:
|
|
486
|
+
error = f"{type(stream_exc).__name__}: {stream_exc}"
|
|
487
|
+
ledger.failed.append(source)
|
|
488
|
+
ledger.stats.append(SourceRunStats(source=source, errors=1))
|
|
489
|
+
yield SourceFailed(source=source, error=error)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
queue: asyncio.Queue[SyncEvent | tuple[str, Exception] | None] = asyncio.Queue()
|
|
493
|
+
|
|
494
|
+
async def pump(source: str, stream: AsyncIterator[SyncEvent]) -> None:
|
|
495
|
+
try:
|
|
496
|
+
async for event in stream:
|
|
497
|
+
await queue.put(event)
|
|
498
|
+
except asyncio.CancelledError:
|
|
499
|
+
raise
|
|
500
|
+
except Exception as exc:
|
|
501
|
+
await queue.put((source, exc))
|
|
502
|
+
finally:
|
|
503
|
+
queue.put_nowait(None)
|
|
504
|
+
|
|
505
|
+
tasks = [asyncio.create_task(pump(source, stream)) for source, stream in streams]
|
|
506
|
+
try:
|
|
507
|
+
done = 0
|
|
508
|
+
while done < len(tasks):
|
|
509
|
+
item = await queue.get()
|
|
510
|
+
if item is None:
|
|
511
|
+
done += 1
|
|
512
|
+
elif isinstance(item, tuple):
|
|
513
|
+
source, queued_exc = item
|
|
514
|
+
error = f"{type(queued_exc).__name__}: {queued_exc}"
|
|
515
|
+
ledger.failed.append(source)
|
|
516
|
+
ledger.stats.append(SourceRunStats(source=source, errors=1))
|
|
517
|
+
yield SourceFailed(source=source, error=error)
|
|
518
|
+
else:
|
|
519
|
+
yield item
|
|
520
|
+
finally:
|
|
521
|
+
for task in tasks:
|
|
522
|
+
task.cancel()
|
|
523
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def _plan_company_source(
|
|
527
|
+
adapter: Adapter, companies: Sequence[Company], validators: dict[str, HttpValidator]
|
|
528
|
+
) -> AsyncIterator[SyncEvent]:
|
|
529
|
+
for company in companies:
|
|
530
|
+
urls, error = _safe_plan(adapter, company)
|
|
531
|
+
if error:
|
|
532
|
+
yield CompanyFailed(
|
|
533
|
+
source=adapter.name, company=company.name, error=error, elapsed_ms=0.0
|
|
534
|
+
)
|
|
535
|
+
continue
|
|
536
|
+
for url in urls:
|
|
537
|
+
cached = validators.get(url)
|
|
538
|
+
has_validator = cached is not None and cached.usable
|
|
539
|
+
yield PlannedRequest(
|
|
540
|
+
source=adapter.name,
|
|
541
|
+
company=company.name,
|
|
542
|
+
url=url,
|
|
543
|
+
has_validator=has_validator,
|
|
544
|
+
expectation="likely 304 (validator on file)" if has_validator else "full response",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def _rehearse_company_source(
|
|
549
|
+
adapter: Adapter,
|
|
550
|
+
source_name: str,
|
|
551
|
+
ordered: Sequence[Company],
|
|
552
|
+
seed: dict[str, HttpValidator],
|
|
553
|
+
ledger: "RunLedger",
|
|
554
|
+
page_budgets: Mapping[str, int],
|
|
555
|
+
detail_budget: int,
|
|
556
|
+
source_clock: float,
|
|
557
|
+
) -> AsyncIterator[SyncEvent]:
|
|
558
|
+
ledger.plan_bounds.extend(
|
|
559
|
+
_plan_bounds(adapter, source_name, ordered, page_budgets, detail_budget)
|
|
560
|
+
)
|
|
561
|
+
async for event in _plan_company_source(adapter, ordered, seed):
|
|
562
|
+
yield event
|
|
563
|
+
yield SourceFinished(
|
|
564
|
+
source=source_name,
|
|
565
|
+
fetched=0,
|
|
566
|
+
added=0,
|
|
567
|
+
updated=0,
|
|
568
|
+
closed=0,
|
|
569
|
+
failed_companies=0,
|
|
570
|
+
elapsed_ms=(time.perf_counter() - source_clock) * 1000,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _drop_recently_refreshed(
|
|
575
|
+
adapter: Adapter,
|
|
576
|
+
companies: Sequence[Company],
|
|
577
|
+
last_success: Mapping[str, datetime | None],
|
|
578
|
+
open_crawls: frozenset[str],
|
|
579
|
+
run_started_at: datetime,
|
|
580
|
+
window_h: float,
|
|
581
|
+
skip: bool,
|
|
582
|
+
) -> tuple[list[Company], int]:
|
|
583
|
+
if skip or window_h <= 0:
|
|
584
|
+
return list(companies), 0
|
|
585
|
+
since = run_started_at - timedelta(hours=window_h)
|
|
586
|
+
kept = [
|
|
587
|
+
company
|
|
588
|
+
for company in companies
|
|
589
|
+
if _safe_board_key(adapter, company) in open_crawls
|
|
590
|
+
or (seen := last_success.get(_safe_board_key(adapter, company))) is None
|
|
591
|
+
or seen < since
|
|
592
|
+
]
|
|
593
|
+
return kept, len(companies) - len(kept)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _drop_resting(
|
|
597
|
+
adapter: Adapter,
|
|
598
|
+
companies: Sequence[Company],
|
|
599
|
+
board_visits: Mapping[str, SourceVisit],
|
|
600
|
+
run_started_at: datetime,
|
|
601
|
+
skip: bool,
|
|
602
|
+
) -> tuple[list[Company], int]:
|
|
603
|
+
if skip:
|
|
604
|
+
return list(companies), 0
|
|
605
|
+
awake = [
|
|
606
|
+
company
|
|
607
|
+
for company in companies
|
|
608
|
+
if not _is_resting(adapter, company, board_visits, run_started_at)
|
|
609
|
+
]
|
|
610
|
+
return awake, len(companies) - len(awake)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
async def _persist_source_batch(
|
|
614
|
+
repository: AsyncRepository,
|
|
615
|
+
template: SourceBatch,
|
|
616
|
+
collected: Sequence[Job],
|
|
617
|
+
) -> tuple[SourceBatchResult, float, float]:
|
|
618
|
+
normalize_clock = time.perf_counter()
|
|
619
|
+
kept, rejected = await asyncio.to_thread(normalize_batch, list(collected))
|
|
620
|
+
normalize_ms = (time.perf_counter() - normalize_clock) * 1000
|
|
621
|
+
write_clock = time.perf_counter()
|
|
622
|
+
counts = await repository.apply_source_batch(replace(template, jobs=kept, quarantined=rejected))
|
|
623
|
+
return counts, normalize_ms, (time.perf_counter() - write_clock) * 1000
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _run_company_source(
|
|
627
|
+
repository: AsyncRepository,
|
|
628
|
+
adapter: Adapter,
|
|
629
|
+
companies: Sequence[Company],
|
|
630
|
+
run_started_at: datetime,
|
|
631
|
+
shuffler: random.Random,
|
|
632
|
+
dry_run: bool,
|
|
633
|
+
ledger: "RunLedger",
|
|
634
|
+
rate_state: Mapping[str, RateState],
|
|
635
|
+
budgets: dict[str, HostBudget],
|
|
636
|
+
postures: Mapping[str, RatePosture],
|
|
637
|
+
force_refresh: bool,
|
|
638
|
+
spent_today: Mapping[str, int],
|
|
639
|
+
has_history: bool,
|
|
640
|
+
) -> AsyncIterator[SyncEvent]:
|
|
641
|
+
source_name = adapter.name
|
|
642
|
+
buckets = _adapter_buckets(adapter, companies)
|
|
643
|
+
block = _active_block(rate_state, buckets, run_started_at)
|
|
644
|
+
if block is not None:
|
|
645
|
+
ledger.blocked.append(source_name)
|
|
646
|
+
if not dry_run:
|
|
647
|
+
ledger.stats.append(SourceRunStats(source=source_name, blocked=True))
|
|
648
|
+
yield SourceBlocked(
|
|
649
|
+
source=source_name,
|
|
650
|
+
bucket=block.bucket,
|
|
651
|
+
blocked_until=block.blocked_until or run_started_at,
|
|
652
|
+
remaining_s=block.blocks_remaining_s(run_started_at),
|
|
653
|
+
reason=block.reason,
|
|
654
|
+
consecutive_failures=block.consecutive_failures,
|
|
655
|
+
)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
rotation_bucket = buckets[0]
|
|
659
|
+
stored = rate_state.get(rotation_bucket)
|
|
660
|
+
workday_adapter = adapter if isinstance(adapter, WorkdayAdapter) else None
|
|
661
|
+
is_workday = workday_adapter is not None
|
|
662
|
+
window_h = postures.get(rotation_bucket, RatePosture()).refresh_interval_h
|
|
663
|
+
board_visits = {
|
|
664
|
+
visit.board: visit for visit in await repository.all_visits() if visit.source == source_name
|
|
665
|
+
}
|
|
666
|
+
last_success = {board: visit.last_success_at for board, visit in board_visits.items()}
|
|
667
|
+
rotation = rotate(
|
|
668
|
+
[RotationMember(key=company.registry_key) for company in companies],
|
|
669
|
+
cursor=stored.rotation_cursor if stored is not None else "",
|
|
670
|
+
budget=adapter.rotation_slice,
|
|
671
|
+
)
|
|
672
|
+
covered = set(rotation.selected)
|
|
673
|
+
ordered = [company for company in companies if company.registry_key in covered]
|
|
674
|
+
source_clock = time.perf_counter()
|
|
675
|
+
fetch_clock = time.perf_counter()
|
|
676
|
+
|
|
677
|
+
open_crawls = (
|
|
678
|
+
frozenset(await repository.load_workday_crawls())
|
|
679
|
+
if workday_adapter is not None
|
|
680
|
+
else frozenset()
|
|
681
|
+
)
|
|
682
|
+
skip_refresh = force_refresh or dry_run
|
|
683
|
+
ordered, refreshed_recently = _drop_recently_refreshed(
|
|
684
|
+
adapter, ordered, last_success, open_crawls, run_started_at, window_h, skip_refresh
|
|
685
|
+
)
|
|
686
|
+
ordered, resting_boards = _drop_resting(
|
|
687
|
+
adapter, ordered, board_visits, run_started_at, skip_refresh
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
seed = dict(await repository.load_validators(source_name))
|
|
691
|
+
resuming_boards: frozenset[str] = frozenset()
|
|
692
|
+
base = postures.get(rotation_bucket) or resolve(
|
|
693
|
+
adapter.rate_profile, [company.rate_profile for company in ordered]
|
|
694
|
+
)
|
|
695
|
+
allowance = _daily_allowance(
|
|
696
|
+
base, spent_today.get(source_name, 0), has_history, buckets=len(buckets)
|
|
697
|
+
)
|
|
698
|
+
posture = replace(base, max_requests_per_run=allowance)
|
|
699
|
+
if workday_adapter is not None:
|
|
700
|
+
facets = await repository.load_workday_facets()
|
|
701
|
+
crawls = await repository.load_workday_crawls()
|
|
702
|
+
resuming_boards = frozenset(crawls)
|
|
703
|
+
page_budgets, detail_budget = (
|
|
704
|
+
workday_adapter.crawl_budgets(ordered, crawls, facets, posture.max_requests_per_run)
|
|
705
|
+
if ordered
|
|
706
|
+
else ({}, 0)
|
|
707
|
+
)
|
|
708
|
+
else:
|
|
709
|
+
page_budgets = {
|
|
710
|
+
company.registry_key: adapter.max_requests_per_company for company in ordered
|
|
711
|
+
}
|
|
712
|
+
detail_budget = adapter.detail_budget
|
|
713
|
+
facets = {}
|
|
714
|
+
crawls = {}
|
|
715
|
+
shuffler.shuffle(ordered)
|
|
716
|
+
ordered = _stalest_first(adapter, ordered, last_success)
|
|
717
|
+
yield SourceStarted(source=source_name, companies=len(ordered))
|
|
718
|
+
if allowance < base.max_requests_per_run:
|
|
719
|
+
yield SourceCapped(
|
|
720
|
+
source=source_name,
|
|
721
|
+
bucket=rotation_bucket,
|
|
722
|
+
spent=spent_today.get(source_name, 0),
|
|
723
|
+
allowance=allowance,
|
|
724
|
+
ceiling=base.max_requests_per_run,
|
|
725
|
+
)
|
|
726
|
+
if refreshed_recently:
|
|
727
|
+
yield SourceFresh(
|
|
728
|
+
source=source_name,
|
|
729
|
+
skipped=refreshed_recently,
|
|
730
|
+
remaining=len(ordered),
|
|
731
|
+
refresh_interval_h=window_h,
|
|
732
|
+
)
|
|
733
|
+
if resting_boards:
|
|
734
|
+
yield SourceResting(source=source_name, skipped=resting_boards, remaining=len(ordered))
|
|
735
|
+
if rotation.rotating:
|
|
736
|
+
yield SourceRotated(
|
|
737
|
+
source=source_name,
|
|
738
|
+
bucket=rotation_bucket,
|
|
739
|
+
selected=len(rotation.selected),
|
|
740
|
+
deferred=len(rotation.deferred),
|
|
741
|
+
cursor=rotation.cursor,
|
|
742
|
+
wrapped=rotation.wrapped,
|
|
743
|
+
)
|
|
744
|
+
detail_queue = await repository.detail_queue(source_name, detail_budget)
|
|
745
|
+
|
|
746
|
+
if dry_run:
|
|
747
|
+
async for event in _rehearse_company_source(
|
|
748
|
+
adapter,
|
|
749
|
+
source_name,
|
|
750
|
+
ordered,
|
|
751
|
+
seed,
|
|
752
|
+
ledger,
|
|
753
|
+
page_budgets,
|
|
754
|
+
detail_budget if is_workday else 0,
|
|
755
|
+
source_clock,
|
|
756
|
+
):
|
|
757
|
+
yield event
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
cache = ValidatorCache(seed)
|
|
761
|
+
harvest = FetchHarvest()
|
|
762
|
+
|
|
763
|
+
async with HttpClient(
|
|
764
|
+
allowed_hosts=adapter.hosts_for(ordered),
|
|
765
|
+
posture=posture,
|
|
766
|
+
cache=cache,
|
|
767
|
+
rng=shuffler,
|
|
768
|
+
bucket_key=adapter.bucket_key,
|
|
769
|
+
rate_state=rate_state,
|
|
770
|
+
now=run_started_at,
|
|
771
|
+
budgets=budgets,
|
|
772
|
+
postures={**postures, rotation_bucket: posture},
|
|
773
|
+
) as client:
|
|
774
|
+
pending = [
|
|
775
|
+
_fetch_company(
|
|
776
|
+
adapter,
|
|
777
|
+
company,
|
|
778
|
+
client,
|
|
779
|
+
run_started_at,
|
|
780
|
+
facets,
|
|
781
|
+
detail_queue,
|
|
782
|
+
crawls,
|
|
783
|
+
page_budgets[company.registry_key],
|
|
784
|
+
_safe_board_key(adapter, company) in resuming_boards,
|
|
785
|
+
)
|
|
786
|
+
for company in ordered
|
|
787
|
+
]
|
|
788
|
+
for company in ordered:
|
|
789
|
+
yield CompanyStarted(source=source_name, company=company.name)
|
|
790
|
+
|
|
791
|
+
for finished in asyncio.as_completed(pending):
|
|
792
|
+
company, result, error, elapsed, attempted, unreachable = await finished
|
|
793
|
+
for record in _drain(client, source_name):
|
|
794
|
+
yield record
|
|
795
|
+
board = _safe_board_key(adapter, company)
|
|
796
|
+
if result is None and not attempted:
|
|
797
|
+
harvest.skip_urls.update(_safe_plan(adapter, company)[0])
|
|
798
|
+
harvest.deferred_boards += 1
|
|
799
|
+
yield CompanyDeferred(source=source_name, company=company.name, reason=error)
|
|
800
|
+
elif result is None:
|
|
801
|
+
harvest.errors += 1
|
|
802
|
+
harvest.skip_urls.update(_safe_plan(adapter, company)[0])
|
|
803
|
+
harvest.visits.append(
|
|
804
|
+
CompanyVisit(board=board, succeeded=False, error=error, label=company.name)
|
|
805
|
+
)
|
|
806
|
+
yield CompanyFailed(
|
|
807
|
+
source=source_name,
|
|
808
|
+
company=company.name,
|
|
809
|
+
error=error,
|
|
810
|
+
elapsed_ms=elapsed,
|
|
811
|
+
unreachable=unreachable,
|
|
812
|
+
)
|
|
813
|
+
elif result.not_modified:
|
|
814
|
+
harvest.visits.append(CompanyVisit(board=board, succeeded=True, label=company.name))
|
|
815
|
+
harvest.unchanged.append(board)
|
|
816
|
+
yield CompanyUnchanged(source=source_name, company=company.name, elapsed_ms=elapsed)
|
|
817
|
+
else:
|
|
818
|
+
harvest.succeeded = True
|
|
819
|
+
harvest.visits.append(CompanyVisit(board=board, succeeded=True, label=company.name))
|
|
820
|
+
harvest.collected.extend(result.jobs)
|
|
821
|
+
harvest.resolved_facets.extend(
|
|
822
|
+
entry for entry in result.facets if isinstance(entry, WorkdayFacet)
|
|
823
|
+
)
|
|
824
|
+
harvest.forgotten_facets.extend(
|
|
825
|
+
entry for entry in result.forgotten_facets if isinstance(entry, WorkdayFacet)
|
|
826
|
+
)
|
|
827
|
+
harvest.detail_outcomes.extend(
|
|
828
|
+
entry for entry in result.detail_fetches if isinstance(entry, DetailFetch)
|
|
829
|
+
)
|
|
830
|
+
if isinstance(result.workday_crawl, WorkdayCrawlStep):
|
|
831
|
+
harvest.workday_crawls.append(result.workday_crawl)
|
|
832
|
+
if result.authoritative:
|
|
833
|
+
harvest.closable.append(board)
|
|
834
|
+
if result.degraded:
|
|
835
|
+
harvest.skip_urls.update(_safe_plan(adapter, company)[0])
|
|
836
|
+
harvest.skip_urls.update(result.stale_urls)
|
|
837
|
+
yield CompanyFinished(
|
|
838
|
+
source=source_name,
|
|
839
|
+
company=company.name,
|
|
840
|
+
fetched=len(result.jobs),
|
|
841
|
+
elapsed_ms=elapsed,
|
|
842
|
+
degraded=result.degraded,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
metrics = _client_metrics(client)
|
|
846
|
+
validators = _keepable_validators(cache, harvest.skip_urls)
|
|
847
|
+
settled = _advance_cursor(
|
|
848
|
+
client.rate_state(run_started_at), rotation, rotation_bucket, run_started_at
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
fetch_ms = (time.perf_counter() - fetch_clock) * 1000
|
|
852
|
+
counts, normalize_ms, write_ms = await _persist_source_batch(
|
|
853
|
+
repository,
|
|
854
|
+
SourceBatch(
|
|
855
|
+
source=source_name,
|
|
856
|
+
run_started_at=run_started_at,
|
|
857
|
+
jobs=(),
|
|
858
|
+
closable_boards=tuple(harvest.closable),
|
|
859
|
+
unchanged_boards=tuple(harvest.unchanged),
|
|
860
|
+
validators=validators,
|
|
861
|
+
rate_state=settled,
|
|
862
|
+
workday_facets=tuple(harvest.resolved_facets),
|
|
863
|
+
forgotten_facets=tuple(harvest.forgotten_facets),
|
|
864
|
+
workday_crawls=tuple(harvest.workday_crawls),
|
|
865
|
+
detail_fetches=tuple(harvest.detail_outcomes),
|
|
866
|
+
visits=tuple(harvest.visits),
|
|
867
|
+
resolve_duplicates=resolve_duplicates,
|
|
868
|
+
),
|
|
869
|
+
harvest.collected,
|
|
870
|
+
)
|
|
871
|
+
if harvest.succeeded or harvest.unchanged:
|
|
872
|
+
ledger.succeeded.append(True)
|
|
873
|
+
if harvest.errors:
|
|
874
|
+
ledger.failed.append(source_name)
|
|
875
|
+
if harvest.deferred_boards:
|
|
876
|
+
ledger.deferred.append(source_name)
|
|
877
|
+
|
|
878
|
+
elapsed_ms = (time.perf_counter() - source_clock) * 1000
|
|
879
|
+
ledger.stats.append(
|
|
880
|
+
replace(
|
|
881
|
+
_stats(source_name, counts, harvest.errors, metrics, elapsed_ms),
|
|
882
|
+
deferred=len(rotation.deferred) + harvest.deferred_boards,
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
yield _finished(
|
|
886
|
+
source_name,
|
|
887
|
+
counts,
|
|
888
|
+
harvest.errors,
|
|
889
|
+
metrics,
|
|
890
|
+
elapsed_ms,
|
|
891
|
+
fetch_ms=fetch_ms,
|
|
892
|
+
normalize_ms=normalize_ms,
|
|
893
|
+
write_ms=write_ms,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
async def _run_feed_source(
|
|
898
|
+
repository: AsyncRepository,
|
|
899
|
+
feed: FeedAdapter,
|
|
900
|
+
run_started_at: datetime,
|
|
901
|
+
shuffler: random.Random,
|
|
902
|
+
dry_run: bool,
|
|
903
|
+
ledger: "RunLedger",
|
|
904
|
+
rate_state: Mapping[str, RateState],
|
|
905
|
+
budgets: dict[str, HostBudget],
|
|
906
|
+
postures: Mapping[str, RatePosture],
|
|
907
|
+
) -> AsyncIterator[SyncEvent]:
|
|
908
|
+
source_name = feed.name
|
|
909
|
+
block = _active_block(rate_state, _bucket_keys(feed.hosts, feed.bucket_key), run_started_at)
|
|
910
|
+
if block is not None:
|
|
911
|
+
ledger.blocked.append(source_name)
|
|
912
|
+
if not dry_run:
|
|
913
|
+
ledger.stats.append(SourceRunStats(source=source_name, blocked=True))
|
|
914
|
+
yield SourceBlocked(
|
|
915
|
+
source=source_name,
|
|
916
|
+
bucket=block.bucket,
|
|
917
|
+
blocked_until=block.blocked_until or run_started_at,
|
|
918
|
+
remaining_s=block.blocks_remaining_s(run_started_at),
|
|
919
|
+
reason=block.reason,
|
|
920
|
+
consecutive_failures=block.consecutive_failures,
|
|
921
|
+
)
|
|
922
|
+
return
|
|
923
|
+
|
|
924
|
+
source_clock = time.perf_counter()
|
|
925
|
+
yield SourceStarted(source=source_name, companies=0)
|
|
926
|
+
|
|
927
|
+
fetch_clock = time.perf_counter()
|
|
928
|
+
seed = dict(await repository.load_validators(source_name))
|
|
929
|
+
|
|
930
|
+
if dry_run:
|
|
931
|
+
planned_urls = feed.plan(run_started_at)
|
|
932
|
+
for bucket in _bucket_keys(feed.hosts, feed.bucket_key):
|
|
933
|
+
ledger.plan_bounds.append((bucket, source_name, len(planned_urls), len(planned_urls)))
|
|
934
|
+
for url in planned_urls:
|
|
935
|
+
cached = seed.get(url)
|
|
936
|
+
has_validator = cached is not None and cached.usable
|
|
937
|
+
yield PlannedRequest(
|
|
938
|
+
source=source_name,
|
|
939
|
+
company=f"season {feed.season_year(run_started_at)}",
|
|
940
|
+
url=url,
|
|
941
|
+
has_validator=has_validator,
|
|
942
|
+
expectation="likely 304 (validator on file)" if has_validator else "full response",
|
|
943
|
+
)
|
|
944
|
+
yield SourceFinished(
|
|
945
|
+
source=source_name,
|
|
946
|
+
fetched=0,
|
|
947
|
+
added=0,
|
|
948
|
+
updated=0,
|
|
949
|
+
closed=0,
|
|
950
|
+
failed_companies=0,
|
|
951
|
+
elapsed_ms=(time.perf_counter() - source_clock) * 1000,
|
|
952
|
+
)
|
|
953
|
+
return
|
|
954
|
+
|
|
955
|
+
cache = ValidatorCache(seed)
|
|
956
|
+
collected: list[Job] = []
|
|
957
|
+
errors = 0
|
|
958
|
+
unchanged = False
|
|
959
|
+
degraded = ""
|
|
960
|
+
authoritative = True
|
|
961
|
+
stale_urls: tuple[str, ...] = ()
|
|
962
|
+
label = f"{source_name} {feed.season_year(run_started_at)}"
|
|
963
|
+
|
|
964
|
+
async with HttpClient(
|
|
965
|
+
allowed_hosts=feed.hosts,
|
|
966
|
+
posture=resolve(feed.rate_profile, []),
|
|
967
|
+
cache=cache,
|
|
968
|
+
rng=shuffler,
|
|
969
|
+
bucket_key=feed.bucket_key,
|
|
970
|
+
rate_state=rate_state,
|
|
971
|
+
now=run_started_at,
|
|
972
|
+
budgets=budgets,
|
|
973
|
+
postures=postures,
|
|
974
|
+
) as client:
|
|
975
|
+
yield CompanyStarted(source=source_name, company=label)
|
|
976
|
+
started = time.perf_counter()
|
|
977
|
+
try:
|
|
978
|
+
result = await feed.fetch(client, run_started_at)
|
|
979
|
+
except Exception as exc:
|
|
980
|
+
errors = 1
|
|
981
|
+
result = None
|
|
982
|
+
error_text = f"{type(exc).__name__}: {exc}"
|
|
983
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
984
|
+
|
|
985
|
+
for record in _drain(client, source_name):
|
|
986
|
+
yield record
|
|
987
|
+
|
|
988
|
+
if result is None:
|
|
989
|
+
yield CompanyFailed(
|
|
990
|
+
source=source_name, company=label, error=error_text, elapsed_ms=elapsed
|
|
991
|
+
)
|
|
992
|
+
elif result.not_modified:
|
|
993
|
+
unchanged = True
|
|
994
|
+
yield CompanyUnchanged(source=source_name, company=label, elapsed_ms=elapsed)
|
|
995
|
+
else:
|
|
996
|
+
collected.extend(result.jobs)
|
|
997
|
+
degraded = result.degraded
|
|
998
|
+
authoritative = result.authoritative
|
|
999
|
+
stale_urls = result.stale_urls
|
|
1000
|
+
yield CompanyFinished(
|
|
1001
|
+
source=source_name,
|
|
1002
|
+
company=label,
|
|
1003
|
+
fetched=len(result.jobs),
|
|
1004
|
+
elapsed_ms=elapsed,
|
|
1005
|
+
degraded=result.degraded,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
metrics = _client_metrics(client)
|
|
1009
|
+
incomplete = bool(errors) or bool(degraded)
|
|
1010
|
+
skip_urls = set(feed.plan(run_started_at)) | set(stale_urls) if incomplete else set()
|
|
1011
|
+
validators = _keepable_validators(cache, skip_urls)
|
|
1012
|
+
settled = client.rate_state(run_started_at)
|
|
1013
|
+
|
|
1014
|
+
fetch_ms = (time.perf_counter() - fetch_clock) * 1000
|
|
1015
|
+
counts, normalize_ms, write_ms = await _persist_source_batch(
|
|
1016
|
+
repository,
|
|
1017
|
+
SourceBatch(
|
|
1018
|
+
source=source_name,
|
|
1019
|
+
run_started_at=run_started_at,
|
|
1020
|
+
jobs=(),
|
|
1021
|
+
validators=validators,
|
|
1022
|
+
rate_state=settled,
|
|
1023
|
+
resolve_duplicates=resolve_duplicates,
|
|
1024
|
+
closes_whole_source=authoritative and not incomplete and not unchanged,
|
|
1025
|
+
visits=(
|
|
1026
|
+
CompanyVisit(
|
|
1027
|
+
board=source_name,
|
|
1028
|
+
succeeded=not errors,
|
|
1029
|
+
error=error_text if errors else "",
|
|
1030
|
+
label=label,
|
|
1031
|
+
),
|
|
1032
|
+
),
|
|
1033
|
+
),
|
|
1034
|
+
collected,
|
|
1035
|
+
)
|
|
1036
|
+
if not errors:
|
|
1037
|
+
ledger.succeeded.append(True)
|
|
1038
|
+
else:
|
|
1039
|
+
ledger.failed.append(source_name)
|
|
1040
|
+
|
|
1041
|
+
elapsed_ms = (time.perf_counter() - source_clock) * 1000
|
|
1042
|
+
ledger.stats.append(_stats(source_name, counts, errors, metrics, elapsed_ms))
|
|
1043
|
+
yield _finished(
|
|
1044
|
+
source_name,
|
|
1045
|
+
counts,
|
|
1046
|
+
errors,
|
|
1047
|
+
metrics,
|
|
1048
|
+
elapsed_ms,
|
|
1049
|
+
fetch_ms=fetch_ms,
|
|
1050
|
+
normalize_ms=normalize_ms,
|
|
1051
|
+
write_ms=write_ms,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _client_metrics(client: HttpClient) -> tuple[int, int, int, int, float, float]:
|
|
1056
|
+
p50, p95 = client.latency_percentiles()
|
|
1057
|
+
return (
|
|
1058
|
+
client.request_count,
|
|
1059
|
+
client.not_modified_count,
|
|
1060
|
+
client.retry_count,
|
|
1061
|
+
client.tightening_count,
|
|
1062
|
+
p50,
|
|
1063
|
+
p95,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _stats(
|
|
1068
|
+
source: str,
|
|
1069
|
+
counts: SourceBatchResult,
|
|
1070
|
+
errors: int,
|
|
1071
|
+
metrics: tuple[int, int, int, int, float, float],
|
|
1072
|
+
elapsed_ms: float,
|
|
1073
|
+
) -> SourceRunStats:
|
|
1074
|
+
requests, not_modified, retries, tightenings, p50, p95 = metrics
|
|
1075
|
+
return SourceRunStats(
|
|
1076
|
+
source=source,
|
|
1077
|
+
fetched=counts.fetched,
|
|
1078
|
+
added=counts.added,
|
|
1079
|
+
updated=counts.updated,
|
|
1080
|
+
closed=counts.closed,
|
|
1081
|
+
quarantined=counts.quarantined,
|
|
1082
|
+
errors=errors,
|
|
1083
|
+
requests=requests,
|
|
1084
|
+
not_modified=not_modified,
|
|
1085
|
+
retries=retries,
|
|
1086
|
+
tightenings=tightenings,
|
|
1087
|
+
latency_p50_ms=p50,
|
|
1088
|
+
latency_p95_ms=p95,
|
|
1089
|
+
elapsed_ms=elapsed_ms,
|
|
1090
|
+
stored=counts.stored,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _finished(
|
|
1095
|
+
source: str,
|
|
1096
|
+
counts: SourceBatchResult,
|
|
1097
|
+
errors: int,
|
|
1098
|
+
metrics: tuple[int, int, int, int, float, float],
|
|
1099
|
+
elapsed_ms: float,
|
|
1100
|
+
fetch_ms: float = 0.0,
|
|
1101
|
+
normalize_ms: float = 0.0,
|
|
1102
|
+
write_ms: float = 0.0,
|
|
1103
|
+
) -> SourceFinished:
|
|
1104
|
+
requests, not_modified, retries, tightenings, p50, p95 = metrics
|
|
1105
|
+
return SourceFinished(
|
|
1106
|
+
source=source,
|
|
1107
|
+
fetched=counts.fetched,
|
|
1108
|
+
added=counts.added,
|
|
1109
|
+
updated=counts.updated,
|
|
1110
|
+
closed=counts.closed,
|
|
1111
|
+
quarantined=counts.quarantined,
|
|
1112
|
+
failed_companies=errors,
|
|
1113
|
+
elapsed_ms=elapsed_ms,
|
|
1114
|
+
fetch_ms=fetch_ms,
|
|
1115
|
+
normalize_ms=normalize_ms,
|
|
1116
|
+
write_ms=write_ms,
|
|
1117
|
+
requests=requests,
|
|
1118
|
+
not_modified=not_modified,
|
|
1119
|
+
retries=retries,
|
|
1120
|
+
tightenings=tightenings,
|
|
1121
|
+
latency_p50_ms=p50,
|
|
1122
|
+
latency_p95_ms=p95,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
async def sync(
|
|
1127
|
+
repository: AsyncRepository,
|
|
1128
|
+
companies: Sequence[Company],
|
|
1129
|
+
*,
|
|
1130
|
+
sources: Sequence[str] | None = None,
|
|
1131
|
+
excluded: Sequence[str] | None = None,
|
|
1132
|
+
dry_run: bool = False,
|
|
1133
|
+
force_refresh: bool = False,
|
|
1134
|
+
now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
1135
|
+
rng: random.Random | None = None,
|
|
1136
|
+
) -> AsyncIterator[SyncEvent]:
|
|
1137
|
+
grouped, feeds, unroutable = _select(companies, sources, excluded)
|
|
1138
|
+
shuffler = rng or random.Random()
|
|
1139
|
+
|
|
1140
|
+
run_started_at = now_fn()
|
|
1141
|
+
run_clock = time.perf_counter()
|
|
1142
|
+
yield SyncStarted(
|
|
1143
|
+
sources=tuple(sorted(set(grouped) | set(feeds))),
|
|
1144
|
+
companies=sum(len(entry[1]) for entry in grouped.values()),
|
|
1145
|
+
started_at=run_started_at,
|
|
1146
|
+
)
|
|
1147
|
+
if unroutable:
|
|
1148
|
+
yield UnroutableCompanies(
|
|
1149
|
+
companies=tuple(company.name for company in unroutable),
|
|
1150
|
+
platforms=tuple(sorted({company.platform.value for company in unroutable})),
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
ledger = RunLedger()
|
|
1154
|
+
|
|
1155
|
+
rate_state = await repository.load_rate_state()
|
|
1156
|
+
spent_today, has_history = await _spent_today(repository, run_started_at - timedelta(hours=24))
|
|
1157
|
+
budgets: dict[str, HostBudget] = {}
|
|
1158
|
+
postures = _bucket_postures(grouped, feeds)
|
|
1159
|
+
|
|
1160
|
+
streams: list[tuple[str, AsyncIterator[SyncEvent]]] = [
|
|
1161
|
+
(
|
|
1162
|
+
name,
|
|
1163
|
+
_run_company_source(
|
|
1164
|
+
repository,
|
|
1165
|
+
grouped[name][0],
|
|
1166
|
+
grouped[name][1],
|
|
1167
|
+
run_started_at,
|
|
1168
|
+
shuffler,
|
|
1169
|
+
dry_run,
|
|
1170
|
+
ledger,
|
|
1171
|
+
rate_state,
|
|
1172
|
+
budgets,
|
|
1173
|
+
postures,
|
|
1174
|
+
force_refresh,
|
|
1175
|
+
spent_today,
|
|
1176
|
+
has_history,
|
|
1177
|
+
),
|
|
1178
|
+
)
|
|
1179
|
+
for name in sorted(grouped)
|
|
1180
|
+
]
|
|
1181
|
+
streams.extend(
|
|
1182
|
+
(
|
|
1183
|
+
name,
|
|
1184
|
+
_run_feed_source(
|
|
1185
|
+
repository,
|
|
1186
|
+
feeds[name],
|
|
1187
|
+
run_started_at,
|
|
1188
|
+
shuffler,
|
|
1189
|
+
dry_run,
|
|
1190
|
+
ledger,
|
|
1191
|
+
rate_state,
|
|
1192
|
+
budgets,
|
|
1193
|
+
postures,
|
|
1194
|
+
),
|
|
1195
|
+
)
|
|
1196
|
+
for name in sorted(feeds)
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
async for event in _merge(streams, ledger):
|
|
1200
|
+
yield event
|
|
1201
|
+
|
|
1202
|
+
if dry_run:
|
|
1203
|
+
for event in _bucket_plans(ledger.plan_bounds, postures):
|
|
1204
|
+
yield event
|
|
1205
|
+
yield SyncFinished(
|
|
1206
|
+
outcome=(
|
|
1207
|
+
SyncOutcome.PARTIAL
|
|
1208
|
+
if ledger.failed or unroutable or ledger.blocked
|
|
1209
|
+
else SyncOutcome.SUCCESS
|
|
1210
|
+
),
|
|
1211
|
+
added=0,
|
|
1212
|
+
updated=0,
|
|
1213
|
+
closed=0,
|
|
1214
|
+
failed_sources=tuple(ledger.failed),
|
|
1215
|
+
elapsed_ms=(time.perf_counter() - run_clock) * 1000,
|
|
1216
|
+
dry_run=True,
|
|
1217
|
+
)
|
|
1218
|
+
return
|
|
1219
|
+
|
|
1220
|
+
if not ledger.succeeded and (ledger.failed or ledger.blocked):
|
|
1221
|
+
outcome_status = SyncOutcome.FAILURE
|
|
1222
|
+
elif ledger.failed or unroutable or ledger.blocked or ledger.deferred:
|
|
1223
|
+
outcome_status = SyncOutcome.PARTIAL
|
|
1224
|
+
else:
|
|
1225
|
+
outcome_status = SyncOutcome.SUCCESS
|
|
1226
|
+
partial_reason = (
|
|
1227
|
+
_partial_reason(ledger.failed, unroutable, ledger.blocked, ledger.deferred)
|
|
1228
|
+
if outcome_status is not SyncOutcome.SUCCESS
|
|
1229
|
+
else ""
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
sources_with_boards, known_boards = _registry_boards(companies)
|
|
1233
|
+
await repository.close_orphan_boards(sources_with_boards, known_boards)
|
|
1234
|
+
|
|
1235
|
+
purged = await repository.purge(now_fn())
|
|
1236
|
+
|
|
1237
|
+
await repository.record_sync_run(
|
|
1238
|
+
SyncRun(
|
|
1239
|
+
started_at=run_started_at,
|
|
1240
|
+
finished_at=now_fn(),
|
|
1241
|
+
outcome=outcome_status,
|
|
1242
|
+
sources=tuple(ledger.stats),
|
|
1243
|
+
)
|
|
1244
|
+
)
|
|
1245
|
+
yield SyncFinished(
|
|
1246
|
+
outcome=outcome_status,
|
|
1247
|
+
added=sum(item.added for item in ledger.stats),
|
|
1248
|
+
updated=sum(item.updated for item in ledger.stats),
|
|
1249
|
+
closed=sum(item.closed for item in ledger.stats),
|
|
1250
|
+
quarantined=sum(item.quarantined for item in ledger.stats),
|
|
1251
|
+
purged=purged.purged,
|
|
1252
|
+
failed_sources=tuple(ledger.failed),
|
|
1253
|
+
elapsed_ms=(time.perf_counter() - run_clock) * 1000,
|
|
1254
|
+
requests=sum(item.requests for item in ledger.stats),
|
|
1255
|
+
not_modified=sum(item.not_modified for item in ledger.stats),
|
|
1256
|
+
partial_reason=partial_reason,
|
|
1257
|
+
)
|