ytclone 0.1.5__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.
- ytclone/__init__.py +8 -0
- ytclone/__main__.py +3 -0
- ytclone/cli.py +277 -0
- ytclone/codec.py +79 -0
- ytclone/engine.py +682 -0
- ytclone/format.py +377 -0
- ytclone/interfaces.py +107 -0
- ytclone/logging.py +53 -0
- ytclone/memory.py +35 -0
- ytclone/parallel/__init__.py +1 -0
- ytclone/parallel/__main__.py +5 -0
- ytclone/parallel/cli.py +191 -0
- ytclone/parallel/discovery.py +108 -0
- ytclone/parallel/protocol.py +37 -0
- ytclone/parallel/scheduler.py +121 -0
- ytclone/parallel/supervisor.py +348 -0
- ytclone/parallel/worker.py +271 -0
- ytclone/progress.py +256 -0
- ytclone/restore_retry.py +75 -0
- ytclone/s3.py +397 -0
- ytclone/s3_retry.py +95 -0
- ytclone/yt.py +976 -0
- ytclone-0.1.5.dist-info/METADATA +129 -0
- ytclone-0.1.5.dist-info/RECORD +27 -0
- ytclone-0.1.5.dist-info/WHEEL +4 -0
- ytclone-0.1.5.dist-info/entry_points.txt +4 -0
- ytclone-0.1.5.dist-info/licenses/LICENSE +21 -0
ytclone/__init__.py
ADDED
ytclone/__main__.py
ADDED
ytclone/cli.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Copy YTsaurus static tables, files and directories to or from an S3 archive."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import nullcontext, suppress
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
import aiomisc
|
|
12
|
+
import argclass
|
|
13
|
+
from aiohttp_s3_client import S3Client
|
|
14
|
+
from aiomisc.log import LogFormat
|
|
15
|
+
|
|
16
|
+
from .engine import CopyOptions, export_tree, restore_tree
|
|
17
|
+
from .format import MIB, parse_s3_url, path_components
|
|
18
|
+
from .logging import file_logging
|
|
19
|
+
from .memory import estimate_memory_mib
|
|
20
|
+
from .progress import Progress, ProgressReporter
|
|
21
|
+
from .s3 import S3Storage
|
|
22
|
+
from .yt import YtAdapter
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Arguments(argclass.Parser):
|
|
29
|
+
source: str = argclass.Argument(
|
|
30
|
+
"source", help="Source //YT/path or s3://bucket/prefix"
|
|
31
|
+
)
|
|
32
|
+
destination: str = argclass.Argument(
|
|
33
|
+
"destination", help="Destination in the other storage"
|
|
34
|
+
)
|
|
35
|
+
yt_proxy: str = argclass.Argument(env_var="YT_PROXY", required=True)
|
|
36
|
+
no_proxy_discovery: bool = argclass.Argument(
|
|
37
|
+
default=False, help="Use only the supplied YT proxy (for local Docker)"
|
|
38
|
+
)
|
|
39
|
+
endpoint: str = argclass.Argument(
|
|
40
|
+
env_var="S3_ENDPOINT", default="https://s3.amazonaws.com"
|
|
41
|
+
)
|
|
42
|
+
region: str = argclass.Argument(env_var="AWS_DEFAULT_REGION", default="us-east-1")
|
|
43
|
+
concurrency: int = argclass.Argument(
|
|
44
|
+
default=4, help="Maximum concurrent table/file transfers"
|
|
45
|
+
)
|
|
46
|
+
queue_size: int = argclass.Argument(default=16, help="Maximum queued node paths")
|
|
47
|
+
part_size_mib: int = argclass.Argument(
|
|
48
|
+
default=256,
|
|
49
|
+
help="Target uncompressed size per archive object; approximate for tables",
|
|
50
|
+
)
|
|
51
|
+
buffer_size_mib: int = argclass.Argument(
|
|
52
|
+
default=32, help="S3 multipart buffer per transfer, 5..512 MiB"
|
|
53
|
+
)
|
|
54
|
+
compression_level: int = argclass.Argument(
|
|
55
|
+
default=3, help="Zstandard level, -7..22"
|
|
56
|
+
)
|
|
57
|
+
log_file: str | None = argclass.Argument(
|
|
58
|
+
default=None,
|
|
59
|
+
type=str,
|
|
60
|
+
help="Append detailed logs to this file and show progress on stdout",
|
|
61
|
+
)
|
|
62
|
+
progress_interval: float = argclass.Argument(
|
|
63
|
+
default=5.0,
|
|
64
|
+
help="Seconds between console summaries with --log-file",
|
|
65
|
+
)
|
|
66
|
+
log_level: str = argclass.Argument(
|
|
67
|
+
default="info", choices=("debug", "info", "warning", "error")
|
|
68
|
+
)
|
|
69
|
+
log_format: str = argclass.Argument(
|
|
70
|
+
default=LogFormat.default(),
|
|
71
|
+
choices=LogFormat.choices(),
|
|
72
|
+
env_var="AIOMISC_LOG_FORMAT",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def direction(*, source: str, destination: str) -> tuple[bool, str, str, str]:
|
|
77
|
+
"""Validate paths and resolve direction, YT path, bucket and prefix."""
|
|
78
|
+
export = source.startswith("//") and destination.startswith("s3://")
|
|
79
|
+
restore = source.startswith("s3://") and destination.startswith("//")
|
|
80
|
+
if not (export or restore):
|
|
81
|
+
raise ValueError("Specify one YT path and one S3 URL; source comes first")
|
|
82
|
+
yt_path = source if export else destination
|
|
83
|
+
path_components(yt_path)
|
|
84
|
+
if any(c in yt_path for c in "\\[]{}<>&*@"):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"YT selectors, links and escaped/special node names are not supported"
|
|
87
|
+
)
|
|
88
|
+
bucket, prefix = parse_s3_url(destination if export else source)
|
|
89
|
+
return export, yt_path, bucket, prefix
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def run(*, arguments: Arguments, options: CopyOptions) -> int:
|
|
93
|
+
"""Create adapters, run the copy and return its exit code."""
|
|
94
|
+
export, yt_path, bucket, prefix = direction(
|
|
95
|
+
source=arguments.source, destination=arguments.destination
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
memory_budget = estimate_memory_mib(
|
|
99
|
+
concurrency=options.concurrency,
|
|
100
|
+
chunk_size=options.chunk_size,
|
|
101
|
+
compression_level=options.compression_level,
|
|
102
|
+
export=export,
|
|
103
|
+
)
|
|
104
|
+
log.info(
|
|
105
|
+
"copy_started direction=%s concurrency=%d buffer_size_mib=%d "
|
|
106
|
+
"memory_estimate_mib=%d",
|
|
107
|
+
"yt-to-s3" if export else "s3-to-yt",
|
|
108
|
+
options.concurrency,
|
|
109
|
+
options.chunk_size // MIB,
|
|
110
|
+
memory_budget,
|
|
111
|
+
)
|
|
112
|
+
log.info(
|
|
113
|
+
"Memory estimate is a planning budget; large rows, metadata and "
|
|
114
|
+
"allocator overhead can exceed it."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
progress = Progress() if arguments.log_file else None
|
|
118
|
+
if progress is not None:
|
|
119
|
+
print(
|
|
120
|
+
f"Detailed log: {arguments.log_file}; "
|
|
121
|
+
f"RAM estimate: {memory_budget} MiB (planning budget, not a limit)",
|
|
122
|
+
flush=True,
|
|
123
|
+
)
|
|
124
|
+
reporter = (
|
|
125
|
+
ProgressReporter(
|
|
126
|
+
progress=progress,
|
|
127
|
+
concurrency=options.concurrency,
|
|
128
|
+
interval=arguments.progress_interval,
|
|
129
|
+
export=export,
|
|
130
|
+
stream=sys.stdout,
|
|
131
|
+
)
|
|
132
|
+
if progress is not None
|
|
133
|
+
else nullcontext()
|
|
134
|
+
)
|
|
135
|
+
started = time.monotonic()
|
|
136
|
+
|
|
137
|
+
async with reporter:
|
|
138
|
+
yt = YtAdapter(
|
|
139
|
+
proxy=arguments.yt_proxy,
|
|
140
|
+
proxy_discovery=not arguments.no_proxy_discovery,
|
|
141
|
+
progress=progress,
|
|
142
|
+
)
|
|
143
|
+
estimate_task = (
|
|
144
|
+
asyncio.create_task(estimate_total(yt=yt, path=yt_path, progress=progress))
|
|
145
|
+
if export and progress is not None
|
|
146
|
+
else None
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
async with aiohttp.ClientSession(
|
|
150
|
+
timeout=aiohttp.ClientTimeout(
|
|
151
|
+
total=None, sock_connect=30, sock_read=300
|
|
152
|
+
)
|
|
153
|
+
) as session:
|
|
154
|
+
client = S3Client(
|
|
155
|
+
session=session,
|
|
156
|
+
url=arguments.endpoint.rstrip("/") + "/" + bucket,
|
|
157
|
+
region=arguments.region,
|
|
158
|
+
)
|
|
159
|
+
storage = S3Storage(
|
|
160
|
+
client=client,
|
|
161
|
+
chunk_size=options.chunk_size,
|
|
162
|
+
progress=progress,
|
|
163
|
+
)
|
|
164
|
+
if export:
|
|
165
|
+
result = await export_tree(
|
|
166
|
+
yt=yt,
|
|
167
|
+
storage=storage,
|
|
168
|
+
source=yt_path,
|
|
169
|
+
prefix=prefix,
|
|
170
|
+
cluster=arguments.yt_proxy.rstrip("/"),
|
|
171
|
+
options=options,
|
|
172
|
+
progress=progress,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
result = await restore_tree(
|
|
176
|
+
yt=yt,
|
|
177
|
+
storage=storage,
|
|
178
|
+
source_prefix=prefix,
|
|
179
|
+
destination=yt_path,
|
|
180
|
+
options=options,
|
|
181
|
+
progress=progress,
|
|
182
|
+
)
|
|
183
|
+
finally:
|
|
184
|
+
if estimate_task is not None:
|
|
185
|
+
estimate_task.cancel()
|
|
186
|
+
with suppress(asyncio.CancelledError):
|
|
187
|
+
await estimate_task
|
|
188
|
+
log.info(
|
|
189
|
+
"copy_finished completed=%d skipped=%d failed=%d seconds=%.3f",
|
|
190
|
+
result.completed,
|
|
191
|
+
result.skipped,
|
|
192
|
+
result.failed,
|
|
193
|
+
time.monotonic() - started,
|
|
194
|
+
)
|
|
195
|
+
return 1 if result.failed else 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def estimate_total(*, yt: YtAdapter, path: str, progress: Progress) -> None:
|
|
199
|
+
"""Fetch optional subtree counts and storage size alongside discovery."""
|
|
200
|
+
try:
|
|
201
|
+
async with yt.worker() as worker:
|
|
202
|
+
estimate = await worker.estimate_source(path=path)
|
|
203
|
+
progress.estimated_total = estimate.node_count
|
|
204
|
+
progress.estimated_source_bytes = estimate.disk_size
|
|
205
|
+
log.info(
|
|
206
|
+
"source_estimate path=%s nodes=%s disk_space=%s",
|
|
207
|
+
path,
|
|
208
|
+
estimate.node_count,
|
|
209
|
+
estimate.disk_size,
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
log.warning("source_estimate_unavailable path=%s", path, exc_info=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> int:
|
|
216
|
+
"""Parse arguments and run the CLI with configured logging."""
|
|
217
|
+
arguments = Arguments(description=__doc__)
|
|
218
|
+
arguments.parse_args()
|
|
219
|
+
try:
|
|
220
|
+
if (
|
|
221
|
+
not math.isfinite(arguments.progress_interval)
|
|
222
|
+
or arguments.progress_interval <= 0
|
|
223
|
+
):
|
|
224
|
+
raise ValueError("Progress interval must be positive and finite")
|
|
225
|
+
direction(source=arguments.source, destination=arguments.destination)
|
|
226
|
+
options = CopyOptions(
|
|
227
|
+
concurrency=arguments.concurrency,
|
|
228
|
+
queue_size=arguments.queue_size,
|
|
229
|
+
part_size=arguments.part_size_mib * MIB,
|
|
230
|
+
chunk_size=arguments.buffer_size_mib * MIB,
|
|
231
|
+
compression_level=arguments.compression_level,
|
|
232
|
+
)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
arguments.create_parser().error(str(exc))
|
|
235
|
+
|
|
236
|
+
# aiomisc's plain formatter ignores dates; stream supports timestamps.
|
|
237
|
+
log_format = arguments.log_format
|
|
238
|
+
if log_format == LogFormat.plain.name:
|
|
239
|
+
log_format = LogFormat.stream.name
|
|
240
|
+
|
|
241
|
+
# System log collectors supply their own timestamps.
|
|
242
|
+
log_date_format = (
|
|
243
|
+
None
|
|
244
|
+
if log_format in (LogFormat.syslog.name, LogFormat.journald.name)
|
|
245
|
+
else LOG_DATE_FORMAT
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
log_context = (
|
|
249
|
+
file_logging(
|
|
250
|
+
filename=arguments.log_file,
|
|
251
|
+
level=arguments.log_level,
|
|
252
|
+
log_format=log_format,
|
|
253
|
+
date_format=LOG_DATE_FORMAT,
|
|
254
|
+
)
|
|
255
|
+
if arguments.log_file
|
|
256
|
+
else nullcontext()
|
|
257
|
+
)
|
|
258
|
+
try:
|
|
259
|
+
with log_context:
|
|
260
|
+
try:
|
|
261
|
+
return aiomisc.run(
|
|
262
|
+
run(arguments=arguments, options=options),
|
|
263
|
+
pool_size=max(4, arguments.concurrency + 1),
|
|
264
|
+
log_level=arguments.log_level,
|
|
265
|
+
log_format=log_format,
|
|
266
|
+
log_date_format=log_date_format,
|
|
267
|
+
log_config=not bool(arguments.log_file),
|
|
268
|
+
)
|
|
269
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
270
|
+
log.warning("copy_cancelled")
|
|
271
|
+
return 130
|
|
272
|
+
except Exception:
|
|
273
|
+
log.exception("copy_failed")
|
|
274
|
+
return 1
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
print(f"Cannot write log file {arguments.log_file}: {exc}", file=sys.stderr)
|
|
277
|
+
return 1
|
ytclone/codec.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Small, bounded bridges between async storage and synchronous codecs/SDKs."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import io
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncReader(io.RawIOBase):
|
|
11
|
+
"""Bridge an async byte stream to a worker with one chunk buffered."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *, chunks: AsyncIterator[bytes], loop):
|
|
14
|
+
"""Bind an async byte source to a synchronous reader."""
|
|
15
|
+
super().__init__()
|
|
16
|
+
self._chunks = chunks.__aiter__()
|
|
17
|
+
self._loop = loop
|
|
18
|
+
self._buffer = memoryview(b"")
|
|
19
|
+
self._pending = None
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._cancelled = False
|
|
22
|
+
self.exhausted = False
|
|
23
|
+
|
|
24
|
+
def readable(self):
|
|
25
|
+
"""Advertise support for reading bytes."""
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
def cancel(self):
|
|
29
|
+
"""Interrupt pending input and prevent further reads."""
|
|
30
|
+
with self._lock:
|
|
31
|
+
self._cancelled = True
|
|
32
|
+
if self._pending is not None:
|
|
33
|
+
self._pending.cancel()
|
|
34
|
+
|
|
35
|
+
def _next(self):
|
|
36
|
+
"""Pull one async chunk from the event loop into the worker."""
|
|
37
|
+
with self._lock:
|
|
38
|
+
if self._cancelled:
|
|
39
|
+
raise InterruptedError("Restore input cancelled")
|
|
40
|
+
self._pending = asyncio.run_coroutine_threadsafe(
|
|
41
|
+
coro=self._chunks.__anext__(), loop=self._loop
|
|
42
|
+
)
|
|
43
|
+
pending = self._pending
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
return pending.result()
|
|
47
|
+
except StopAsyncIteration:
|
|
48
|
+
self.exhausted = True
|
|
49
|
+
return b""
|
|
50
|
+
except concurrent.futures.CancelledError as error:
|
|
51
|
+
raise InterruptedError("Restore input cancelled") from error
|
|
52
|
+
finally:
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._pending = None
|
|
55
|
+
|
|
56
|
+
def read(self, size=-1):
|
|
57
|
+
"""Return a bounded slice of the buffered input."""
|
|
58
|
+
if size < 0:
|
|
59
|
+
raise ValueError("Unbounded reads are not supported")
|
|
60
|
+
if size == 0:
|
|
61
|
+
return b""
|
|
62
|
+
|
|
63
|
+
while not self._buffer and not self.exhausted:
|
|
64
|
+
self._buffer = memoryview(self._next())
|
|
65
|
+
|
|
66
|
+
result = bytes(self._buffer[:size])
|
|
67
|
+
self._buffer = self._buffer[len(result) :]
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def readinto(self, buffer):
|
|
71
|
+
"""Fill the supplied buffer with available bytes."""
|
|
72
|
+
data = self.read(size=len(buffer))
|
|
73
|
+
buffer[: len(data)] = data
|
|
74
|
+
return len(data)
|
|
75
|
+
|
|
76
|
+
def finish(self):
|
|
77
|
+
"""Reach source EOF so size/checksum validation cannot be bypassed."""
|
|
78
|
+
if self._buffer or self.read(size=1):
|
|
79
|
+
raise ValueError("Compressed input was not fully consumed")
|