shareddata 6.83.17__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.
Files changed (111) hide show
  1. shareddata-6.83.21/MANIFEST.in +3 -0
  2. {shareddata-6.83.17/src/shareddata.egg-info → shareddata-6.83.21}/PKG-INFO +2 -3
  3. {shareddata-6.83.17 → shareddata-6.83.21}/setup.py +2 -2
  4. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/__init__.py +3 -0
  5. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/constants.py +7 -0
  6. shareddata-6.83.21/src/SharedData/API/download_paths.py +126 -0
  7. shareddata-6.83.21/src/SharedData/API/download_slots.py +142 -0
  8. shareddata-6.83.21/src/SharedData/API/routes/downloads.py +147 -0
  9. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/ClientAPI.py +63 -0
  10. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/MongoDBClient.py +1 -1
  11. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Logger.py +7 -11
  12. shareddata-6.83.21/src/SharedData/Routines/UpdateActiveJobs.py +53 -0
  13. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/Worker.py +1 -1
  14. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/WorkerLib.py +1 -1
  15. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/WorkerPool.py +81 -76
  16. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/SharedNumpy.py +128 -51
  17. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Table.py +1 -0
  18. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TimeSeriesDisk.py +1 -0
  19. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TimeseriesContainer.py +1 -0
  20. {shareddata-6.83.17 → shareddata-6.83.21/src/shareddata.egg-info}/PKG-INFO +2 -3
  21. {shareddata-6.83.17 → shareddata-6.83.21}/src/shareddata.egg-info/SOURCES.txt +4 -28
  22. shareddata-6.83.17/MANIFEST.in +0 -1
  23. shareddata-6.83.17/src/SharedData/Routines/UpdateActiveJobs.py +0 -75
  24. shareddata-6.83.17/tests/test_api_collection.py +0 -119
  25. shareddata-6.83.17/tests/test_api_collection_loopback.py +0 -164
  26. shareddata-6.83.17/tests/test_api_table.py +0 -201
  27. shareddata-6.83.17/tests/test_api_table_production_flows.py +0 -414
  28. shareddata-6.83.17/tests/test_api_table_schemaless.py +0 -261
  29. shareddata-6.83.17/tests/test_api_table_schemaless_extend.py +0 -131
  30. shareddata-6.83.17/tests/test_bson_last_pos_reuse.py +0 -468
  31. shareddata-6.83.17/tests/test_cache_redis.py +0 -162
  32. shareddata-6.83.17/tests/test_extend_rt.py +0 -69
  33. shareddata-6.83.17/tests/test_listcache.py +0 -94
  34. shareddata-6.83.17/tests/test_loc.py +0 -149
  35. shareddata-6.83.17/tests/test_logger_thread_safety.py +0 -47
  36. shareddata-6.83.17/tests/test_metadata.py +0 -0
  37. shareddata-6.83.17/tests/test_mount_unmount_detection.py +0 -93
  38. shareddata-6.83.17/tests/test_read_write_tail.py +0 -48
  39. shareddata-6.83.17/tests/test_schema_acquire_timeout.py +0 -47
  40. shareddata-6.83.17/tests/test_stream_loopback_async.py +0 -169
  41. shareddata-6.83.17/tests/test_table_auto_grow_all_pkeys.py +0 -98
  42. shareddata-6.83.17/tests/test_timeseries.py +0 -1137
  43. shareddata-6.83.17/tests/test_timeseries_api.py +0 -391
  44. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_d1.py +0 -80
  45. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m1.py +0 -46
  46. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m15.py +0 -46
  47. shareddata-6.83.17/tests/test_upsert_unordered_positions_m1.py +0 -47
  48. shareddata-6.83.17/tests/test_workerpool_jobs_status.py +0 -283
  49. shareddata-6.83.17/tests/test_workerpool_mutex_release.py +0 -55
  50. shareddata-6.83.17/tests/test_workerpool_post_commands.py +0 -146
  51. {shareddata-6.83.17 → shareddata-6.83.21}/LICENSE +0 -0
  52. {shareddata-6.83.17 → shareddata-6.83.21}/README.md +0 -0
  53. {shareddata-6.83.17 → shareddata-6.83.21}/pyproject.toml +0 -0
  54. {shareddata-6.83.17 → shareddata-6.83.21}/setup.cfg +0 -0
  55. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/ServerGunicorn.py +0 -0
  56. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/ServerWaitress.py +0 -0
  57. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/auth.py +0 -0
  58. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/listcache.py +0 -0
  59. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/__init__.py +0 -0
  60. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/cache.py +0 -0
  61. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/collections.py +0 -0
  62. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/metadata.py +0 -0
  63. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/system.py +0 -0
  64. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/tables.py +0 -0
  65. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/timeseries.py +0 -0
  66. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/routes/workers.py +0 -0
  67. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/API/utils.py +0 -0
  68. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/CacheRedis.py +0 -0
  69. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/CollectionMongoDB.py +0 -0
  70. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Database.py +0 -0
  71. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Defaults.py +0 -0
  72. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/AWSEC2.py +0 -0
  73. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/AWSS3.py +0 -0
  74. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/AutoDocstrings.py +0 -0
  75. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/ClientSocket.py +0 -0
  76. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/ClientWebSocket.py +0 -0
  77. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  78. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/SaveTables.py +0 -0
  79. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/ServerSocket.py +0 -0
  80. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/ServerWebSocket.py +0 -0
  81. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/StreamsCache.py +0 -0
  82. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/StreamsPersist.py +0 -0
  83. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/SyncTable.py +0 -0
  84. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  85. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/IO/__init__.py +0 -0
  86. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Metadata.py +0 -0
  87. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/MultiProc.py +0 -0
  88. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/OpenFIGI.py +0 -0
  89. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
  90. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/BatchJob.py +0 -0
  91. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/Schedule.py +0 -0
  92. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  93. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/Scheduler.py +0 -0
  94. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Routines/__init__.py +0 -0
  95. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/SharedData.py +0 -0
  96. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/StreamKafka.py +0 -0
  97. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Symbol.py +0 -0
  98. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableDisk.py +0 -0
  99. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndex.py +0 -0
  100. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctions.py +0 -0
  101. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  102. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndexJitGenerate.py +0 -0
  103. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndexJitHash.py +0 -0
  104. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/TableIndexJitLoc.py +0 -0
  105. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Users.py +0 -0
  106. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/Utils.py +0 -0
  107. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/__init__.py +0 -0
  108. {shareddata-6.83.17 → shareddata-6.83.21}/src/SharedData/sharedmutexwin.pyd +0 -0
  109. {shareddata-6.83.17 → shareddata-6.83.21}/src/shareddata.egg-info/dependency_links.txt +0 -0
  110. {shareddata-6.83.17 → shareddata-6.83.21}/src/shareddata.egg-info/requires.txt +0 -0
  111. {shareddata-6.83.17 → shareddata-6.83.21}/src/shareddata.egg-info/top_level.txt +0 -0
@@ -0,0 +1,3 @@
1
+ include src/SharedData/sharedmutexwin.pyd
2
+ include src/SharedData/IO/apidocs.yml
3
+ prune tests
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.83.17
3
+ Version: 6.83.21
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
- License: MIT
6
5
  Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
7
  Classifier: Operating System :: OS Independent
8
8
  Requires-Python: >=3.9, <3.13
9
9
  Description-Content-Type: text/markdown
@@ -39,7 +39,6 @@ Requires-Dist: redis==6.2.0
39
39
  Dynamic: classifier
40
40
  Dynamic: description
41
41
  Dynamic: description-content-type
42
- Dynamic: license
43
42
  Dynamic: license-file
44
43
  Dynamic: requires-dist
45
44
  Dynamic: requires-python
@@ -34,13 +34,13 @@ install_requires = [
34
34
 
35
35
  setup(
36
36
  name='shareddata',
37
- version='6.83.17',
37
+ version='6.83.21',
38
38
  description='Memory Mapped / Shared Memory Database with S3 repository',
39
- license='MIT',
40
39
  long_description=open('README.md').read(),
41
40
  long_description_content_type='text/markdown',
42
41
  classifiers=[
43
42
  'Programming Language :: Python :: 3',
43
+ 'License :: OSI Approved :: MIT License',
44
44
  'Operating System :: OS Independent',
45
45
  ],
46
46
  packages=find_packages(where='src'),
@@ -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
@@ -260,4 +260,4 @@ class MongoDBClient:
260
260
  if pymongo.helpers._index_list(index_data['key']) == target_index:
261
261
  return # Index already exists
262
262
 
263
- coll.create_index(index_fields, **kwargs)
263
+ coll.create_index(index_fields, **kwargs)
@@ -340,18 +340,14 @@ class Logger:
340
340
  Returns:
341
341
  pandas.DataFrame: The updated DataFrame containing processed log entries.
342
342
  """
343
- # Serialize cursor + dflogs mutation: gunicorn gthread workers run
344
- # concurrent request threads that would otherwise race _logfileposition
345
- # / dflogs and serve inconsistent tails to UI polls.
346
- with Logger.lock:
347
- dfnewlines_sorted = Logger.readLogs()
348
- if dfnewlines_sorted is None or len(dfnewlines_sorted) == 0:
349
- return Logger.dflogs
350
-
351
- Logger.getLastLog(dfnewlines_sorted)
352
- Logger.getStatus(dfnewlines_sorted)
353
- Logger.handleHeartbeats(keep_latest_heartbeat)
343
+ dfnewlines_sorted = Logger.readLogs()
344
+ if dfnewlines_sorted is None or len(dfnewlines_sorted) == 0:
354
345
  return Logger.dflogs
346
+
347
+ Logger.getLastLog(dfnewlines_sorted)
348
+ Logger.getStatus(dfnewlines_sorted)
349
+ Logger.handleHeartbeats(keep_latest_heartbeat)
350
+ return Logger.dflogs
355
351
 
356
352
  @staticmethod
357
353
  def readLogs():
@@ -0,0 +1,53 @@
1
+ import faulthandler
2
+ import time
3
+
4
+ # Capture a Python-level traceback if a C extension (pandas_parser, numpy,
5
+ # pyarrow, ...) crashes the interpreter with SIGSEGV/SIGABRT/SIGFPE/SIGBUS.
6
+ # Without this, a crash leaves only a kernel segfault line with a raw IP.
7
+ faulthandler.enable()
8
+
9
+ # PROPRIETARY LIBS
10
+ from SharedData.Routines.WorkerPool import WorkerPool
11
+ from SharedData.Logger import Logger
12
+ from SharedData.SharedData import SharedData
13
+ shdata = SharedData('SharedData.Routines.UpdateActiveJobs', user='master')
14
+
15
+ Logger.log.info('ROUTINE STARTED!')
16
+
17
+ # Standalone routine form of the promotion sweep formerly run as an
18
+ # in-process Worker thread (WorkerPool.update_active_jobs). Splitting it out
19
+ # lets it be killed/restarted independently via the normal workerpool
20
+ # commands without tearing down the Worker's API server and every other
21
+ # child routine on the host — see docs/INCIDENT_WORKERPOOL_JOBS_INDEX_20260724.md.
22
+ MAX_ERRORS = 10
23
+ error_count = 0
24
+ lastheartbeat = time.time()
25
+
26
+ while True:
27
+ try:
28
+ if time.time() - lastheartbeat > 15:
29
+ lastheartbeat = time.time()
30
+ Logger.log.debug('#heartbeat#errors=%d' % error_count)
31
+
32
+ retry_delay = 15
33
+ try:
34
+ WorkerPool.update_active_jobs_once(shdata)
35
+ error_count = 0
36
+ except Exception as e:
37
+ Logger.log.error('Error in update_active_jobs_once: %s' % (str(e)))
38
+ error_count += 1
39
+ retry_delay = 60
40
+ if error_count >= MAX_ERRORS:
41
+ Logger.log.error(
42
+ 'Max errors (%d) reached, exiting' % (MAX_ERRORS))
43
+ raise SystemExit(1)
44
+
45
+ # Never hold the JOBS mutex during the retry backoff —
46
+ # update_active_jobs_once() always releases it before raising or
47
+ # returning.
48
+ time.sleep(retry_delay)
49
+ except SystemExit:
50
+ raise
51
+ except Exception as e:
52
+ Logger.log.error('UpdateActiveJobs Loop Exception: %s' % (str(e)))
53
+ time.sleep(60)
@@ -330,4 +330,4 @@ while True:
330
330
  )
331
331
  # Logger.log.debug(f'#heartbeat# {nroutines}routines,{running_batch_jobs}/{MAX_BATCH_JOBS}jobs,{completed_batch_jobs}completed,{error_batch_jobs}errors,{SHAREDDATA_VERSION}')
332
332
 
333
- time.sleep(SLEEP_TIME * np.random.rand())
333
+ time.sleep(SLEEP_TIME * np.random.rand())
@@ -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(('10.255.255.255', 1))
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'