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.
- {shareddata-6.83.8/src/shareddata.egg-info → shareddata-6.83.16}/PKG-INFO +1 -1
- {shareddata-6.83.8 → shareddata-6.83.16}/setup.py +1 -1
- shareddata-6.83.16/src/SharedData/API/listcache.py +91 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/collections.py +14 -6
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/tables.py +74 -39
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/timeseries.py +18 -15
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/CollectionMongoDB.py +1 -1
- shareddata-6.83.16/src/SharedData/Defaults.py +313 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Logger.py +58 -26
- shareddata-6.83.16/src/SharedData/Routines/ArchiveOldCollections.py +254 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Schedule.py +3 -3
- shareddata-6.83.16/src/SharedData/Routines/UpdateActiveJobs.py +53 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Worker.py +8 -7
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/WorkerPool.py +190 -159
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/SharedData.py +141 -34
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/SharedNumpy.py +6 -6
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Table.py +223 -62
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableDisk.py +39 -2
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndex.py +70 -29
- {shareddata-6.83.8 → shareddata-6.83.16/src/shareddata.egg-info}/PKG-INFO +1 -1
- {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/SOURCES.txt +11 -1
- shareddata-6.83.16/tests/test_api_table_production_flows.py +414 -0
- shareddata-6.83.16/tests/test_listcache.py +94 -0
- shareddata-6.83.16/tests/test_mount_unmount_detection.py +93 -0
- shareddata-6.83.16/tests/test_schema_acquire_timeout.py +47 -0
- shareddata-6.83.16/tests/test_table_auto_grow_all_pkeys.py +98 -0
- shareddata-6.83.16/tests/test_workerpool_mutex_release.py +55 -0
- shareddata-6.83.16/tests/test_workerpool_post_commands.py +141 -0
- shareddata-6.83.8/src/SharedData/Defaults.py +0 -102
- {shareddata-6.83.8 → shareddata-6.83.16}/LICENSE +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/MANIFEST.in +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/README.md +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/pyproject.toml +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/setup.cfg +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/ServerGunicorn.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/ServerWaitress.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/__init__.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/auth.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/constants.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/__init__.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/cache.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/metadata.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/system.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/routes/workers.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/API/utils.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/CacheRedis.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Database.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AWSEC2.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AWSS3.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/AutoDocstrings.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientAPI.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientSocket.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ClientWebSocket.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/LogHandlerAPI.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/MongoDBClient.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/SaveTables.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ServerSocket.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/ServerWebSocket.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/StreamsCache.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/StreamsPersist.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/SyncTable.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/TunnelWebSocket.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/IO/__init__.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Metadata.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/MultiProc.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/OpenFIGI.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/BatchJob.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/Scheduler.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/WorkerLib.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Routines/__init__.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/StreamKafka.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Symbol.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitFunctions.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitGenerate.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitHash.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TableIndexJitLoc.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TimeSeriesDisk.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/TimeseriesContainer.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Users.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/Utils.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/__init__.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/SharedData/sharedmutexwin.pyd +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/dependency_links.txt +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/requires.txt +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/src/shareddata.egg-info/top_level.txt +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_collection.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_collection_loopback.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table_schemaless.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_api_table_schemaless_extend.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_bson_last_pos_reuse.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_cache_redis.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_extend_rt.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_loc.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_metadata.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_read_write_tail.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_stream_loopback_async.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_timeseries.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_timeseries_api.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_d1.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_m1.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_get_date_loc_m15.py +0 -0
- {shareddata-6.83.8 → shareddata-6.83.16}/tests/test_upsert_unordered_positions_m1.py +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.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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
|