shareddata 6.83.8__tar.gz → 6.83.16__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 (105) hide show
  1. {shareddata-6.83.8/src/shareddata.egg-info → shareddata-6.83.16}/PKG-INFO +1 -1
  2. {shareddata-6.83.8 → shareddata-6.83.16}/setup.py +1 -1
  3. shareddata-6.83.16/src/SharedData/API/listcache.py +91 -0
  4. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/collections.py +14 -6
  5. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/tables.py +74 -39
  6. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/timeseries.py +18 -15
  7. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/CollectionMongoDB.py +1 -1
  8. shareddata-6.83.16/src/SharedData/Defaults.py +313 -0
  9. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Logger.py +58 -26
  10. shareddata-6.83.16/src/SharedData/Routines/ArchiveOldCollections.py +254 -0
  11. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Schedule.py +3 -3
  12. shareddata-6.83.16/src/SharedData/Routines/UpdateActiveJobs.py +53 -0
  13. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Worker.py +8 -7
  14. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/WorkerPool.py +190 -159
  15. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/SharedData.py +141 -34
  16. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/SharedNumpy.py +6 -6
  17. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Table.py +223 -62
  18. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableDisk.py +39 -2
  19. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndex.py +70 -29
  20. {shareddata-6.83.8 → shareddata-6.83.16/src/shareddata.egg-info}/PKG-INFO +1 -1
  21. {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/SOURCES.txt +11 -1
  22. shareddata-6.83.16/tests/test_api_table_production_flows.py +414 -0
  23. shareddata-6.83.16/tests/test_listcache.py +94 -0
  24. shareddata-6.83.16/tests/test_mount_unmount_detection.py +93 -0
  25. shareddata-6.83.16/tests/test_schema_acquire_timeout.py +47 -0
  26. shareddata-6.83.16/tests/test_table_auto_grow_all_pkeys.py +98 -0
  27. shareddata-6.83.16/tests/test_workerpool_mutex_release.py +55 -0
  28. shareddata-6.83.16/tests/test_workerpool_post_commands.py +141 -0
  29. shareddata-6.83.8/src/SharedData/Defaults.py +0 -102
  30. {shareddata-6.83.8 → shareddata-6.83.16}/LICENSE +0 -0
  31. {shareddata-6.83.8 → shareddata-6.83.16}/MANIFEST.in +0 -0
  32. {shareddata-6.83.8 → shareddata-6.83.16}/README.md +0 -0
  33. {shareddata-6.83.8 → shareddata-6.83.16}/pyproject.toml +0 -0
  34. {shareddata-6.83.8 → shareddata-6.83.16}/setup.cfg +0 -0
  35. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/ServerGunicorn.py +0 -0
  36. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/ServerWaitress.py +0 -0
  37. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/__init__.py +0 -0
  38. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/auth.py +0 -0
  39. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/constants.py +0 -0
  40. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/__init__.py +0 -0
  41. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/cache.py +0 -0
  42. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/metadata.py +0 -0
  43. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/system.py +0 -0
  44. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/workers.py +0 -0
  45. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/utils.py +0 -0
  46. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/CacheRedis.py +0 -0
  47. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Database.py +0 -0
  48. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AWSEC2.py +0 -0
  49. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AWSS3.py +0 -0
  50. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AutoDocstrings.py +0 -0
  51. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientAPI.py +0 -0
  52. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientSocket.py +0 -0
  53. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientWebSocket.py +0 -0
  54. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  55. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/MongoDBClient.py +0 -0
  56. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/SaveTables.py +0 -0
  57. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ServerSocket.py +0 -0
  58. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ServerWebSocket.py +0 -0
  59. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/StreamsCache.py +0 -0
  60. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/StreamsPersist.py +0 -0
  61. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/SyncTable.py +0 -0
  62. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  63. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/__init__.py +0 -0
  64. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Metadata.py +0 -0
  65. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/MultiProc.py +0 -0
  66. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/OpenFIGI.py +0 -0
  67. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/BatchJob.py +0 -0
  68. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  69. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Scheduler.py +0 -0
  70. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/WorkerLib.py +0 -0
  71. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/__init__.py +0 -0
  72. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/StreamKafka.py +0 -0
  73. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Symbol.py +0 -0
  74. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitFunctions.py +0 -0
  75. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  76. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitGenerate.py +0 -0
  77. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitHash.py +0 -0
  78. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitLoc.py +0 -0
  79. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TimeSeriesDisk.py +0 -0
  80. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TimeseriesContainer.py +0 -0
  81. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Users.py +0 -0
  82. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Utils.py +0 -0
  83. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/__init__.py +0 -0
  84. {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/sharedmutexwin.pyd +0 -0
  85. {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/dependency_links.txt +0 -0
  86. {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/requires.txt +0 -0
  87. {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/top_level.txt +0 -0
  88. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_collection.py +0 -0
  89. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_collection_loopback.py +0 -0
  90. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table.py +0 -0
  91. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table_schemaless.py +0 -0
  92. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table_schemaless_extend.py +0 -0
  93. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_bson_last_pos_reuse.py +0 -0
  94. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_cache_redis.py +0 -0
  95. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_extend_rt.py +0 -0
  96. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_loc.py +0 -0
  97. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_metadata.py +0 -0
  98. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_read_write_tail.py +0 -0
  99. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_stream_loopback_async.py +0 -0
  100. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_timeseries.py +0 -0
  101. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_timeseries_api.py +0 -0
  102. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_d1.py +0 -0
  103. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_m1.py +0 -0
  104. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_m15.py +0 -0
  105. {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_positions_m1.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.83.8
3
+ Version: 6.83.16
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -34,7 +34,7 @@ install_requires = [
34
34
 
35
35
  setup(
36
36
  name='shareddata',
37
- version='6.83.8',
37
+ version='6.83.16',
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',
@@ -0,0 +1,91 @@
1
+ """
2
+ TTL + single-flight cache for the expensive listing endpoints.
3
+
4
+ ``/api/tables``, ``/api/timeseries`` and ``/api/collections`` enumerate every
5
+ partition on S3 (``list_remote``) and on local disk (``list_local``). With an
6
+ empty ``keyword`` this scans the entire data lake and can take well over a
7
+ minute. When several callers (the Dashboard polling + workers) hit the route at
8
+ once, each one launches an independent full scan, the responses materialize in
9
+ memory simultaneously, and the box sawtooths into swap while every request
10
+ exceeds the upstream gateway timeout and returns 502/504.
11
+
12
+ This cache fixes the dominant failure mode: it memoizes each listing for a short
13
+ TTL and, crucially, runs the producer under a per-key lock (single-flight) so at
14
+ most one scan executes per key at a time. Concurrent callers block on the lock
15
+ and then receive the freshly cached result instead of each starting their own
16
+ scan. Steady-state polling becomes instant; the storm of overlapping full scans
17
+ disappears.
18
+
19
+ Note: the very first (cold) call for a key still pays the full scan cost. That
20
+ is inherent to a no-keyword full enumeration; pair this with a higher gateway
21
+ read-timeout on the listing routes if cold calls must also stay under the proxy
22
+ limit.
23
+ """
24
+
25
+ import threading
26
+ import time
27
+
28
+
29
+ class _Entry:
30
+ __slots__ = ("value", "ts", "lock")
31
+
32
+ def __init__(self):
33
+ self.value = None
34
+ self.ts = 0.0
35
+ self.lock = threading.Lock()
36
+
37
+
38
+ class TTLSingleFlightCache:
39
+ """Thread-safe TTL cache that collapses concurrent misses into one producer call."""
40
+
41
+ def __init__(self, ttl=60.0):
42
+ self.ttl = float(ttl)
43
+ self._entries = {}
44
+ self._guard = threading.Lock()
45
+
46
+ def _entry(self, key):
47
+ # Fast path under a short global lock just to fetch/create the per-key entry.
48
+ with self._guard:
49
+ entry = self._entries.get(key)
50
+ if entry is None:
51
+ entry = _Entry()
52
+ self._entries[key] = entry
53
+ return entry
54
+
55
+ def get(self, key, producer, ttl=None):
56
+ """
57
+ Return the cached value for ``key`` if fresh, otherwise call ``producer()``.
58
+
59
+ Only one thread runs ``producer`` per key at a time; others wait on the
60
+ per-key lock and then receive the value the winner just stored.
61
+ """
62
+ ttl = self.ttl if ttl is None else float(ttl)
63
+ entry = self._entry(key)
64
+
65
+ # Fresh hit, no locking needed.
66
+ if entry.value is not None and (time.time() - entry.ts) < ttl:
67
+ return entry.value
68
+
69
+ # Stale or empty -> single-flight refresh.
70
+ with entry.lock:
71
+ # Another thread may have refreshed while we waited on the lock.
72
+ if entry.value is not None and (time.time() - entry.ts) < ttl:
73
+ return entry.value
74
+ value = producer()
75
+ entry.value = value
76
+ entry.ts = time.time()
77
+ return value
78
+
79
+ def invalidate(self, key=None):
80
+ """Drop one key (or the whole cache when ``key`` is None)."""
81
+ with self._guard:
82
+ if key is None:
83
+ self._entries.clear()
84
+ else:
85
+ self._entries.pop(key, None)
86
+
87
+
88
+ # Module-level singleton shared by the listing routes.
89
+ # 60s TTL: listing metadata changes slowly, and a minute of staleness is
90
+ # acceptable for a directory view while it eliminates the scan pile-up.
91
+ LIST_CACHE = TTLSingleFlightCache(ttl=60.0)
@@ -23,6 +23,7 @@ from SharedData.API.constants import (
23
23
  DEFAULT_PAGE_SIZE,
24
24
  ERROR_SLEEP_SECONDS,
25
25
  )
26
+ from SharedData.API.listcache import LIST_CACHE
26
27
  from SharedData.API.utils import (
27
28
  apply_subfolder,
28
29
  make_empty_response,
@@ -99,13 +100,20 @@ def init_collections_routes(shdata, authenticate_fn):
99
100
  return jsonify({'error': 'unauthorized'}), 401
100
101
  keyword = request.args.get('keyword', '')
101
102
  user = request.args.get('user', DEFAULT_USER)
102
- collections = shdata.list_collections(keyword=keyword, user=user)
103
- if collections.empty:
103
+
104
+ def _produce():
105
+ collections = shdata.list_collections(keyword=keyword, user=user)
106
+ if collections.empty:
107
+ return b'' # sentinel -> 204
108
+ collections_recs = collections.reset_index().to_dict('records')
109
+ collections_recs = CollectionMongoDB.serialize(collections_recs, iso_dates=True)
110
+ return json.dumps(collections_recs).encode('utf-8')
111
+
112
+ # Single-flight + TTL cache: see listcache.py.
113
+ response_data = LIST_CACHE.get(('collections', user, keyword), _produce)
114
+ if not response_data:
104
115
  return Response(status=204)
105
- collections = collections.reset_index().to_dict('records')
106
- collections = CollectionMongoDB.serialize(collections, iso_dates=True)
107
- response_data = json.dumps(collections).encode('utf-8')
108
- response = Response(response_data, mimetype='application/json')
116
+ response = Response(response_data, mimetype='application/json')
109
117
  return response
110
118
  except Exception as e:
111
119
  response = Response(status=500)
@@ -21,6 +21,7 @@ from SharedData.API.constants import (
21
21
  ERROR_SLEEP_SECONDS,
22
22
  MAX_RESPONSE_SIZE_BYTES,
23
23
  )
24
+ from SharedData.API.listcache import LIST_CACHE
24
25
  from SharedData.API.utils import (
25
26
  apply_subfolder,
26
27
  make_empty_response,
@@ -56,12 +57,19 @@ def init_tables_routes(shdata, authenticate_fn):
56
57
  keyword = request.args.get('keyword', '')
57
58
  user = request.args.get('user', DEFAULT_USER)
58
59
 
59
- tables = shdata.list_tables(keyword=keyword, user=user)
60
- if tables.empty:
60
+ def _produce():
61
+ tables = shdata.list_tables(keyword=keyword, user=user)
62
+ if tables.empty:
63
+ return b'' # sentinel -> 204
64
+ tables_recs = tables.reset_index().to_dict('records')
65
+ tables_recs = CollectionMongoDB.serialize(tables_recs, iso_dates=True)
66
+ return json.dumps(tables_recs).encode('utf-8')
67
+
68
+ # Single-flight + TTL cache: collapses concurrent/repeat full scans so
69
+ # they no longer pile up past the gateway timeout (see listcache.py).
70
+ response_data = LIST_CACHE.get(('tables', user, keyword), _produce)
71
+ if not response_data:
61
72
  return Response(status=204)
62
- tables = tables.reset_index().to_dict('records')
63
- tables = CollectionMongoDB.serialize(tables, iso_dates=True)
64
- response_data = json.dumps(tables).encode('utf-8')
65
73
  response = Response(response_data, mimetype='application/json')
66
74
  response.headers['Content-Length'] = len(response_data)
67
75
  return response
@@ -190,14 +198,36 @@ def init_tables_routes(shdata, authenticate_fn):
190
198
  loc = np.array(loc)
191
199
 
192
200
  # cycle query keys
193
- if query.keys() is not None:
194
- for key in query.keys():
195
- if pd.api.types.is_string_dtype(tbl[key]):
196
- idx = tbl[loc][key] == query[key].encode()
197
- elif pd.api.types.is_datetime64_any_dtype(tbl[key]):
198
- idx = tbl[loc][key] == pd.Timestamp(query[key])
199
- else:
200
- idx = tbl[loc][key] == query[key]
201
+ if query.keys() is not None:
202
+ for key in query.keys():
203
+ # Take the column view FIRST, then index it.
204
+ #
205
+ # `tbl[key]` is zero-copy (SharedNumpy.__getitem__ slices to
206
+ # count, and a field name on an ndarray is a view), whereas
207
+ # `tbl[loc]` is fancy indexing -- a copy of every selected row
208
+ # at the FULL record width, done once per query key and applied
209
+ # BEFORE the 20 MiB response cap, so it was unbounded.
210
+ #
211
+ # Measured on a 208-byte record: old peak was exactly
212
+ # 217.0 B/row at 100k/200k/400k/800k rows and FLAT across a
213
+ # 2000x selectivity sweep (it copies the whole table either
214
+ # way); new peak is ~1 B/row.
215
+ #
216
+ # `tbl[:]` (not `tbl[key]`) is deliberate: SharedNumpy.__getitem__
217
+ # re-reads tbl.count on every access, so slicing the whole table
218
+ # here observes the count at COMPARISON time, exactly as the old
219
+ # `tbl[loc]` did. Caching a column view from an earlier count
220
+ # would diverge if a concurrent writer shrank the table -- e.g.
221
+ # TableIndex.create_index() lowers hdr['count'] when it drops
222
+ # invalid/duplicate rows -- letting this succeed where the old
223
+ # code raised IndexError (and vice versa on growth).
224
+ col = tbl[:][key]
225
+ if pd.api.types.is_string_dtype(col):
226
+ idx = col[loc] == query[key].encode()
227
+ elif pd.api.types.is_datetime64_any_dtype(col):
228
+ idx = col[loc] == pd.Timestamp(query[key])
229
+ else:
230
+ idx = col[loc] == query[key]
201
231
  loc = loc[idx]
202
232
 
203
233
  if len(loc) == 0:
@@ -206,28 +236,31 @@ def init_tables_routes(shdata, authenticate_fn):
206
236
  # filter columns
207
237
  pkey = DATABASE_PKEYS[database]
208
238
  columns = request.args.get('columns')
209
- if columns:
210
- if not tbl.is_schemaless:
211
- columns = columns.split(',')
212
- columns = np.array([c for c in columns if c not in pkey])
213
- columns = pkey + list(np.unique(columns))
214
- names = columns
215
- formats = [tbl.dtype.fields[name][0].str for name in names]
216
- dtype = np.dtype(list(zip(names, formats)))
217
- # Apply pagination
218
- maxrows = int(np.floor(MAX_RESPONSE_SIZE_BYTES / dtype.itemsize))
219
- maxrows = min(maxrows, len(loc))
220
- if (per_page > maxrows) | (per_page == 0):
221
- per_page = maxrows
222
- startpage = (page - 1) * per_page
223
- endpage = startpage + per_page
224
- content_pages = int(np.ceil(len(loc) / per_page))
225
- recs2send = tbl[loc[startpage:endpage]]
226
- # Create new array
227
- arrays = [recs2send[field] for field in columns]
228
- recs2send = np.rec.fromarrays(arrays, dtype=dtype)
229
- else:
230
- pass
239
+ # NOTE: `columns` on a SCHEMALESS table falls through to the plain
240
+ # pagination branch below. It used to hit `else: pass`, which left
241
+ # startpage/endpage/content_pages/recs2send unbound -> UnboundLocalError
242
+ # -> 500 -> 1s in-handler sleep -> 3 immediate client retries. The
243
+ # projection for schemaless tables is applied to the decoded dict_list
244
+ # further down instead.
245
+ if columns and not tbl.is_schemaless:
246
+ columns = columns.split(',')
247
+ columns = np.array([c for c in columns if c not in pkey])
248
+ columns = pkey + list(np.unique(columns))
249
+ names = columns
250
+ formats = [tbl.dtype.fields[name][0].str for name in names]
251
+ dtype = np.dtype(list(zip(names, formats)))
252
+ # Apply pagination
253
+ maxrows = int(np.floor(MAX_RESPONSE_SIZE_BYTES / dtype.itemsize))
254
+ maxrows = min(maxrows, len(loc))
255
+ if (per_page > maxrows) | (per_page == 0):
256
+ per_page = maxrows
257
+ startpage = (page - 1) * per_page
258
+ endpage = startpage + per_page
259
+ content_pages = int(np.ceil(len(loc) / per_page))
260
+ recs2send = tbl[loc[startpage:endpage]]
261
+ # Create new array
262
+ arrays = [recs2send[field] for field in columns]
263
+ recs2send = np.rec.fromarrays(arrays, dtype=dtype)
231
264
  else:
232
265
  # Apply pagination
233
266
  maxrows = int(np.floor(MAX_RESPONSE_SIZE_BYTES / tbl.itemsize))
@@ -293,11 +326,13 @@ def init_tables_routes(shdata, authenticate_fn):
293
326
  if tbl.is_schemaless:
294
327
  dict_list = tbl.get_dict_list(loc[startpage:endpage])
295
328
  if columns:
329
+ # was built into `filtered_dict_list` and then dropped on
330
+ # the floor -- the UNfiltered list was sent to the client
296
331
  columns = columns.split(',')
297
- filtered_dict_list = []
298
- for record in dict_list:
299
- filtered_record = {col: record[col] for col in columns if col in record}
300
- filtered_dict_list.append(filtered_record)
332
+ dict_list = [
333
+ {col: record[col] for col in columns if col in record}
334
+ for record in dict_list
335
+ ]
301
336
 
302
337
  json_payload = {
303
338
  'names': names,
@@ -16,6 +16,7 @@ from SharedData.API.constants import (
16
16
  DEFAULT_USER,
17
17
  ERROR_SLEEP_SECONDS,
18
18
  )
19
+ from SharedData.API.listcache import LIST_CACHE
19
20
  from SharedData.API.utils import (
20
21
  apply_subfolder,
21
22
  make_empty_response,
@@ -45,22 +46,24 @@ def init_timeseries_routes(shdata, authenticate_fn):
45
46
  return jsonify({'error': 'unauthorized'}), 401
46
47
  keyword = request.args.get('keyword', '')
47
48
  user = request.args.get('user', DEFAULT_USER)
48
-
49
- timeseries_df = shdata.list_timeseries(keyword=keyword, user=user)
50
-
51
- if len(timeseries_df) == 0:
49
+
50
+ def _produce():
51
+ timeseries_df = shdata.list_timeseries(keyword=keyword, user=user)
52
+ if len(timeseries_df) == 0:
53
+ return b'' # sentinel -> 204
54
+ timeseries_list = timeseries_df.reset_index().to_dict('records')
55
+ for ts in timeseries_list:
56
+ for key, value in ts.items():
57
+ if pd.isna(value):
58
+ ts[key] = None
59
+ elif isinstance(value, pd.Timestamp):
60
+ ts[key] = value.isoformat()
61
+ return json.dumps(timeseries_list).encode('utf-8')
62
+
63
+ # Single-flight + TTL cache: see listcache.py.
64
+ response_data = LIST_CACHE.get(('timeseries', user, keyword), _produce)
65
+ if not response_data:
52
66
  return Response(status=204)
53
-
54
- timeseries_list = timeseries_df.reset_index().to_dict('records')
55
-
56
- for ts in timeseries_list:
57
- for key, value in ts.items():
58
- if pd.isna(value):
59
- ts[key] = None
60
- elif isinstance(value, pd.Timestamp):
61
- ts[key] = value.isoformat()
62
-
63
- response_data = json.dumps(timeseries_list).encode('utf-8')
64
67
  response = Response(response_data, mimetype='application/json')
65
68
  response.headers['Content-Length'] = len(response_data)
66
69
  return response
@@ -112,7 +112,7 @@ class CollectionMongoDB:
112
112
  # Check for indexes other than the default _id index
113
113
  self.hasindex = any(index_name == pkey_name for index_name in index_info)
114
114
  # If index was requested but the collection was previously created without one, create it now
115
- if hasindex and not self.hasindex:
115
+ if hasindex and not self.hasindex and self.period in ['D1', 'M15', 'M1']:
116
116
  self.mongodb.client[self.user][self.relpath].create_index(pkey_fields, unique=True, name=pkey_name)
117
117
  self.mongodb.client[self.user][self.relpath].create_index([("mtime", DESCENDING)])
118
118
  self.hasindex = True
@@ -0,0 +1,313 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+ import socket
4
+ from pathlib import Path
5
+
6
+ # Load persisted SharedData environment variables from user home directory
7
+ # These are set remotely via the 'setenv' WorkerPool API command
8
+ shareddata_env_file = Path.home() / '.shareddata.env'
9
+ if shareddata_env_file.is_file():
10
+ load_dotenv(shareddata_env_file, override=False)
11
+
12
+ try:
13
+ load_dotenv() # take environment variables from .env in CWD
14
+ except Exception:
15
+ # Continue gracefully if .env file is not found or cannot be loaded
16
+ pass
17
+
18
+ if not 'AWS_PROFILE' in os.environ:
19
+ if (not 'AWS_ACCESS_KEY_ID' in os.environ)\
20
+ or (not 'AWS_SECRET_ACCESS_KEY' in os.environ):
21
+
22
+ if not 'AWS_ACCESS_KEY_ID' in os.environ:
23
+ os.environ['AWS_ACCESS_KEY_ID'] = ''
24
+ if not 'AWS_SECRET_ACCESS_KEY' in os.environ:
25
+ os.environ['AWS_SECRET_ACCESS_KEY'] = ''
26
+ else:
27
+
28
+ if not 'S3_AWS_PROFILE' in os.environ:
29
+ os.environ['S3_AWS_PROFILE'] = os.environ['AWS_PROFILE']
30
+
31
+ if not 'DATABASE_FOLDER' in os.environ:
32
+ try:
33
+ os.environ['DATABASE_FOLDER'] = os.path.expanduser("~")+'/db'
34
+ except Exception:
35
+ # Fallback for Docker containers where home directory might not be accessible
36
+ os.environ['DATABASE_FOLDER'] = '/tmp/db'
37
+ Path(os.environ['DATABASE_FOLDER']).mkdir(parents=True, exist_ok=True)
38
+
39
+ if not 'SHAREDDATA_TOKEN' in os.environ:
40
+ os.environ['SHAREDDATA_TOKEN'] = 'shareddata-token'
41
+
42
+ if not 'SHAREDDATA_SECRET_KEY' in os.environ:
43
+ os.environ['SHAREDDATA_SECRET_KEY'] = 'shareddata-secret-key'
44
+
45
+ if not 'S3_BUCKET' in os.environ:
46
+ os.environ['S3_BUCKET'] = 's3://shareddata'
47
+
48
+ if not 'LOG_API' in os.environ:
49
+ os.environ['LOG_API'] = 'True'
50
+
51
+ if not 'LOG_FILE' in os.environ:
52
+ os.environ['LOG_FILE'] = 'False'
53
+
54
+ if not 'USERNAME' in os.environ:
55
+ if 'USER' in os.environ:
56
+ os.environ['USERNAME'] = os.environ['USER']
57
+ else:
58
+ os.environ['USERNAME'] = 'shareddata-user'
59
+
60
+ if not 'COMPUTERNAME' in os.environ:
61
+ os.environ['COMPUTERNAME'] = socket.gethostname().upper()
62
+
63
+ if not 'USER_COMPUTER' in os.environ:
64
+ os.environ['USER_COMPUTER'] = os.environ['USERNAME'] + \
65
+ '@'+os.environ['COMPUTERNAME']
66
+
67
+ if not 'LOG_LEVEL' in os.environ:
68
+ os.environ['LOG_LEVEL'] = 'INFO'
69
+
70
+ if not 'GIT_PROTOCOL' in os.environ:
71
+ os.environ['GIT_PROTOCOL'] = 'https'
72
+
73
+ if not 'GIT_SERVER' in os.environ:
74
+ os.environ['GIT_SERVER'] = 'github.com'
75
+
76
+ if not 'SOURCE_FOLDER' in os.environ:
77
+ if 'USERPROFILE' in os.environ:
78
+ os.environ['SOURCE_FOLDER'] = os.environ['USERPROFILE']+'/src/'
79
+ elif 'HOME' in os.environ:
80
+ os.environ['SOURCE_FOLDER'] = os.environ['HOME']+'/src/'
81
+ else:
82
+ # Fallback for Docker containers without HOME set
83
+ os.environ['SOURCE_FOLDER'] = '/tmp/src/'
84
+ Path(os.environ['SOURCE_FOLDER']).mkdir(parents=True, exist_ok=True)
85
+
86
+
87
+ if not 'SLEEP_TIME' in os.environ:
88
+ os.environ['SLEEP_TIME'] = '2'
89
+
90
+ if not 'KAFKA_RETENTION' in os.environ:
91
+ os.environ['KAFKA_RETENTION'] = '84600000' # 1 days
92
+
93
+ if not 'KAFKA_PARTITIONS' in os.environ:
94
+ os.environ['KAFKA_PARTITIONS'] = '1'
95
+
96
+ if not 'KAFKA_REPLICATION' in os.environ:
97
+ os.environ['KAFKA_REPLICATION'] = '1'
98
+
99
+ if not 'PYTHONUNBUFFERED' in os.environ:
100
+ os.environ['PYTHONUNBUFFERED'] = '1'
101
+
102
+ loaded = True
103
+
104
+
105
+ def folder_accessible(folder: str) -> bool:
106
+ """
107
+ Check whether a folder is actually usable for I/O.
108
+
109
+ ``Path.is_dir()`` returns ``True`` for unmounted mountpoints (the
110
+ mountpoint directory still exists on the root fs) — which would
111
+ silently route writes to the wrong disk. This function detects
112
+ unmounts by checking for a provisioning marker file
113
+ (``.shareddata_mounted``) that SharedData creates in each dbfolder
114
+ during ``__init__``. When a disk is unmounted, the marker
115
+ (which lives on the disk) disappears while the empty mountpoint
116
+ directory remains on the root fs — so the absence of the marker
117
+ reliably signals an unmount.
118
+
119
+ Returns ``True`` only if the folder exists, is a directory, carries
120
+ the provisioning marker, and allows creating a temporary file
121
+ (which is immediately cleaned up).
122
+ """
123
+ try:
124
+ if not folder:
125
+ return False
126
+ import uuid
127
+ p = Path(folder)
128
+ if not p.is_dir():
129
+ return False
130
+ # Marker file — created at provisioning time (SharedData.__init__).
131
+ # Absence means the disk is unmounted (marker lives on the disk,
132
+ # not the root fs) or the folder was never provisioned.
133
+ if not (p / '.shareddata_mounted').exists():
134
+ return False
135
+ # Writability probe with unique sentinel to avoid races.
136
+ sentinel = p / f'.shareddata_access_check.{os.getpid()}.{uuid.uuid4().hex}'
137
+ with open(sentinel, 'wb') as f:
138
+ f.write(b'')
139
+ sentinel.unlink(missing_ok=True)
140
+ return True
141
+ except (OSError, TypeError):
142
+ return False
143
+
144
+
145
+ def _current_mount_points():
146
+ """Set of currently-mounted mount points from ``/proc/self/mountinfo``.
147
+
148
+ Returns ``None`` when the file is unavailable (e.g. non-Linux) so callers
149
+ can fall back to the legacy ``os.path.ismount()`` heuristic.
150
+ """
151
+ try:
152
+ mounts = set()
153
+ with open('/proc/self/mountinfo', 'r') as f:
154
+ for line in f:
155
+ fields = line.split(' ')
156
+ # mountinfo field 5 (index 4) is the mount point.
157
+ if len(fields) >= 5:
158
+ mounts.add(_unescape_mountinfo(fields[4]))
159
+ return mounts or None
160
+ except (OSError, ValueError):
161
+ return None
162
+
163
+
164
+ def _unescape_mountinfo(s: str) -> str:
165
+ # mountinfo octal-escapes space/tab/newline/backslash in path fields.
166
+ return (s.replace('\\040', ' ').replace('\\011', '\t')
167
+ .replace('\\012', '\n').replace('\\134', '\\'))
168
+
169
+
170
+ def _parse_fstab_targets(lines):
171
+ """Pure parser: set of absolute mount targets from fstab *lines*.
172
+
173
+ fstab octal-escapes whitespace in the target field exactly like
174
+ mountinfo, so decode it the same way — otherwise a declared target like
175
+ ``/mnt/db\\040disk`` would never match the unescaped path from
176
+ ``Path.resolve()`` / mountinfo and the unmounted-stub check would miss it.
177
+ """
178
+ targets = set()
179
+ for line in lines:
180
+ line = line.strip()
181
+ if not line or line.startswith('#'):
182
+ continue
183
+ cols = line.split()
184
+ if len(cols) >= 2 and cols[1].startswith('/'):
185
+ targets.add(_unescape_mountinfo(cols[1]))
186
+ return targets
187
+
188
+
189
+ def _declared_mount_points():
190
+ """Set of declared mount targets from ``/etc/fstab`` (absolute paths only).
191
+
192
+ fstab lists mount points that *should* carry a device even when the device
193
+ is currently unmounted — exactly what distinguishes an unmounted stub from
194
+ a genuine root-fs folder.
195
+ """
196
+ try:
197
+ with open('/etc/fstab', 'r') as f:
198
+ return _parse_fstab_targets(f)
199
+ except OSError:
200
+ return set()
201
+
202
+
203
+ def _classify_on_mounted_device(resolved_path, current_mounts, declared_mounts):
204
+ """Pure decision (no I/O): is *resolved_path* backed by a live device?
205
+
206
+ Walks the path's ancestors from itself up to ``/``:
207
+ - an ancestor that is a DECLARED mount target but is NOT currently mounted
208
+ is an unmounted stub — the path lives on the root fs only by accident —
209
+ so return ``False``;
210
+ - the first ancestor that IS a current mount point (``/`` always is) means
211
+ the path is backed by a live filesystem — return ``True``.
212
+ """
213
+ from pathlib import PurePosixPath
214
+ p = PurePosixPath(resolved_path)
215
+ ancestors = [str(p)] + [str(a) for a in p.parents]
216
+ for a in ancestors:
217
+ if a in declared_mounts and a not in current_mounts:
218
+ return False
219
+ if a in current_mounts:
220
+ return True
221
+ return True
222
+
223
+
224
+ def _is_on_mounted_device_legacy(folder: str) -> bool:
225
+ """Original ``os.path.ismount()`` st_dev heuristic — fallback for platforms
226
+ without ``/proc/self/mountinfo``. Cannot detect an unmounted mountpoint
227
+ stub (``ismount()`` returns False for it); the mountinfo path is preferred
228
+ on Linux."""
229
+ try:
230
+ p = Path(folder).resolve()
231
+ current = p
232
+ while True:
233
+ if os.path.ismount(current):
234
+ try:
235
+ dev = current.stat().st_dev
236
+ parent_dev = current.parent.stat().st_dev
237
+ if dev != parent_dev:
238
+ return True # device is mounted
239
+ else:
240
+ # Mountpoint stub — device not mounted.
241
+ # But if this is /, it's the root fs (always live).
242
+ if current == Path('/'):
243
+ return True
244
+ return False
245
+ except OSError:
246
+ return False
247
+ if current == current.parent:
248
+ # Reached / without finding a mountpoint — root fs.
249
+ return True
250
+ current = current.parent
251
+ except (OSError, TypeError):
252
+ return False
253
+
254
+
255
+ def _is_on_mounted_device(folder: str) -> bool:
256
+ """
257
+ Return ``True`` if *folder* is on a currently-live device.
258
+
259
+ Combines ``/proc/self/mountinfo`` (currently-mounted points) with
260
+ ``/etc/fstab`` (declared mount targets) so an *unmounted* mountpoint stub
261
+ is correctly rejected. ``os.path.ismount()`` returns False for such a
262
+ stub, so the previous st_dev walk fell through to ``/`` and wrongly
263
+ reported it live — letting ``ensure_folder_provisioned()`` drop the
264
+ ``.shareddata_mounted`` marker on the root fs and defeating unmount
265
+ detection (writes silently landing on ``/`` instead of the intended disk).
266
+
267
+ Falls back to the legacy ``os.path.ismount()`` heuristic when mountinfo is
268
+ unavailable (non-Linux).
269
+ """
270
+ try:
271
+ if not folder:
272
+ return False
273
+ resolved = str(Path(folder).resolve())
274
+ current = _current_mount_points()
275
+ if current is None:
276
+ return _is_on_mounted_device_legacy(folder)
277
+ declared = _declared_mount_points()
278
+ return _classify_on_mounted_device(resolved, current, declared)
279
+ except (OSError, TypeError):
280
+ return False
281
+
282
+ def ensure_folder_provisioned(folder: str) -> bool:
283
+ """
284
+ Create the provisioning marker in *folder* so that future calls to
285
+ ``folder_accessible()`` can distinguish a mounted disk from an
286
+ unmounted mountpoint directory.
287
+
288
+ Refuses to create the marker if the folder is on an unmounted
289
+ mountpoint (``_is_on_mounted_device`` returns ``False``) — this
290
+ prevents provisioning over a bare mountpoint directory on the
291
+ root filesystem, which would defeat the unmount detection.
292
+
293
+ Called from ``SharedData.__init__`` after ``os.makedirs`` for each
294
+ dbfolder. Returns ``True`` on success, ``False`` if the folder
295
+ cannot be written to or is on an unmounted device.
296
+ """
297
+ try:
298
+ if not folder:
299
+ return False
300
+ p = Path(folder)
301
+ if not p.is_dir():
302
+ return False
303
+ # Refuse to provision over an unmounted mountpoint — the marker
304
+ # would end up on root fs and defeat folder_accessible().
305
+ if not _is_on_mounted_device(folder):
306
+ return False
307
+ marker = p / '.shareddata_mounted'
308
+ if not marker.exists():
309
+ marker.touch()
310
+ return True
311
+ except (OSError, TypeError):
312
+ return False
313
+