shareddata 6.83.16__tar.gz → 6.83.21__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.
- shareddata-6.83.21/MANIFEST.in +3 -0
- {shareddata-6.83.16/src/shareddata.egg-info → shareddata-6.83.21}/PKG-INFO +1 -1
- {shareddata-6.83.16 → shareddata-6.83.21}/setup.py +1 -1
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/__init__.py +3 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/constants.py +7 -0
- shareddata-6.83.21/src/SharedData/API/download_paths.py +126 -0
- shareddata-6.83.21/src/SharedData/API/download_slots.py +142 -0
- shareddata-6.83.21/src/SharedData/API/routes/downloads.py +147 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientAPI.py +63 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/MongoDBClient.py +1 -1
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Worker.py +2 -2
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/WorkerLib.py +1 -1
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/SharedNumpy.py +128 -51
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Table.py +1 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TimeSeriesDisk.py +1 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TimeseriesContainer.py +1 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/sharedmutexwin.pyd +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21/src/shareddata.egg-info}/PKG-INFO +1 -1
- {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/SOURCES.txt +4 -26
- shareddata-6.83.16/MANIFEST.in +0 -2
- shareddata-6.83.16/tests/test_api_collection.py +0 -119
- shareddata-6.83.16/tests/test_api_collection_loopback.py +0 -164
- shareddata-6.83.16/tests/test_api_table.py +0 -201
- shareddata-6.83.16/tests/test_api_table_production_flows.py +0 -414
- shareddata-6.83.16/tests/test_api_table_schemaless.py +0 -261
- shareddata-6.83.16/tests/test_api_table_schemaless_extend.py +0 -131
- shareddata-6.83.16/tests/test_bson_last_pos_reuse.py +0 -468
- shareddata-6.83.16/tests/test_cache_redis.py +0 -162
- shareddata-6.83.16/tests/test_extend_rt.py +0 -69
- shareddata-6.83.16/tests/test_listcache.py +0 -94
- shareddata-6.83.16/tests/test_loc.py +0 -149
- shareddata-6.83.16/tests/test_metadata.py +0 -0
- shareddata-6.83.16/tests/test_mount_unmount_detection.py +0 -93
- shareddata-6.83.16/tests/test_read_write_tail.py +0 -48
- shareddata-6.83.16/tests/test_schema_acquire_timeout.py +0 -47
- shareddata-6.83.16/tests/test_stream_loopback_async.py +0 -169
- shareddata-6.83.16/tests/test_table_auto_grow_all_pkeys.py +0 -98
- shareddata-6.83.16/tests/test_timeseries.py +0 -1137
- shareddata-6.83.16/tests/test_timeseries_api.py +0 -391
- shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_d1.py +0 -80
- shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_m1.py +0 -46
- shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_m15.py +0 -46
- shareddata-6.83.16/tests/test_upsert_unordered_positions_m1.py +0 -47
- shareddata-6.83.16/tests/test_workerpool_mutex_release.py +0 -55
- shareddata-6.83.16/tests/test_workerpool_post_commands.py +0 -141
- {shareddata-6.83.16 → shareddata-6.83.21}/LICENSE +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/README.md +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/pyproject.toml +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/setup.cfg +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/ServerGunicorn.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/ServerWaitress.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/auth.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/listcache.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/__init__.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/cache.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/collections.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/metadata.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/system.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/tables.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/timeseries.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/workers.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/utils.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/CacheRedis.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/CollectionMongoDB.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Database.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Defaults.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AWSEC2.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AWSS3.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AutoDocstrings.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientSocket.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientWebSocket.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/LogHandlerAPI.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/SaveTables.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ServerSocket.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ServerWebSocket.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/StreamsCache.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/StreamsPersist.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/SyncTable.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/TunnelWebSocket.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/__init__.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Logger.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Metadata.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/MultiProc.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/OpenFIGI.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/BatchJob.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Schedule.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Scheduler.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/WorkerPool.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/__init__.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/SharedData.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/StreamKafka.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Symbol.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableDisk.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndex.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctions.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitGenerate.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitHash.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitLoc.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Users.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Utils.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/__init__.py +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/dependency_links.txt +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/requires.txt +0 -0
- {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/top_level.txt +0 -0
|
@@ -34,7 +34,7 @@ install_requires = [
|
|
|
34
34
|
|
|
35
35
|
setup(
|
|
36
36
|
name='shareddata',
|
|
37
|
-
version='6.83.
|
|
37
|
+
version='6.83.21',
|
|
38
38
|
description='Memory Mapped / Shared Memory Database with S3 repository',
|
|
39
39
|
long_description=open('README.md').read(),
|
|
40
40
|
long_description_content_type='text/markdown',
|
|
@@ -21,6 +21,7 @@ from SharedData.API.routes.timeseries import timeseries_bp, init_timeseries_rout
|
|
|
21
21
|
from SharedData.API.routes.workers import workers_bp, init_workers_routes
|
|
22
22
|
from SharedData.API.routes.metadata import metadata_bp, init_metadata_routes
|
|
23
23
|
from SharedData.API.routes.cache import cache_bp, init_cache_routes
|
|
24
|
+
from SharedData.API.routes.downloads import init_downloads_routes
|
|
24
25
|
from SharedData.API.routes.system import (
|
|
25
26
|
system_bp,
|
|
26
27
|
init_system_routes,
|
|
@@ -85,6 +86,7 @@ def create_app(shdata=None, quiet=True):
|
|
|
85
86
|
init_workers_routes(shdata, authenticate_fn)
|
|
86
87
|
init_metadata_routes(shdata, authenticate_fn)
|
|
87
88
|
init_cache_routes(shdata, authenticate_fn)
|
|
89
|
+
downloads_bp = init_downloads_routes(shdata, authenticate_fn)
|
|
88
90
|
init_system_routes(shdata, authenticate_fn)
|
|
89
91
|
|
|
90
92
|
# Register blueprints with /api prefix
|
|
@@ -94,6 +96,7 @@ def create_app(shdata=None, quiet=True):
|
|
|
94
96
|
app.register_blueprint(workers_bp, url_prefix='/api')
|
|
95
97
|
app.register_blueprint(metadata_bp, url_prefix='/api')
|
|
96
98
|
app.register_blueprint(cache_bp, url_prefix='/api')
|
|
99
|
+
app.register_blueprint(downloads_bp, url_prefix='/api')
|
|
97
100
|
app.register_blueprint(system_bp, url_prefix='/api')
|
|
98
101
|
|
|
99
102
|
# Register request lifecycle hooks
|
|
@@ -29,3 +29,10 @@ FORMAT_JSON = 'json'
|
|
|
29
29
|
FORMAT_CSV = 'csv'
|
|
30
30
|
FORMAT_BIN = 'bin'
|
|
31
31
|
FORMAT_BSON = 'bson'
|
|
32
|
+
|
|
33
|
+
# Downloads explorer (/api/list, /api/download)
|
|
34
|
+
DOWNLOAD_SLOT_LIMIT = 5
|
|
35
|
+
DOWNLOAD_SLOT_TTL_SECONDS = 6 * 60 * 60 # leak-safety if a worker dies mid-transfer
|
|
36
|
+
LIST_DEFAULT_DEPTH = 1
|
|
37
|
+
LIST_MAX_DEPTH = 3
|
|
38
|
+
LIST_MAX_ENTRIES = 5000
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path safety and directory listing for the DOWNLOADS_FOLDER explorer.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from SharedData.API.constants import LIST_MAX_ENTRIES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PathEscapeError(ValueError):
|
|
14
|
+
"""Requested path is outside DOWNLOADS_FOLDER."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DownloadsRootError(RuntimeError):
|
|
18
|
+
"""DOWNLOADS_FOLDER is missing or not a directory."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downloads_root() -> Path:
|
|
22
|
+
raw = os.environ.get('DOWNLOADS_FOLDER')
|
|
23
|
+
if not raw:
|
|
24
|
+
raise DownloadsRootError('DOWNLOADS_FOLDER is not set')
|
|
25
|
+
root = Path(raw).resolve()
|
|
26
|
+
if not root.is_dir():
|
|
27
|
+
raise DownloadsRootError(f'DOWNLOADS_FOLDER is not a directory: {root}')
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_under_downloads(relpath: str | None) -> Path:
|
|
32
|
+
"""
|
|
33
|
+
Resolve a client-supplied relative path so it stays inside DOWNLOADS_FOLDER.
|
|
34
|
+
|
|
35
|
+
Leading slashes are stripped. ``.``, empty, and ``/`` mean the folder root.
|
|
36
|
+
"""
|
|
37
|
+
root = downloads_root()
|
|
38
|
+
rel = (relpath or '').strip()
|
|
39
|
+
if rel in ('', '.', '/'):
|
|
40
|
+
return root
|
|
41
|
+
if '\x00' in rel:
|
|
42
|
+
raise PathEscapeError('invalid path')
|
|
43
|
+
rel = rel.lstrip('/')
|
|
44
|
+
candidate = (root / rel).resolve()
|
|
45
|
+
try:
|
|
46
|
+
candidate.relative_to(root)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise PathEscapeError('path escapes DOWNLOADS_FOLDER') from exc
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _relative_posix(path: Path, root: Path) -> str:
|
|
53
|
+
if path == root:
|
|
54
|
+
return ''
|
|
55
|
+
return path.relative_to(root).as_posix()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _skip_name(name: str) -> bool:
|
|
59
|
+
return name.startswith('.')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def list_entries(
|
|
63
|
+
relpath: str | None,
|
|
64
|
+
depth: int,
|
|
65
|
+
max_entries: int = LIST_MAX_ENTRIES,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
List files and directories under ``relpath`` up to ``depth`` levels.
|
|
69
|
+
|
|
70
|
+
``depth=1`` is the requested directory's immediate children only.
|
|
71
|
+
Hidden names (leading ``.``) are omitted. Escaping symlinks are skipped.
|
|
72
|
+
"""
|
|
73
|
+
root = downloads_root()
|
|
74
|
+
base = resolve_under_downloads(relpath)
|
|
75
|
+
if not base.exists():
|
|
76
|
+
raise FileNotFoundError(f'path not found: {relpath}')
|
|
77
|
+
if not base.is_dir():
|
|
78
|
+
raise NotADirectoryError(f'not a directory: {relpath}')
|
|
79
|
+
|
|
80
|
+
entries: list[dict[str, Any]] = []
|
|
81
|
+
truncated = False
|
|
82
|
+
|
|
83
|
+
def walk(dirpath: Path, remaining: int) -> None:
|
|
84
|
+
nonlocal truncated
|
|
85
|
+
if truncated:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
children = sorted(dirpath.iterdir(), key=lambda p: p.name.lower())
|
|
89
|
+
except OSError:
|
|
90
|
+
return
|
|
91
|
+
for child in children:
|
|
92
|
+
if truncated:
|
|
93
|
+
return
|
|
94
|
+
if _skip_name(child.name):
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
resolved = child.resolve()
|
|
98
|
+
resolved.relative_to(root)
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
st = child.stat()
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
is_dir = child.is_dir()
|
|
106
|
+
entries.append({
|
|
107
|
+
'name': child.name,
|
|
108
|
+
'path': _relative_posix(resolved if not is_dir else child.resolve(), root),
|
|
109
|
+
'type': 'dir' if is_dir else 'file',
|
|
110
|
+
'size': None if is_dir else int(st.st_size),
|
|
111
|
+
'mtime': int(st.st_mtime),
|
|
112
|
+
})
|
|
113
|
+
if len(entries) >= max_entries:
|
|
114
|
+
truncated = True
|
|
115
|
+
return
|
|
116
|
+
if is_dir and remaining > 1:
|
|
117
|
+
walk(child, remaining - 1)
|
|
118
|
+
|
|
119
|
+
walk(base, depth)
|
|
120
|
+
return {
|
|
121
|
+
'path': _relative_posix(base, root),
|
|
122
|
+
'depth': depth,
|
|
123
|
+
'truncated': truncated,
|
|
124
|
+
'count': len(entries),
|
|
125
|
+
'entries': entries,
|
|
126
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global concurrent-download limiter.
|
|
3
|
+
|
|
4
|
+
Gunicorn runs multiple worker processes (production: 8), so an in-memory
|
|
5
|
+
counter would allow limit × workers. This limiter is process- and host-safe:
|
|
6
|
+
|
|
7
|
+
- Redis (default when REDIS_CLUSTER_NODES is set): one counter for the
|
|
8
|
+
whole SharedData deployment.
|
|
9
|
+
- File lock under DOWNLOADS_FOLDER: fallback for tests / Redis-unavailable,
|
|
10
|
+
still global across hosts that share that NFS folder.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from filelock import FileLock
|
|
18
|
+
|
|
19
|
+
from SharedData.API.constants import DOWNLOAD_SLOT_LIMIT, DOWNLOAD_SLOT_TTL_SECONDS
|
|
20
|
+
from SharedData.API.download_paths import downloads_root
|
|
21
|
+
from SharedData.Logger import Logger
|
|
22
|
+
|
|
23
|
+
_REDIS_SLOT_KEY = '{shareddata}:download_slots'
|
|
24
|
+
|
|
25
|
+
_ACQUIRE_LUA = r"""
|
|
26
|
+
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
27
|
+
if n >= tonumber(ARGV[1]) then
|
|
28
|
+
return -1
|
|
29
|
+
end
|
|
30
|
+
n = redis.call('INCR', KEYS[1])
|
|
31
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
|
32
|
+
return n
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_RELEASE_LUA = r"""
|
|
36
|
+
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
37
|
+
if n <= 0 then
|
|
38
|
+
redis.call('SET', KEYS[1], 0)
|
|
39
|
+
return 0
|
|
40
|
+
end
|
|
41
|
+
return redis.call('DECR', KEYS[1])
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_limiter = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DownloadSlotLimiter:
|
|
48
|
+
def acquire(self) -> bool:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def release(self) -> None:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def active(self) -> int:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileDownloadSlotLimiter(DownloadSlotLimiter):
|
|
59
|
+
def __init__(self, root: Path | None = None, limit: int = DOWNLOAD_SLOT_LIMIT):
|
|
60
|
+
self.limit = limit
|
|
61
|
+
self.root = root if root is not None else downloads_root()
|
|
62
|
+
self.count_path = self.root / '.shareddata_download_slots.count'
|
|
63
|
+
self.lock_path = str(self.root / '.shareddata_download_slots.lock')
|
|
64
|
+
|
|
65
|
+
def _read(self) -> int:
|
|
66
|
+
try:
|
|
67
|
+
return max(0, int(self.count_path.read_text().strip() or '0'))
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
def acquire(self) -> bool:
|
|
72
|
+
with FileLock(self.lock_path):
|
|
73
|
+
n = self._read()
|
|
74
|
+
if n >= self.limit:
|
|
75
|
+
return False
|
|
76
|
+
self.count_path.write_text(str(n + 1))
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def release(self) -> None:
|
|
80
|
+
with FileLock(self.lock_path):
|
|
81
|
+
n = self._read()
|
|
82
|
+
self.count_path.write_text(str(max(0, n - 1)))
|
|
83
|
+
|
|
84
|
+
def active(self) -> int:
|
|
85
|
+
with FileLock(self.lock_path):
|
|
86
|
+
return self._read()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RedisDownloadSlotLimiter(DownloadSlotLimiter):
|
|
90
|
+
def __init__(self, limit: int = DOWNLOAD_SLOT_LIMIT, ttl: int = DOWNLOAD_SLOT_TTL_SECONDS):
|
|
91
|
+
from SharedData.CacheRedis import _build_sync_client
|
|
92
|
+
|
|
93
|
+
self.limit = limit
|
|
94
|
+
self.ttl = ttl
|
|
95
|
+
self._client = _build_sync_client()
|
|
96
|
+
self._acquire = self._client.register_script(_ACQUIRE_LUA)
|
|
97
|
+
self._release = self._client.register_script(_RELEASE_LUA)
|
|
98
|
+
|
|
99
|
+
def acquire(self) -> bool:
|
|
100
|
+
result = self._acquire(keys=[_REDIS_SLOT_KEY], args=[self.limit, self.ttl])
|
|
101
|
+
return int(result) > 0
|
|
102
|
+
|
|
103
|
+
def release(self) -> None:
|
|
104
|
+
self._release(keys=[_REDIS_SLOT_KEY], args=[])
|
|
105
|
+
|
|
106
|
+
def active(self) -> int:
|
|
107
|
+
raw = self._client.get(_REDIS_SLOT_KEY)
|
|
108
|
+
if raw is None:
|
|
109
|
+
return 0
|
|
110
|
+
try:
|
|
111
|
+
return max(0, int(raw))
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _make_limiter() -> DownloadSlotLimiter:
|
|
117
|
+
backend = os.environ.get('SHAREDDATA_DOWNLOAD_SLOT_BACKEND', '').strip().lower()
|
|
118
|
+
if backend == 'file':
|
|
119
|
+
return FileDownloadSlotLimiter()
|
|
120
|
+
if backend == 'redis' or os.environ.get('REDIS_CLUSTER_NODES'):
|
|
121
|
+
try:
|
|
122
|
+
return RedisDownloadSlotLimiter()
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
Logger.log.warning(
|
|
125
|
+
'download slot limiter: Redis unavailable (%s); using file backend',
|
|
126
|
+
exc,
|
|
127
|
+
)
|
|
128
|
+
return FileDownloadSlotLimiter()
|
|
129
|
+
return FileDownloadSlotLimiter()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_limiter() -> DownloadSlotLimiter:
|
|
133
|
+
global _limiter
|
|
134
|
+
if _limiter is None:
|
|
135
|
+
_limiter = _make_limiter()
|
|
136
|
+
return _limiter
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reset_limiter() -> None:
|
|
140
|
+
"""Drop the cached limiter (tests only)."""
|
|
141
|
+
global _limiter
|
|
142
|
+
_limiter = None
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Downloads explorer blueprint: GET /api/list and GET /api/download.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from flask import Blueprint, Response, jsonify, request, stream_with_context
|
|
9
|
+
|
|
10
|
+
from SharedData.API.constants import (
|
|
11
|
+
DOWNLOAD_SLOT_LIMIT,
|
|
12
|
+
ERROR_SLEEP_SECONDS,
|
|
13
|
+
LIST_DEFAULT_DEPTH,
|
|
14
|
+
LIST_MAX_DEPTH,
|
|
15
|
+
)
|
|
16
|
+
from SharedData.API.download_paths import (
|
|
17
|
+
DownloadsRootError,
|
|
18
|
+
PathEscapeError,
|
|
19
|
+
list_entries,
|
|
20
|
+
resolve_under_downloads,
|
|
21
|
+
)
|
|
22
|
+
from SharedData.API.download_slots import get_limiter
|
|
23
|
+
from SharedData.API.utils import make_error_response, make_json_response, parse_int_param
|
|
24
|
+
from SharedData.Logger import Logger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clamp_depth(raw: str | None) -> int:
|
|
28
|
+
depth = parse_int_param(raw, LIST_DEFAULT_DEPTH)
|
|
29
|
+
if depth < 1:
|
|
30
|
+
return 1
|
|
31
|
+
if depth > LIST_MAX_DEPTH:
|
|
32
|
+
return LIST_MAX_DEPTH
|
|
33
|
+
return depth
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def init_downloads_routes(shdata, authenticate_fn):
|
|
37
|
+
"""
|
|
38
|
+
Initialize downloads routes with required dependencies.
|
|
39
|
+
|
|
40
|
+
Returns a fresh blueprint so create_app / tests can register it more
|
|
41
|
+
than once in the same process.
|
|
42
|
+
|
|
43
|
+
Parameters:
|
|
44
|
+
shdata: SharedData instance.
|
|
45
|
+
authenticate_fn: Authentication function.
|
|
46
|
+
"""
|
|
47
|
+
downloads_bp = Blueprint('downloads', __name__)
|
|
48
|
+
|
|
49
|
+
@downloads_bp.route('/list', methods=['GET'])
|
|
50
|
+
def list_downloads():
|
|
51
|
+
"""GET /api/list — list files under DOWNLOADS_FOLDER up to a depth cap."""
|
|
52
|
+
try:
|
|
53
|
+
if not authenticate_fn(request, shdata):
|
|
54
|
+
return jsonify({'error': 'unauthorized'}), 401
|
|
55
|
+
|
|
56
|
+
depth = _clamp_depth(request.args.get('depth'))
|
|
57
|
+
payload = list_entries(request.args.get('path'), depth)
|
|
58
|
+
return make_json_response(payload)
|
|
59
|
+
except PathEscapeError as e:
|
|
60
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
61
|
+
except DownloadsRootError as e:
|
|
62
|
+
return make_json_response({'error': str(e)}, status=503)
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
return make_json_response({'error': str(e)}, status=404)
|
|
65
|
+
except NotADirectoryError as e:
|
|
66
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
Logger.log.error(f'list_downloads error: {e}')
|
|
69
|
+
time.sleep(ERROR_SLEEP_SECONDS)
|
|
70
|
+
return make_error_response(e)
|
|
71
|
+
|
|
72
|
+
@downloads_bp.route('/download', methods=['GET'])
|
|
73
|
+
def download_file():
|
|
74
|
+
"""GET /api/download — stream one regular file; 429 if 5 transfers are active."""
|
|
75
|
+
try:
|
|
76
|
+
if not authenticate_fn(request, shdata):
|
|
77
|
+
return jsonify({'error': 'unauthorized'}), 401
|
|
78
|
+
|
|
79
|
+
target = resolve_under_downloads(request.args.get('path'))
|
|
80
|
+
if not target.exists():
|
|
81
|
+
return make_json_response(
|
|
82
|
+
{'error': f'path not found: {request.args.get("path")}'},
|
|
83
|
+
status=404,
|
|
84
|
+
)
|
|
85
|
+
if not target.is_file():
|
|
86
|
+
return make_json_response(
|
|
87
|
+
{'error': 'not a file'},
|
|
88
|
+
status=400,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
limiter = get_limiter()
|
|
92
|
+
if not limiter.acquire():
|
|
93
|
+
return make_json_response(
|
|
94
|
+
{
|
|
95
|
+
'error': 'download limit reached',
|
|
96
|
+
'limit': DOWNLOAD_SLOT_LIMIT,
|
|
97
|
+
'active': limiter.active(),
|
|
98
|
+
},
|
|
99
|
+
status=429,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
released = False
|
|
103
|
+
|
|
104
|
+
def _release_once():
|
|
105
|
+
nonlocal released
|
|
106
|
+
if released:
|
|
107
|
+
return
|
|
108
|
+
released = True
|
|
109
|
+
limiter.release()
|
|
110
|
+
|
|
111
|
+
def _stream():
|
|
112
|
+
try:
|
|
113
|
+
with target.open('rb') as fh:
|
|
114
|
+
while True:
|
|
115
|
+
chunk = fh.read(1024 * 1024)
|
|
116
|
+
if not chunk:
|
|
117
|
+
break
|
|
118
|
+
yield chunk
|
|
119
|
+
finally:
|
|
120
|
+
_release_once()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response = Response(
|
|
124
|
+
stream_with_context(_stream()),
|
|
125
|
+
mimetype='application/octet-stream',
|
|
126
|
+
direct_passthrough=True,
|
|
127
|
+
)
|
|
128
|
+
response.headers['Content-Disposition'] = (
|
|
129
|
+
f'attachment; filename="{target.name}"'
|
|
130
|
+
)
|
|
131
|
+
response.headers['Content-Length'] = str(target.stat().st_size)
|
|
132
|
+
response.call_on_close(_release_once)
|
|
133
|
+
except Exception:
|
|
134
|
+
_release_once()
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
return response
|
|
138
|
+
except PathEscapeError as e:
|
|
139
|
+
return make_json_response({'error': str(e)}, status=400)
|
|
140
|
+
except DownloadsRootError as e:
|
|
141
|
+
return make_json_response({'error': str(e)}, status=503)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
Logger.log.error(f'download_file error: {e}')
|
|
144
|
+
time.sleep(ERROR_SLEEP_SECONDS)
|
|
145
|
+
return make_error_response(e)
|
|
146
|
+
|
|
147
|
+
return downloads_bp
|
|
@@ -3054,3 +3054,66 @@ class ClientAPI:
|
|
|
3054
3054
|
response = requests.post(url, params=params)
|
|
3055
3055
|
ClientAPI.raise_for_status(response)
|
|
3056
3056
|
return response.json().get('deleted', 0)
|
|
3057
|
+
|
|
3058
|
+
################# DOWNLOADS #################
|
|
3059
|
+
|
|
3060
|
+
@staticmethod
|
|
3061
|
+
def list_downloads(path: str = '', depth: int = 1,
|
|
3062
|
+
endpoint=None, token=None):
|
|
3063
|
+
"""
|
|
3064
|
+
GET /api/list — list files and folders under DOWNLOADS_FOLDER.
|
|
3065
|
+
|
|
3066
|
+
Parameters:
|
|
3067
|
+
path (str): Relative path under DOWNLOADS_FOLDER. Empty lists the root.
|
|
3068
|
+
depth (int): Directory depth to include (server caps at 3).
|
|
3069
|
+
endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
|
|
3070
|
+
token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
|
|
3071
|
+
|
|
3072
|
+
Returns:
|
|
3073
|
+
dict: {path, depth, truncated, count, entries}
|
|
3074
|
+
"""
|
|
3075
|
+
url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
|
|
3076
|
+
url += '/api/list'
|
|
3077
|
+
params = {'path': path, 'depth': depth}
|
|
3078
|
+
if token is not None:
|
|
3079
|
+
params['token'] = token
|
|
3080
|
+
else:
|
|
3081
|
+
params['token'] = os.environ.get('SHAREDDATA_TOKEN')
|
|
3082
|
+
if not params['token']:
|
|
3083
|
+
raise Exception('SHAREDDATA_TOKEN not found in environment variables')
|
|
3084
|
+
response = requests.get(url, params=params, headers={'Accept': 'application/json'})
|
|
3085
|
+
ClientAPI.raise_for_status(response)
|
|
3086
|
+
return response.json()
|
|
3087
|
+
|
|
3088
|
+
@staticmethod
|
|
3089
|
+
def download_file(path: str, dest, endpoint=None, token=None):
|
|
3090
|
+
"""
|
|
3091
|
+
GET /api/download — stream one file from DOWNLOADS_FOLDER to ``dest``.
|
|
3092
|
+
|
|
3093
|
+
Parameters:
|
|
3094
|
+
path (str): Relative file path under DOWNLOADS_FOLDER.
|
|
3095
|
+
dest: Local filesystem path to write.
|
|
3096
|
+
endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
|
|
3097
|
+
token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
|
|
3098
|
+
|
|
3099
|
+
Returns:
|
|
3100
|
+
str: Absolute destination path.
|
|
3101
|
+
"""
|
|
3102
|
+
url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
|
|
3103
|
+
url += '/api/download'
|
|
3104
|
+
params = {'path': path}
|
|
3105
|
+
if token is not None:
|
|
3106
|
+
params['token'] = token
|
|
3107
|
+
else:
|
|
3108
|
+
params['token'] = os.environ.get('SHAREDDATA_TOKEN')
|
|
3109
|
+
if not params['token']:
|
|
3110
|
+
raise Exception('SHAREDDATA_TOKEN not found in environment variables')
|
|
3111
|
+
response = requests.get(url, params=params, stream=True)
|
|
3112
|
+
ClientAPI.raise_for_status(response)
|
|
3113
|
+
dest_path = os.path.abspath(str(dest))
|
|
3114
|
+
os.makedirs(os.path.dirname(dest_path) or '.', exist_ok=True)
|
|
3115
|
+
with open(dest_path, 'wb') as fh:
|
|
3116
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
3117
|
+
if chunk:
|
|
3118
|
+
fh.write(chunk)
|
|
3119
|
+
return dest_path
|
|
@@ -62,7 +62,7 @@ class MongoDBClient:
|
|
|
62
62
|
f'&serverSelectionTimeoutMS=10000'
|
|
63
63
|
)
|
|
64
64
|
elif 'MONGODB_REPLICA_SET' not in os.environ:
|
|
65
|
-
# Single server connection (MONGODB_HOST includes port: '
|
|
65
|
+
# Single server connection (MONGODB_HOST includes port: '192.0.2.10:27017')
|
|
66
66
|
self.mongodb_conn_str = (
|
|
67
67
|
f'mongodb://{os.environ["MONGODB_USER"]}:'
|
|
68
68
|
f'{os.environ["MONGODB_PWD"]}@'
|
|
@@ -226,8 +226,8 @@ while True:
|
|
|
226
226
|
|
|
227
227
|
# Scheduler lifecycle — watchdog works in both election and non-election
|
|
228
228
|
# modes. In election mode, only the current master owns the scheduler and
|
|
229
|
-
# runs 'MASTER'. In non-election mode (static deploy, e.g.
|
|
230
|
-
# --schedules MASTER,
|
|
229
|
+
# runs 'MASTER'. In non-election mode (static deploy, e.g. WORKER01 with
|
|
230
|
+
# --schedules MASTER,WORKER02), this worker always owns the scheduler
|
|
231
231
|
# and runs its configured SCHEDULE_NAMES.
|
|
232
232
|
election_mode = str(os.environ.get('RUN_MASTER_ELECTION', '')).upper() == 'TRUE'
|
|
233
233
|
if election_mode:
|
|
@@ -42,7 +42,7 @@ def get_own_ip():
|
|
|
42
42
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
43
43
|
try:
|
|
44
44
|
# Doesn't need to be reachable, just used to determine the interface
|
|
45
|
-
s.connect(('
|
|
45
|
+
s.connect(('192.0.2.1', 1))
|
|
46
46
|
own_ip = s.getsockname()[0]
|
|
47
47
|
except Exception:
|
|
48
48
|
own_ip = '127.0.0.1'
|