ytclone 0.1.5__tar.gz
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-0.1.5/LICENSE +21 -0
- ytclone-0.1.5/PKG-INFO +129 -0
- ytclone-0.1.5/README.md +108 -0
- ytclone-0.1.5/pyproject.toml +40 -0
- ytclone-0.1.5/pyproject.toml.orig +38 -0
- ytclone-0.1.5/src/ytclone/__init__.py +8 -0
- ytclone-0.1.5/src/ytclone/__main__.py +3 -0
- ytclone-0.1.5/src/ytclone/cli.py +277 -0
- ytclone-0.1.5/src/ytclone/codec.py +79 -0
- ytclone-0.1.5/src/ytclone/engine.py +682 -0
- ytclone-0.1.5/src/ytclone/format.py +377 -0
- ytclone-0.1.5/src/ytclone/interfaces.py +107 -0
- ytclone-0.1.5/src/ytclone/logging.py +53 -0
- ytclone-0.1.5/src/ytclone/memory.py +35 -0
- ytclone-0.1.5/src/ytclone/parallel/__init__.py +1 -0
- ytclone-0.1.5/src/ytclone/parallel/__main__.py +5 -0
- ytclone-0.1.5/src/ytclone/parallel/cli.py +191 -0
- ytclone-0.1.5/src/ytclone/parallel/discovery.py +108 -0
- ytclone-0.1.5/src/ytclone/parallel/protocol.py +37 -0
- ytclone-0.1.5/src/ytclone/parallel/scheduler.py +121 -0
- ytclone-0.1.5/src/ytclone/parallel/supervisor.py +348 -0
- ytclone-0.1.5/src/ytclone/parallel/worker.py +271 -0
- ytclone-0.1.5/src/ytclone/progress.py +256 -0
- ytclone-0.1.5/src/ytclone/restore_retry.py +75 -0
- ytclone-0.1.5/src/ytclone/s3.py +397 -0
- ytclone-0.1.5/src/ytclone/s3_retry.py +95 -0
- ytclone-0.1.5/src/ytclone/yt.py +976 -0
ytclone-0.1.5/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Vasin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ytclone-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ytclone
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Resumable copying of YTsaurus tables, files and directories to and from S3
|
|
5
|
+
Author: Alexander Vasin
|
|
6
|
+
Author-email: Alexander Vasin <hi@alvass.in>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
11
|
+
Requires-Dist: aiochannel>=1.3,<2
|
|
12
|
+
Requires-Dist: aiohttp>=3.14.3,<4
|
|
13
|
+
Requires-Dist: aiohttp-s3-client>=1.0.14,<2
|
|
14
|
+
Requires-Dist: aiomisc>=17.10.3,<19
|
|
15
|
+
Requires-Dist: argclass>=1.11.1,<2
|
|
16
|
+
Requires-Dist: ytsaurus-client>=0.13.54,<0.14
|
|
17
|
+
Requires-Dist: ytsaurus-yson>=0.4.10,<0.5
|
|
18
|
+
Requires-Dist: zstandard>=0.25.0,<1
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# ytclone
|
|
23
|
+
|
|
24
|
+
Copy static tables, files, and directories between YTsaurus and S3, preserving
|
|
25
|
+
schemas and portable attributes. Repeat a command to skip completed, unchanged
|
|
26
|
+
nodes and retry unfinished copies.
|
|
27
|
+
|
|
28
|
+
Restoring from S3 requires an archive created by `ytclone`.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Install from PyPI in your Python virtual environment:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install ytclone
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires **CPython 3.10+** on **Linux x86_64 with glibc** or **macOS ARM64**
|
|
39
|
+
(Apple Silicon). Other platforms are currently unsupported because the required
|
|
40
|
+
[`ytsaurus-yson`](https://pypi.org/project/ytsaurus-yson/) dependency has no wheels
|
|
41
|
+
for them.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
Configure access to YTsaurus and S3:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export YT_PROXY="https://yt.example.com"
|
|
49
|
+
export YT_TOKEN="..."
|
|
50
|
+
export AWS_ACCESS_KEY_ID="..."
|
|
51
|
+
export AWS_SECRET_ACCESS_KEY="..."
|
|
52
|
+
export AWS_DEFAULT_REGION="us-east-1"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The S3 bucket must already exist. For an S3-compatible service, also set
|
|
56
|
+
`S3_ENDPOINT` to its URL. Use `AWS_SESSION_TOKEN` for temporary credentials.
|
|
57
|
+
|
|
58
|
+
**Copy to S3:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ytclone //home/analytics/events s3://my-bucket/export --log-file export.log
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The full YT path is appended to the S3 prefix: this command stores `events` at
|
|
65
|
+
`s3://my-bucket/export/home/analytics/events/`.
|
|
66
|
+
|
|
67
|
+
**Restore to YTsaurus:**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ytclone s3://my-bucket/export/home/analytics/events \
|
|
71
|
+
//home/analytics/events_copy --log-file restore.log
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The restore destination is used exactly as given: the children of `events` go
|
|
75
|
+
directly under `//home/analytics/events_copy`. The same commands work for a single
|
|
76
|
+
table or file. With `--log-file`, progress appears on the console and detailed
|
|
77
|
+
logs are appended to the file.
|
|
78
|
+
|
|
79
|
+
## Repeated runs
|
|
80
|
+
|
|
81
|
+
Run the same command again after an interruption or a source update. Completed,
|
|
82
|
+
unchanged nodes are skipped. New, changed, or incomplete nodes are copied again;
|
|
83
|
+
an interrupted table or file starts from the beginning, not from its last part.
|
|
84
|
+
|
|
85
|
+
Source data is never deleted. Destination nodes absent from the source are kept.
|
|
86
|
+
Use a separate S3 prefix for each source cluster.
|
|
87
|
+
|
|
88
|
+
## Parallel export
|
|
89
|
+
|
|
90
|
+
For trees with many tables or files, use multiple copy processes:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
ytclone-parallel //home/analytics/events s3://my-bucket/export \
|
|
94
|
+
--processes 2 --concurrency 2 --log-file export.log
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This runs two processes with two transfers each, prioritizing directories and
|
|
98
|
+
small nodes. One table still occupies one transfer slot. Parallel mode supports
|
|
99
|
+
YT → S3; restore its archives with `ytclone` as above.
|
|
100
|
+
|
|
101
|
+
## Before you copy
|
|
102
|
+
|
|
103
|
+
- **Supported node types:** static tables, files, and directories. Dynamic tables
|
|
104
|
+
are skipped and reported; links are unsupported.
|
|
105
|
+
- **One current S3 copy.** There is no archive version history. Replacing a node
|
|
106
|
+
makes its archive unavailable until completion; a failed replacement can lose
|
|
107
|
+
the previous complete copy.
|
|
108
|
+
- **Permissions are not copied.** ACLs, ownership, and account settings are
|
|
109
|
+
excluded. Replacing an existing YT table or file recreates the node, so its
|
|
110
|
+
previous settings are not preserved either.
|
|
111
|
+
- **Consistency is per node.** A directory tree is not a single point-in-time
|
|
112
|
+
snapshot.
|
|
113
|
+
- **Avoid overlapping writers.** Do not run commands that write to the same
|
|
114
|
+
destination nodes concurrently, or restore an archive while it is being replaced.
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
Run `ytclone --help` or `ytclone-parallel --help` for all command-line options.
|
|
119
|
+
|
|
120
|
+
- [User guide](https://github.com/alvassin/ytclone/blob/main/USAGE.md): paths,
|
|
121
|
+
configuration, progress, retries, and parallel export.
|
|
122
|
+
- [Architecture](https://github.com/alvassin/ytclone/blob/main/ARCHITECTURE.md):
|
|
123
|
+
archive format, change detection, and execution diagrams.
|
|
124
|
+
- [Contributing](https://github.com/alvassin/ytclone/blob/main/CONTRIBUTING.md):
|
|
125
|
+
installation from source, development, and tests.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](https://github.com/alvassin/ytclone/blob/main/LICENSE).
|
ytclone-0.1.5/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# ytclone
|
|
2
|
+
|
|
3
|
+
Copy static tables, files, and directories between YTsaurus and S3, preserving
|
|
4
|
+
schemas and portable attributes. Repeat a command to skip completed, unchanged
|
|
5
|
+
nodes and retry unfinished copies.
|
|
6
|
+
|
|
7
|
+
Restoring from S3 requires an archive created by `ytclone`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install from PyPI in your Python virtual environment:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ytclone
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires **CPython 3.10+** on **Linux x86_64 with glibc** or **macOS ARM64**
|
|
18
|
+
(Apple Silicon). Other platforms are currently unsupported because the required
|
|
19
|
+
[`ytsaurus-yson`](https://pypi.org/project/ytsaurus-yson/) dependency has no wheels
|
|
20
|
+
for them.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
Configure access to YTsaurus and S3:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export YT_PROXY="https://yt.example.com"
|
|
28
|
+
export YT_TOKEN="..."
|
|
29
|
+
export AWS_ACCESS_KEY_ID="..."
|
|
30
|
+
export AWS_SECRET_ACCESS_KEY="..."
|
|
31
|
+
export AWS_DEFAULT_REGION="us-east-1"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The S3 bucket must already exist. For an S3-compatible service, also set
|
|
35
|
+
`S3_ENDPOINT` to its URL. Use `AWS_SESSION_TOKEN` for temporary credentials.
|
|
36
|
+
|
|
37
|
+
**Copy to S3:**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
ytclone //home/analytics/events s3://my-bucket/export --log-file export.log
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The full YT path is appended to the S3 prefix: this command stores `events` at
|
|
44
|
+
`s3://my-bucket/export/home/analytics/events/`.
|
|
45
|
+
|
|
46
|
+
**Restore to YTsaurus:**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ytclone s3://my-bucket/export/home/analytics/events \
|
|
50
|
+
//home/analytics/events_copy --log-file restore.log
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The restore destination is used exactly as given: the children of `events` go
|
|
54
|
+
directly under `//home/analytics/events_copy`. The same commands work for a single
|
|
55
|
+
table or file. With `--log-file`, progress appears on the console and detailed
|
|
56
|
+
logs are appended to the file.
|
|
57
|
+
|
|
58
|
+
## Repeated runs
|
|
59
|
+
|
|
60
|
+
Run the same command again after an interruption or a source update. Completed,
|
|
61
|
+
unchanged nodes are skipped. New, changed, or incomplete nodes are copied again;
|
|
62
|
+
an interrupted table or file starts from the beginning, not from its last part.
|
|
63
|
+
|
|
64
|
+
Source data is never deleted. Destination nodes absent from the source are kept.
|
|
65
|
+
Use a separate S3 prefix for each source cluster.
|
|
66
|
+
|
|
67
|
+
## Parallel export
|
|
68
|
+
|
|
69
|
+
For trees with many tables or files, use multiple copy processes:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
ytclone-parallel //home/analytics/events s3://my-bucket/export \
|
|
73
|
+
--processes 2 --concurrency 2 --log-file export.log
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This runs two processes with two transfers each, prioritizing directories and
|
|
77
|
+
small nodes. One table still occupies one transfer slot. Parallel mode supports
|
|
78
|
+
YT → S3; restore its archives with `ytclone` as above.
|
|
79
|
+
|
|
80
|
+
## Before you copy
|
|
81
|
+
|
|
82
|
+
- **Supported node types:** static tables, files, and directories. Dynamic tables
|
|
83
|
+
are skipped and reported; links are unsupported.
|
|
84
|
+
- **One current S3 copy.** There is no archive version history. Replacing a node
|
|
85
|
+
makes its archive unavailable until completion; a failed replacement can lose
|
|
86
|
+
the previous complete copy.
|
|
87
|
+
- **Permissions are not copied.** ACLs, ownership, and account settings are
|
|
88
|
+
excluded. Replacing an existing YT table or file recreates the node, so its
|
|
89
|
+
previous settings are not preserved either.
|
|
90
|
+
- **Consistency is per node.** A directory tree is not a single point-in-time
|
|
91
|
+
snapshot.
|
|
92
|
+
- **Avoid overlapping writers.** Do not run commands that write to the same
|
|
93
|
+
destination nodes concurrently, or restore an archive while it is being replaced.
|
|
94
|
+
|
|
95
|
+
## Documentation
|
|
96
|
+
|
|
97
|
+
Run `ytclone --help` or `ytclone-parallel --help` for all command-line options.
|
|
98
|
+
|
|
99
|
+
- [User guide](https://github.com/alvassin/ytclone/blob/main/USAGE.md): paths,
|
|
100
|
+
configuration, progress, retries, and parallel export.
|
|
101
|
+
- [Architecture](https://github.com/alvassin/ytclone/blob/main/ARCHITECTURE.md):
|
|
102
|
+
archive format, change detection, and execution diagrams.
|
|
103
|
+
- [Contributing](https://github.com/alvassin/ytclone/blob/main/CONTRIBUTING.md):
|
|
104
|
+
installation from source, development, and tests.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
[MIT](https://github.com/alvassin/ytclone/blob/main/LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ytclone"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Resumable copying of YTsaurus tables, files and directories to and from S3"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Operating System :: POSIX :: Linux",
|
|
11
|
+
"Operating System :: MacOS :: MacOS X",
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aiochannel>=1.3,<2",
|
|
15
|
+
"aiohttp>=3.14.3,<4",
|
|
16
|
+
"aiohttp-s3-client>=1.0.14,<2",
|
|
17
|
+
"aiomisc>=17.10.3,<19",
|
|
18
|
+
"argclass>=1.11.1,<2",
|
|
19
|
+
"ytsaurus-client>=0.13.54,<0.14",
|
|
20
|
+
"ytsaurus-yson>=0.4.10,<0.5",
|
|
21
|
+
"zstandard>=0.25.0,<1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[[project.authors]]
|
|
25
|
+
name = "Alexander Vasin"
|
|
26
|
+
email = "hi@alvass.in"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
ytclone = "ytclone:main"
|
|
30
|
+
ytclone-parallel = "ytclone.parallel.cli:main"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"ruff>=0.16",
|
|
40
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ytclone"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Resumable copying of YTsaurus tables, files and directories to and from S3"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Alexander Vasin", email = "hi@alvass.in" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
# Native ytsaurus-yson wheels limit support to Linux x86_64 (glibc)
|
|
13
|
+
# and macOS ARM64. See README.md for installation requirements.
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Operating System :: MacOS :: MacOS X",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"aiochannel>=1.3,<2",
|
|
20
|
+
"aiohttp>=3.14.3,<4",
|
|
21
|
+
"aiohttp-s3-client>=1.0.14,<2",
|
|
22
|
+
"aiomisc>=17.10.3,<19",
|
|
23
|
+
"argclass>=1.11.1,<2",
|
|
24
|
+
"ytsaurus-client>=0.13.54,<0.14",
|
|
25
|
+
"ytsaurus-yson>=0.4.10,<0.5",
|
|
26
|
+
"zstandard>=0.25.0,<1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
ytclone = "ytclone:main"
|
|
31
|
+
ytclone-parallel = "ytclone.parallel.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = ["pytest>=8", "ruff>=0.16"]
|
|
@@ -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
|