bdns-sync 0.1.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.
- bdns/sync/__init__.py +5 -0
- bdns/sync/__main__.py +14 -0
- bdns/sync/cli.py +158 -0
- bdns/sync/generic.py +233 -0
- bdns/sync/hashing.py +49 -0
- bdns/sync/pipeline.py +129 -0
- bdns/sync/sinks/__init__.py +156 -0
- bdns/sync/sinks/sql/__init__.py +89 -0
- bdns/sync/sinks/sql/bookkeeping.py +138 -0
- bdns/sync/sinks/sql/dialects.py +114 -0
- bdns/sync/sinks/sql/scd2.py +315 -0
- bdns/sync/sinks/sql/schema.py +147 -0
- bdns/sync/syncers.py +545 -0
- bdns_sync-0.1.0.dist-info/METADATA +312 -0
- bdns_sync-0.1.0.dist-info/RECORD +18 -0
- bdns_sync-0.1.0.dist-info/WHEEL +4 -0
- bdns_sync-0.1.0.dist-info/entry_points.txt +3 -0
- bdns_sync-0.1.0.dist-info/licenses/LICENSE +674 -0
bdns/sync/__init__.py
ADDED
bdns/sync/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Main entry point for the BDNS Sync command line interface.
|
|
4
|
+
|
|
5
|
+
Also what the installed `bdns-sync` console script points to
|
|
6
|
+
(`pyproject.toml`). It imports `app` directly rather than running this
|
|
7
|
+
module as `__main__`, so logging setup lives in `cli.py`'s callback
|
|
8
|
+
instead of here (see its docstring).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from bdns.sync.cli import app
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
app()
|
bdns/sync/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""bdns-sync is a pure parameterized tool: one endpoint per invocation, no
|
|
4
|
+
config file, no cadence knowledge. Which endpoints to sync and when is an
|
|
5
|
+
orchestration concern that lives outside this package (see scripts/).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import date
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
import bdns.fetch.client as _bdns_fetch_client
|
|
16
|
+
from bdns.fetch import BDNSClient
|
|
17
|
+
from bdns.sync import __version__
|
|
18
|
+
from bdns.sync.generic import WINDOWS
|
|
19
|
+
from bdns.sync.sinks import get_sink
|
|
20
|
+
from bdns.sync.syncers import FULL_SYNCERS, SEARCH_SYNCERS
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="bdns-sync",
|
|
24
|
+
help="Sync one BDNS API endpoint into a target database in SCD2 form.",
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"bdns-sync {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--version",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
help="Show the version and exit.",
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""BDNS Sync command line interface.
|
|
46
|
+
|
|
47
|
+
Configures logging here, not in `__main__.py`'s `if __name__ ==
|
|
48
|
+
"__main__":` guard. The installed `bdns-sync` console script
|
|
49
|
+
(`pyproject.toml`) imports and calls this Typer `app` directly, so that
|
|
50
|
+
guard never runs. Typer always runs this callback before any
|
|
51
|
+
subcommand regardless of entry point, so this is the one place
|
|
52
|
+
guaranteed to run every time.
|
|
53
|
+
"""
|
|
54
|
+
logging.basicConfig(
|
|
55
|
+
level=logging.INFO,
|
|
56
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
57
|
+
force=True,
|
|
58
|
+
)
|
|
59
|
+
if not sys.stderr.isatty():
|
|
60
|
+
# bdns-fetch's pagination progress bar writes bare `\r` updates with
|
|
61
|
+
# no trailing newline, meant for an interactive terminal. Piped to a
|
|
62
|
+
# log file (cron, background runs) that leaves a stale progress
|
|
63
|
+
# fragment glued to the front of the next log line. No public knob
|
|
64
|
+
# on BDNSClient to disable it, so silence it here instead. Only
|
|
65
|
+
# applies when output isn't a real terminal; interactive use keeps it.
|
|
66
|
+
_bdns_fetch_client.tqdm = lambda iterable, *args, **kwargs: iterable
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
TARGET_URL_OPTION = typer.Option(
|
|
70
|
+
...,
|
|
71
|
+
"--target-url",
|
|
72
|
+
envvar="BDNS_SYNC_TARGET_URL",
|
|
73
|
+
help="SQLAlchemy target DB URL (e.g. bigquery://project/dataset).",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_iso_date(value: Optional[str], flag: str) -> Optional[date]:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return date.fromisoformat(value)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise typer.BadParameter(f"{flag} must be an ISO date (YYYY-MM-DD), got {value!r}") from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def sync(
|
|
88
|
+
endpoint: str = typer.Argument(..., help="Endpoint/entity name to sync."),
|
|
89
|
+
target_url: str = TARGET_URL_OPTION,
|
|
90
|
+
window: str = typer.Option(
|
|
91
|
+
None,
|
|
92
|
+
"--window",
|
|
93
|
+
help=f"Cascade window for incremental endpoints: one of {', '.join(WINDOWS)}.",
|
|
94
|
+
),
|
|
95
|
+
since: str = typer.Option(
|
|
96
|
+
None,
|
|
97
|
+
"--since",
|
|
98
|
+
help="Backfill start date (YYYY-MM-DD) for incremental endpoints. "
|
|
99
|
+
"Overrides --window; syncs [since, until]. See scripts/full_load.sh.",
|
|
100
|
+
),
|
|
101
|
+
until: str = typer.Option(
|
|
102
|
+
None,
|
|
103
|
+
"--until",
|
|
104
|
+
help="Backfill end date (YYYY-MM-DD). Defaults to yesterday. Only with --since.",
|
|
105
|
+
),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Sync one endpoint.
|
|
108
|
+
|
|
109
|
+
Incremental endpoints (the big search endpoints plus convocatorias) need
|
|
110
|
+
a reg-date range: either a cascade `--window` (daily/weekly/monthly/annual)
|
|
111
|
+
or an explicit `--since [--until]` backfill range. Full-replace endpoints
|
|
112
|
+
ignore all of these.
|
|
113
|
+
"""
|
|
114
|
+
sink = get_sink(target_url)
|
|
115
|
+
# Defaults (3 retries, 2s fixed wait) give up after ~1 minute of server
|
|
116
|
+
# trouble; a real multi-hour backfill died live to one request timing
|
|
117
|
+
# out 3 times in a row. 8 x 15s rides out a ~2-minute server rough patch;
|
|
118
|
+
# the only cost is extra delay before a genuinely permanent failure.
|
|
119
|
+
client = BDNSClient(max_retries=8, wait_time=15)
|
|
120
|
+
|
|
121
|
+
since_date = _parse_iso_date(since, "--since")
|
|
122
|
+
until_date = _parse_iso_date(until, "--until")
|
|
123
|
+
|
|
124
|
+
# Outcome (row counts, duration) is logged by bookkeeping.run_with_bookkeeping;
|
|
125
|
+
# no separate echo here to avoid printing the same summary twice.
|
|
126
|
+
if endpoint in SEARCH_SYNCERS:
|
|
127
|
+
sync_fn = SEARCH_SYNCERS[endpoint]
|
|
128
|
+
if since_date is not None:
|
|
129
|
+
if window is not None:
|
|
130
|
+
raise typer.BadParameter("use either --window or --since, not both")
|
|
131
|
+
if until_date is not None and until_date < since_date:
|
|
132
|
+
raise typer.BadParameter("--until must not be before --since")
|
|
133
|
+
sync_fn(sink, client, since=since_date, until=until_date)
|
|
134
|
+
elif window is not None:
|
|
135
|
+
if window not in WINDOWS:
|
|
136
|
+
raise typer.BadParameter(f"window must be one of {', '.join(WINDOWS)}")
|
|
137
|
+
sync_fn(sink, client, window)
|
|
138
|
+
else:
|
|
139
|
+
raise typer.BadParameter(f"{endpoint} requires --window or --since")
|
|
140
|
+
elif endpoint in FULL_SYNCERS:
|
|
141
|
+
FULL_SYNCERS[endpoint](sink, client)
|
|
142
|
+
else:
|
|
143
|
+
raise typer.BadParameter(f"unknown endpoint: {endpoint}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command(name="list")
|
|
147
|
+
def list_endpoints(
|
|
148
|
+
kind: str = typer.Option("full", "--kind", help="one of: full, search"),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""List known endpoint names, one per line, for scripting (no hardcoded lists)."""
|
|
151
|
+
if kind == "full":
|
|
152
|
+
for name in FULL_SYNCERS:
|
|
153
|
+
typer.echo(name)
|
|
154
|
+
elif kind == "search":
|
|
155
|
+
for name in SEARCH_SYNCERS:
|
|
156
|
+
typer.echo(name)
|
|
157
|
+
else:
|
|
158
|
+
raise typer.BadParameter("kind must be one of: full, search")
|
bdns/sync/generic.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Shared sync shapes reused by several entity modules. Each entity module
|
|
4
|
+
still owns its own name, key fields, and any real one-off logic. Only the
|
|
5
|
+
mechanical "fetch, then apply" plumbing lives here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Iterator, Sequence
|
|
11
|
+
from datetime import date, timedelta
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from bdns.fetch import BDNSClient
|
|
15
|
+
from bdns.sync.sinks import Sink
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
logger.addHandler(logging.NullHandler())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def all_pages(fetch):
|
|
22
|
+
"""Wrap a client fetch method so paginated endpoints return EVERY page.
|
|
23
|
+
|
|
24
|
+
bdns-fetch's `num_pages` option defaults to 1, which silently truncates
|
|
25
|
+
any response bigger than one page (pageSize max 10,000). Caught live:
|
|
26
|
+
grandesbeneficiarios_busqueda returned 10,000 of 142,260 rows. Passing
|
|
27
|
+
`num_pages=0` ("all pages") on every call is the fix, but only the
|
|
28
|
+
paginated methods accept the parameter, so it's added by signature
|
|
29
|
+
inspection instead of blindly.
|
|
30
|
+
"""
|
|
31
|
+
params = inspect.signature(fetch).parameters
|
|
32
|
+
if "num_pages" not in params:
|
|
33
|
+
return fetch
|
|
34
|
+
|
|
35
|
+
def fetch_all(*args, **kwargs):
|
|
36
|
+
kwargs.setdefault("num_pages", 0)
|
|
37
|
+
return fetch(*args, **kwargs)
|
|
38
|
+
|
|
39
|
+
return fetch_all
|
|
40
|
+
|
|
41
|
+
# window name -> reg-date window size in days. Shared by every entity that
|
|
42
|
+
# does cascading re-verification (concesiones, ayudasestado, minimis,
|
|
43
|
+
# partidospoliticos, convocatorias).
|
|
44
|
+
WINDOWS: dict[str, int] = {
|
|
45
|
+
"daily": 1,
|
|
46
|
+
"weekly": 7,
|
|
47
|
+
"monthly": 30,
|
|
48
|
+
"annual": 365,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Every reg-date fetch is chunked into pieces this wide, regardless of the
|
|
52
|
+
# requested window. Live-tested against concesiones_busqueda: a 7-day range
|
|
53
|
+
# pulled cleanly (147,856 rows, 0 errors), while a 4-year range failed
|
|
54
|
+
# intermittently with ERR_MANTENIMIENTO_BBDD at every page depth. `daily`
|
|
55
|
+
# and `weekly` are already this size or smaller, so chunking is a no-op for
|
|
56
|
+
# them; `monthly` and `annual` are the ones this actually protects.
|
|
57
|
+
CHUNK_DAYS = 7
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def iter_date_chunks(start: date, end: date, chunk_days: int = CHUNK_DAYS) -> Iterator[tuple[date, date]]:
|
|
61
|
+
"""Split [start, end] (inclusive) into chunks of at most `chunk_days`,
|
|
62
|
+
contiguous and non-overlapping. Yields (start, end) is always at least
|
|
63
|
+
one item even if `start == end`.
|
|
64
|
+
"""
|
|
65
|
+
current = start
|
|
66
|
+
while current <= end:
|
|
67
|
+
chunk_end = min(current + timedelta(days=chunk_days - 1), end)
|
|
68
|
+
yield current, chunk_end
|
|
69
|
+
current = chunk_end + timedelta(days=1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_api_upper_bound(inclusive_end: date) -> date:
|
|
73
|
+
"""Translate an inclusive end date (how windows and chunks are expressed
|
|
74
|
+
everywhere else in this codebase) into the value the four `fechaRegFin`
|
|
75
|
+
search endpoints expect.
|
|
76
|
+
|
|
77
|
+
Those endpoints (concesiones/ayudasestado/minimis/partidospoliticos)
|
|
78
|
+
treat `fechaRegFin` as EXCLUSIVE: it matches registrations strictly
|
|
79
|
+
before 00:00 of the given date, so it does NOT include the date's own
|
|
80
|
+
day. Confirmed live: querying `fechaRegFin=D` returned 0 rows for day D
|
|
81
|
+
(concesiones leaked a single midnight-exact row), while `fechaRegFin=D+1`
|
|
82
|
+
returned the whole day (~58,000 for concesiones). Left as a bare
|
|
83
|
+
`fechaRegFin=end`, `daily` (start == end) fetches almost nothing, and
|
|
84
|
+
every wider window silently drops its most recent day per chunk.
|
|
85
|
+
|
|
86
|
+
To include every registration made on `inclusive_end`, ask the API for
|
|
87
|
+
the day after. This function is the only place that crosses from the
|
|
88
|
+
codebase's inclusive convention to the API's half-open one. Keep it
|
|
89
|
+
here, not inlined as a `+ 1` at call sites.
|
|
90
|
+
|
|
91
|
+
IMPORTANT: this is NOT universal. convocatorias' discovery uses a
|
|
92
|
+
different parameter, `fechaHasta`, which is INCLUSIVE (also confirmed
|
|
93
|
+
live: `fechaHasta=D` returns the full day D). That path must NOT call
|
|
94
|
+
this function; see `syncers.discover_convocatoria_codes`.
|
|
95
|
+
"""
|
|
96
|
+
return inclusive_end + timedelta(days=1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sync_full_catalog(
|
|
100
|
+
sink: Sink, client: BDNSClient, endpoint_name: str, fetch_method_name: str, key_fields: Sequence[str]
|
|
101
|
+
) -> dict[str, int]:
|
|
102
|
+
"""Fetch everything with one no-arg call, full-reconcile every run."""
|
|
103
|
+
fetch = all_pages(getattr(client, fetch_method_name))
|
|
104
|
+
return sink.sync_full(endpoint_name, fetch(), key_fields)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sync_swept_catalog(
|
|
108
|
+
sink: Sink,
|
|
109
|
+
client: BDNSClient,
|
|
110
|
+
endpoint_name: str,
|
|
111
|
+
fetch_method_name: str,
|
|
112
|
+
sweep_param: str,
|
|
113
|
+
sweep_values: Sequence[str],
|
|
114
|
+
key_fields: Sequence[str],
|
|
115
|
+
) -> dict[str, int]:
|
|
116
|
+
"""Sweep `sweep_param` across `sweep_values`, merging into one table
|
|
117
|
+
before reconciling. Reconciling per sweep value would wrongly close out
|
|
118
|
+
the other values' rows as "missing". The sweep value is tagged onto the
|
|
119
|
+
payload under `sweep_param` since the API doesn't echo it back.
|
|
120
|
+
"""
|
|
121
|
+
fetch = all_pages(getattr(client, fetch_method_name))
|
|
122
|
+
|
|
123
|
+
def rows():
|
|
124
|
+
for value in sweep_values:
|
|
125
|
+
for item in fetch(**{sweep_param: value}):
|
|
126
|
+
item = dict(item)
|
|
127
|
+
item[sweep_param] = value
|
|
128
|
+
yield item
|
|
129
|
+
|
|
130
|
+
return sink.sync_full(endpoint_name, rows(), key_fields)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def window_bounds(window: str) -> tuple[date, date]:
|
|
134
|
+
"""Map a cascade window name to its inclusive `[start, end]` range. `end`
|
|
135
|
+
is always yesterday (see `to_api_upper_bound` for why today is excluded).
|
|
136
|
+
"""
|
|
137
|
+
days = WINDOWS[window]
|
|
138
|
+
end = date.today() - timedelta(days=1)
|
|
139
|
+
return end - timedelta(days=days - 1), end
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_when(
|
|
143
|
+
window: Optional[str], since: Optional[date], until: Optional[date]
|
|
144
|
+
) -> tuple[date, date, str]:
|
|
145
|
+
"""Turn the two ways of asking for a reg-date range (a named cascade
|
|
146
|
+
`window`, or an explicit `since`/`until` backfill range) into a single
|
|
147
|
+
`(start, end, run_type)` triple.
|
|
148
|
+
|
|
149
|
+
An explicit `since` wins over `window` and marks the run as "backfill"
|
|
150
|
+
in `_sync_runs` (`until` defaults to yesterday). This is what lets the
|
|
151
|
+
tool stay a pure primitive: cadence and history bounds are the caller's
|
|
152
|
+
business (`scripts/`), the engine just syncs whatever range it's told.
|
|
153
|
+
"""
|
|
154
|
+
if since is not None:
|
|
155
|
+
end = until if until is not None else date.today() - timedelta(days=1)
|
|
156
|
+
return since, end, "backfill"
|
|
157
|
+
if window is not None:
|
|
158
|
+
start, end = window_bounds(window)
|
|
159
|
+
return start, end, window
|
|
160
|
+
raise ValueError("a reg-date sync needs either a window or a since date")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sync_search_range(
|
|
164
|
+
sink: Sink,
|
|
165
|
+
client: BDNSClient,
|
|
166
|
+
endpoint_name: str,
|
|
167
|
+
fetch_method_name: str,
|
|
168
|
+
key_fields: Sequence[str],
|
|
169
|
+
start: date,
|
|
170
|
+
end: date,
|
|
171
|
+
run_type: str,
|
|
172
|
+
reg_date_field: Optional[str] = None,
|
|
173
|
+
) -> dict[str, int]:
|
|
174
|
+
"""Fetch the reg-date range `[start, end]` and apply incrementally. Used
|
|
175
|
+
for both cascade windows (a few days back) and backfills (years back);
|
|
176
|
+
same machinery, only the range and `run_type` label differ.
|
|
177
|
+
|
|
178
|
+
By default this never closes out keys, since a range is a subset of the
|
|
179
|
+
table, not its full current state. `reg_date_field` opts into
|
|
180
|
+
window-scoped deletion detection (see `scd2.apply_incremental`); only
|
|
181
|
+
entities that expose their own registration date can use it, confirmed
|
|
182
|
+
live per entity.
|
|
183
|
+
|
|
184
|
+
The fetch is chunked into `CHUNK_DAYS`-wide pieces (see `iter_date_chunks`
|
|
185
|
+
and `to_api_upper_bound`). `window_start`/`window_end` given to
|
|
186
|
+
`apply_incremental` still span the whole `[start, end]`, since deletion
|
|
187
|
+
scoping cares about the full range, not how it was split to fetch it.
|
|
188
|
+
"""
|
|
189
|
+
fetch = all_pages(getattr(client, fetch_method_name))
|
|
190
|
+
|
|
191
|
+
def rows():
|
|
192
|
+
for chunk_start, chunk_end in iter_date_chunks(start, end):
|
|
193
|
+
logger.info("%s: chunk [%s .. %s]", endpoint_name, chunk_start, chunk_end)
|
|
194
|
+
yield from fetch(
|
|
195
|
+
fechaRegInicio=chunk_start, fechaRegFin=to_api_upper_bound(chunk_end)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return sink.sync_window(
|
|
199
|
+
endpoint_name, rows(), key_fields,
|
|
200
|
+
window_start=start, window_end=end, run_type=run_type, reg_date_field=reg_date_field,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def sync_search_range_inclusive(
|
|
205
|
+
sink: Sink,
|
|
206
|
+
client: BDNSClient,
|
|
207
|
+
endpoint_name: str,
|
|
208
|
+
fetch_method_name: str,
|
|
209
|
+
key_fields: Sequence[str],
|
|
210
|
+
start: date,
|
|
211
|
+
end: date,
|
|
212
|
+
run_type: str,
|
|
213
|
+
reg_date_field: Optional[str] = None,
|
|
214
|
+
) -> dict[str, int]:
|
|
215
|
+
"""Same shape as `sync_search_range`, for the OTHER date-parameter family:
|
|
216
|
+
`fechaDesde`/`fechaHasta`, which is INCLUSIVE on the upper bound (unlike
|
|
217
|
+
`fechaRegFin`; see `to_api_upper_bound` and section 2 of
|
|
218
|
+
docs/bdns-api-behavior.md).
|
|
219
|
+
`chunk_end` is passed as-is, with NO `to_api_upper_bound` bridge.
|
|
220
|
+
Calling it here would over-fetch one extra day past the window.
|
|
221
|
+
"""
|
|
222
|
+
fetch = all_pages(getattr(client, fetch_method_name))
|
|
223
|
+
|
|
224
|
+
def rows():
|
|
225
|
+
for chunk_start, chunk_end in iter_date_chunks(start, end):
|
|
226
|
+
logger.info("%s: chunk [%s .. %s]", endpoint_name, chunk_start, chunk_end)
|
|
227
|
+
yield from fetch(fechaDesde=chunk_start, fechaHasta=chunk_end)
|
|
228
|
+
|
|
229
|
+
return sink.sync_window(
|
|
230
|
+
endpoint_name, rows(), key_fields,
|
|
231
|
+
window_start=start, window_end=end, run_type=run_type, reg_date_field=reg_date_field,
|
|
232
|
+
)
|
|
233
|
+
|
bdns/sync/hashing.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Canonical JSON, row hashing, and natural key derivation for SCD2 versioning."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _order_independent(value: Any) -> Any:
|
|
12
|
+
"""Recursively sort list elements so hashing doesn't care about array
|
|
13
|
+
order. Confirmed live on `regiones`: the API returns the same tree
|
|
14
|
+
`children` in a different element order across calls, with no field
|
|
15
|
+
actually changed. Without this normalization, every re-sync produced a
|
|
16
|
+
spurious SCD2 version for any key with a reordered nested array.
|
|
17
|
+
|
|
18
|
+
Dict key order is already handled by `json.dumps(sort_keys=True)`
|
|
19
|
+
(recursively); only list element order needs normalizing here, by
|
|
20
|
+
sorting on each element's own canonical JSON string.
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {k: _order_independent(v) for k, v in value.items()}
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
items = [_order_independent(v) for v in value]
|
|
26
|
+
return sorted(items, key=lambda v: json.dumps(v, sort_keys=True, ensure_ascii=False, default=str))
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def canonical_json(
|
|
31
|
+
payload: dict[str, Any], exclude_fields: Optional[Iterable[str]] = None
|
|
32
|
+
) -> str:
|
|
33
|
+
if exclude_fields:
|
|
34
|
+
excluded = set(exclude_fields)
|
|
35
|
+
payload = {k: v for k, v in payload.items() if k not in excluded}
|
|
36
|
+
return json.dumps(
|
|
37
|
+
_order_independent(payload), sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def row_hash(payload: dict[str, Any], exclude_fields: Optional[Iterable[str]] = None) -> str:
|
|
42
|
+
digest = canonical_json(payload, exclude_fields).encode("utf-8")
|
|
43
|
+
return hashlib.sha256(digest).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def natural_key(payload: dict[str, Any], key_fields: Sequence[str]) -> str:
|
|
47
|
+
"""Build a stable string key from one or more fields (composite keys supported)."""
|
|
48
|
+
values = [payload[field] for field in key_fields]
|
|
49
|
+
return json.dumps(values, separators=(",", ":"), ensure_ascii=False, default=str)
|
bdns/sync/pipeline.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Concurrency helpers shared by the fetch and storage code.
|
|
4
|
+
|
|
5
|
+
Nothing here knows about the BDNS API or SQL. Callers pass plain
|
|
6
|
+
iterables and callables, which also keeps these functions easy to test.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import itertools
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def chunked(items: Iterable[Any], chunk_size: int) -> Iterator[list[Any]]:
|
|
19
|
+
"""Group `items` into lists of at most `chunk_size`. Pure: no threads."""
|
|
20
|
+
chunk = []
|
|
21
|
+
for item in items:
|
|
22
|
+
chunk.append(item)
|
|
23
|
+
if len(chunk) >= chunk_size:
|
|
24
|
+
yield chunk
|
|
25
|
+
chunk = []
|
|
26
|
+
if chunk:
|
|
27
|
+
yield chunk
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prefetch(iterable: Iterable[Any]) -> Iterator[Any]:
|
|
31
|
+
"""Yield the items of `iterable`, pulling them on a helper thread that
|
|
32
|
+
reads ahead of the caller.
|
|
33
|
+
|
|
34
|
+
While the caller processes one item, the helper is already producing
|
|
35
|
+
the next. The queue holds at most two items: if the caller falls
|
|
36
|
+
behind, the helper blocks instead of filling memory.
|
|
37
|
+
|
|
38
|
+
The caller does its work on its own thread. That matters for SQLite
|
|
39
|
+
connections, which must stay on the thread that created them.
|
|
40
|
+
|
|
41
|
+
If the helper raises, the exception is re-raised here. If the caller
|
|
42
|
+
stops iterating early, the helper is unblocked and joined before the
|
|
43
|
+
generator exits.
|
|
44
|
+
"""
|
|
45
|
+
item_queue: queue.Queue = queue.Queue(maxsize=2)
|
|
46
|
+
done = object()
|
|
47
|
+
consumer_gone = threading.Event()
|
|
48
|
+
|
|
49
|
+
def put(item) -> bool:
|
|
50
|
+
# Re-check every second whether the consumer went away. Without
|
|
51
|
+
# this, a full queue would block the helper thread forever.
|
|
52
|
+
while not consumer_gone.is_set():
|
|
53
|
+
try:
|
|
54
|
+
item_queue.put(item, timeout=1)
|
|
55
|
+
return True
|
|
56
|
+
except queue.Full:
|
|
57
|
+
continue
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def read_ahead():
|
|
61
|
+
try:
|
|
62
|
+
for item in iterable:
|
|
63
|
+
if not put(item):
|
|
64
|
+
return # consumer is gone, nothing left to do
|
|
65
|
+
put(done)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
put(exc) # re-raised on the caller's thread below
|
|
68
|
+
|
|
69
|
+
helper = threading.Thread(target=read_ahead, daemon=True)
|
|
70
|
+
helper.start()
|
|
71
|
+
try:
|
|
72
|
+
while True:
|
|
73
|
+
item = item_queue.get()
|
|
74
|
+
if item is done:
|
|
75
|
+
return
|
|
76
|
+
if isinstance(item, Exception):
|
|
77
|
+
raise item
|
|
78
|
+
yield item
|
|
79
|
+
finally:
|
|
80
|
+
consumer_gone.set()
|
|
81
|
+
helper.join()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rate_limited_map(
|
|
85
|
+
keys: Iterable[Any],
|
|
86
|
+
fn: Callable[[Any], Any],
|
|
87
|
+
spacing_seconds: float,
|
|
88
|
+
max_workers: int,
|
|
89
|
+
) -> Iterator[tuple[Any, Any]]:
|
|
90
|
+
"""Run `fn(key)` on a thread pool and yield `(key, result)` as calls
|
|
91
|
+
finish.
|
|
92
|
+
|
|
93
|
+
Call starts are spaced at least `spacing_seconds` apart across all
|
|
94
|
+
workers. Rate-limited servers reject bursts, not averages: a fresh
|
|
95
|
+
pool firing all its workers at once gets 429s even when the average
|
|
96
|
+
rate is fine. With spaced starts, `max_workers` only needs to be
|
|
97
|
+
large enough to cover call latency.
|
|
98
|
+
|
|
99
|
+
At most `2 * max_workers` calls are submitted ahead of the consumer,
|
|
100
|
+
so a large key set never piles up results in memory. If `fn` raises,
|
|
101
|
+
the exception propagates and stops the iteration.
|
|
102
|
+
"""
|
|
103
|
+
lock = threading.Lock()
|
|
104
|
+
next_start = 0.0
|
|
105
|
+
|
|
106
|
+
def run_one(key):
|
|
107
|
+
nonlocal next_start
|
|
108
|
+
with lock:
|
|
109
|
+
now = time.monotonic()
|
|
110
|
+
wait = max(0.0, next_start - now)
|
|
111
|
+
next_start = now + wait + spacing_seconds
|
|
112
|
+
if wait:
|
|
113
|
+
time.sleep(wait)
|
|
114
|
+
return key, fn(key)
|
|
115
|
+
|
|
116
|
+
keys_iter = iter(keys)
|
|
117
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
118
|
+
pending = {
|
|
119
|
+
executor.submit(run_one, key)
|
|
120
|
+
for key in itertools.islice(keys_iter, max_workers * 2)
|
|
121
|
+
}
|
|
122
|
+
while pending:
|
|
123
|
+
finished, pending = concurrent.futures.wait(
|
|
124
|
+
pending, return_when=concurrent.futures.FIRST_COMPLETED
|
|
125
|
+
)
|
|
126
|
+
for key in itertools.islice(keys_iter, len(finished)):
|
|
127
|
+
pending.add(executor.submit(run_one, key))
|
|
128
|
+
for future in finished:
|
|
129
|
+
yield future.result()
|