shareddata 6.83.12__tar.gz → 6.83.17__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 (107) hide show
  1. {shareddata-6.83.12 → shareddata-6.83.17}/MANIFEST.in +0 -1
  2. {shareddata-6.83.12/src/shareddata.egg-info → shareddata-6.83.17}/PKG-INFO +3 -2
  3. {shareddata-6.83.12 → shareddata-6.83.17}/setup.py +2 -2
  4. shareddata-6.83.17/src/SharedData/API/listcache.py +91 -0
  5. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/collections.py +14 -6
  6. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/tables.py +74 -39
  7. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/timeseries.py +18 -15
  8. shareddata-6.83.17/src/SharedData/Defaults.py +313 -0
  9. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/MongoDBClient.py +2 -2
  10. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Logger.py +11 -7
  11. shareddata-6.83.17/src/SharedData/Routines/ArchiveOldCollections.py +254 -0
  12. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/Schedule.py +3 -3
  13. shareddata-6.83.17/src/SharedData/Routines/UpdateActiveJobs.py +75 -0
  14. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/Worker.py +11 -10
  15. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/WorkerPool.py +164 -147
  16. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/SharedData.py +54 -9
  17. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/SharedNumpy.py +6 -6
  18. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Table.py +138 -43
  19. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndex.py +32 -28
  20. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/sharedmutexwin.pyd +0 -0
  21. {shareddata-6.83.12 → shareddata-6.83.17/src/shareddata.egg-info}/PKG-INFO +3 -2
  22. {shareddata-6.83.12 → shareddata-6.83.17}/src/shareddata.egg-info/SOURCES.txt +13 -1
  23. shareddata-6.83.17/tests/test_api_table_production_flows.py +414 -0
  24. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_bson_last_pos_reuse.py +5 -5
  25. shareddata-6.83.17/tests/test_listcache.py +94 -0
  26. shareddata-6.83.17/tests/test_logger_thread_safety.py +47 -0
  27. shareddata-6.83.17/tests/test_mount_unmount_detection.py +93 -0
  28. shareddata-6.83.17/tests/test_schema_acquire_timeout.py +47 -0
  29. shareddata-6.83.17/tests/test_table_auto_grow_all_pkeys.py +98 -0
  30. shareddata-6.83.17/tests/test_workerpool_jobs_status.py +283 -0
  31. shareddata-6.83.17/tests/test_workerpool_mutex_release.py +55 -0
  32. shareddata-6.83.17/tests/test_workerpool_post_commands.py +146 -0
  33. shareddata-6.83.12/src/SharedData/Defaults.py +0 -102
  34. {shareddata-6.83.12 → shareddata-6.83.17}/LICENSE +0 -0
  35. {shareddata-6.83.12 → shareddata-6.83.17}/README.md +0 -0
  36. {shareddata-6.83.12 → shareddata-6.83.17}/pyproject.toml +0 -0
  37. {shareddata-6.83.12 → shareddata-6.83.17}/setup.cfg +0 -0
  38. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/ServerGunicorn.py +0 -0
  39. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/ServerWaitress.py +0 -0
  40. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/__init__.py +0 -0
  41. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/auth.py +0 -0
  42. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/constants.py +0 -0
  43. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/__init__.py +0 -0
  44. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/cache.py +0 -0
  45. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/metadata.py +0 -0
  46. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/system.py +0 -0
  47. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/routes/workers.py +0 -0
  48. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/API/utils.py +0 -0
  49. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/CacheRedis.py +0 -0
  50. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/CollectionMongoDB.py +0 -0
  51. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Database.py +0 -0
  52. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/AWSEC2.py +0 -0
  53. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/AWSS3.py +0 -0
  54. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/AutoDocstrings.py +0 -0
  55. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/ClientAPI.py +0 -0
  56. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/ClientSocket.py +0 -0
  57. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/ClientWebSocket.py +0 -0
  58. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  59. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/SaveTables.py +0 -0
  60. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/ServerSocket.py +0 -0
  61. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/ServerWebSocket.py +0 -0
  62. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/StreamsCache.py +0 -0
  63. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/StreamsPersist.py +0 -0
  64. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/SyncTable.py +0 -0
  65. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  66. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/IO/__init__.py +0 -0
  67. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Metadata.py +0 -0
  68. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/MultiProc.py +0 -0
  69. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/OpenFIGI.py +0 -0
  70. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/BatchJob.py +0 -0
  71. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  72. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/Scheduler.py +0 -0
  73. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/WorkerLib.py +0 -0
  74. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Routines/__init__.py +0 -0
  75. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/StreamKafka.py +0 -0
  76. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Symbol.py +0 -0
  77. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableDisk.py +0 -0
  78. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndexJitFunctions.py +0 -0
  79. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  80. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndexJitGenerate.py +0 -0
  81. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndexJitHash.py +0 -0
  82. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TableIndexJitLoc.py +0 -0
  83. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TimeSeriesDisk.py +0 -0
  84. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/TimeseriesContainer.py +0 -0
  85. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Users.py +0 -0
  86. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/Utils.py +0 -0
  87. {shareddata-6.83.12 → shareddata-6.83.17}/src/SharedData/__init__.py +0 -0
  88. {shareddata-6.83.12 → shareddata-6.83.17}/src/shareddata.egg-info/dependency_links.txt +0 -0
  89. {shareddata-6.83.12 → shareddata-6.83.17}/src/shareddata.egg-info/requires.txt +0 -0
  90. {shareddata-6.83.12 → shareddata-6.83.17}/src/shareddata.egg-info/top_level.txt +0 -0
  91. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_api_collection.py +0 -0
  92. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_api_collection_loopback.py +0 -0
  93. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_api_table.py +0 -0
  94. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_api_table_schemaless.py +0 -0
  95. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_api_table_schemaless_extend.py +0 -0
  96. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_cache_redis.py +0 -0
  97. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_extend_rt.py +0 -0
  98. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_loc.py +0 -0
  99. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_metadata.py +0 -0
  100. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_read_write_tail.py +0 -0
  101. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_stream_loopback_async.py +0 -0
  102. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_timeseries.py +0 -0
  103. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_timeseries_api.py +0 -0
  104. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_upsert_unordered_get_date_loc_d1.py +0 -0
  105. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_upsert_unordered_get_date_loc_m1.py +0 -0
  106. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_upsert_unordered_get_date_loc_m15.py +0 -0
  107. {shareddata-6.83.12 → shareddata-6.83.17}/tests/test_upsert_unordered_positions_m1.py +0 -0
@@ -1,2 +1 @@
1
1
  include src/SharedData/sharedmutexwin.pyd
2
- include src/SharedData/IO/apidocs.yml
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.83.12
3
+ Version: 6.83.17
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
+ License: MIT
5
6
  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,6 +39,7 @@ Requires-Dist: redis==6.2.0
39
39
  Dynamic: classifier
40
40
  Dynamic: description
41
41
  Dynamic: description-content-type
42
+ Dynamic: license
42
43
  Dynamic: license-file
43
44
  Dynamic: requires-dist
44
45
  Dynamic: requires-python
@@ -34,13 +34,13 @@ install_requires = [
34
34
 
35
35
  setup(
36
36
  name='shareddata',
37
- version='6.83.12',
37
+ version='6.83.17',
38
38
  description='Memory Mapped / Shared Memory Database with S3 repository',
39
+ license='MIT',
39
40
  long_description=open('README.md').read(),
40
41
  long_description_content_type='text/markdown',
41
42
  classifiers=[
42
43
  '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'),
@@ -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