pixeltable 0.4.6__py3-none-any.whl → 0.4.8__py3-none-any.whl
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.
Potentially problematic release.
This version of pixeltable might be problematic. Click here for more details.
- pixeltable/__init__.py +4 -2
- pixeltable/catalog/__init__.py +1 -1
- pixeltable/catalog/catalog.py +7 -9
- pixeltable/catalog/column.py +49 -0
- pixeltable/catalog/insertable_table.py +0 -7
- pixeltable/catalog/schema_object.py +1 -14
- pixeltable/catalog/table.py +180 -67
- pixeltable/catalog/table_version.py +42 -146
- pixeltable/catalog/table_version_path.py +6 -5
- pixeltable/catalog/view.py +2 -1
- pixeltable/config.py +24 -9
- pixeltable/dataframe.py +5 -6
- pixeltable/env.py +113 -21
- pixeltable/exec/aggregation_node.py +1 -1
- pixeltable/exec/cache_prefetch_node.py +4 -3
- pixeltable/exec/exec_node.py +0 -8
- pixeltable/exec/expr_eval/expr_eval_node.py +2 -2
- pixeltable/exec/expr_eval/globals.py +1 -0
- pixeltable/exec/expr_eval/schedulers.py +52 -19
- pixeltable/exec/in_memory_data_node.py +2 -3
- pixeltable/exprs/array_slice.py +2 -2
- pixeltable/exprs/data_row.py +15 -2
- pixeltable/exprs/expr.py +9 -9
- pixeltable/exprs/function_call.py +61 -23
- pixeltable/exprs/globals.py +1 -2
- pixeltable/exprs/json_path.py +3 -3
- pixeltable/exprs/row_builder.py +25 -21
- pixeltable/exprs/string_op.py +3 -3
- pixeltable/func/expr_template_function.py +6 -3
- pixeltable/func/query_template_function.py +2 -2
- pixeltable/func/signature.py +30 -3
- pixeltable/func/tools.py +2 -2
- pixeltable/functions/anthropic.py +76 -27
- pixeltable/functions/deepseek.py +5 -1
- pixeltable/functions/gemini.py +11 -2
- pixeltable/functions/globals.py +2 -2
- pixeltable/functions/huggingface.py +6 -12
- pixeltable/functions/llama_cpp.py +9 -1
- pixeltable/functions/openai.py +76 -55
- pixeltable/functions/video.py +59 -6
- pixeltable/functions/vision.py +2 -2
- pixeltable/globals.py +86 -13
- pixeltable/io/datarows.py +3 -3
- pixeltable/io/fiftyone.py +7 -7
- pixeltable/io/globals.py +3 -3
- pixeltable/io/hf_datasets.py +4 -4
- pixeltable/io/label_studio.py +2 -1
- pixeltable/io/pandas.py +6 -6
- pixeltable/io/parquet.py +3 -3
- pixeltable/io/table_data_conduit.py +2 -2
- pixeltable/io/utils.py +2 -2
- pixeltable/iterators/audio.py +3 -2
- pixeltable/iterators/document.py +2 -8
- pixeltable/iterators/video.py +49 -9
- pixeltable/plan.py +0 -16
- pixeltable/share/packager.py +51 -42
- pixeltable/share/publish.py +134 -7
- pixeltable/store.py +5 -25
- pixeltable/type_system.py +5 -8
- pixeltable/utils/__init__.py +2 -2
- pixeltable/utils/arrow.py +5 -5
- pixeltable/utils/description_helper.py +3 -3
- pixeltable/utils/iceberg.py +1 -2
- pixeltable/utils/media_store.py +131 -66
- {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/METADATA +238 -122
- {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/RECORD +69 -69
- {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/WHEEL +0 -0
- {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/entry_points.txt +0 -0
- {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/licenses/LICENSE +0 -0
pixeltable/env.py
CHANGED
|
@@ -15,9 +15,7 @@ import sys
|
|
|
15
15
|
import threading
|
|
16
16
|
import types
|
|
17
17
|
import typing
|
|
18
|
-
import uuid
|
|
19
18
|
import warnings
|
|
20
|
-
from abc import abstractmethod
|
|
21
19
|
from contextlib import contextmanager
|
|
22
20
|
from dataclasses import dataclass, field
|
|
23
21
|
from pathlib import Path
|
|
@@ -102,6 +100,8 @@ class Env:
|
|
|
102
100
|
def _init_env(cls, reinit_db: bool = False) -> None:
|
|
103
101
|
assert not cls.__initializing, 'Circular env initialization detected.'
|
|
104
102
|
cls.__initializing = True
|
|
103
|
+
if cls._instance is not None:
|
|
104
|
+
cls._instance._clean_up()
|
|
105
105
|
cls._instance = None
|
|
106
106
|
env = Env()
|
|
107
107
|
env._set_up(reinit_db=reinit_db)
|
|
@@ -247,7 +247,7 @@ class Env:
|
|
|
247
247
|
if self._current_conn is None:
|
|
248
248
|
assert self._current_session is None
|
|
249
249
|
try:
|
|
250
|
-
self._current_isolation_level = 'SERIALIZABLE'
|
|
250
|
+
self._current_isolation_level = 'SERIALIZABLE'
|
|
251
251
|
with (
|
|
252
252
|
self.engine.connect().execution_options(isolation_level=self._current_isolation_level) as conn,
|
|
253
253
|
sql.orm.Session(conn) as session,
|
|
@@ -486,7 +486,7 @@ class Env:
|
|
|
486
486
|
raise excs.Error(error)
|
|
487
487
|
self._logger.info(f'Using database at: {self.db_url}')
|
|
488
488
|
else:
|
|
489
|
-
self._db_name =
|
|
489
|
+
self._db_name = config.get_string_value('db') or 'pixeltable'
|
|
490
490
|
self._pgdata_dir = Path(os.environ.get('PIXELTABLE_PGDATA', str(Config.get().home / 'pgdata')))
|
|
491
491
|
# cleanup_mode=None will leave the postgres process running after Python exits
|
|
492
492
|
# cleanup_mode='stop' will terminate the postgres process when Python exits
|
|
@@ -558,6 +558,14 @@ class Env:
|
|
|
558
558
|
finally:
|
|
559
559
|
engine.dispose()
|
|
560
560
|
|
|
561
|
+
def _pgserver_terminate_connections_stmt(self) -> str:
|
|
562
|
+
return f"""
|
|
563
|
+
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
|
564
|
+
FROM pg_stat_activity
|
|
565
|
+
WHERE pg_stat_activity.datname = '{self._db_name}'
|
|
566
|
+
AND pid <> pg_backend_pid()
|
|
567
|
+
"""
|
|
568
|
+
|
|
561
569
|
def _drop_store_db(self) -> None:
|
|
562
570
|
assert self._db_name is not None
|
|
563
571
|
engine = sql.create_engine(self._dbms.default_system_db_url(), future=True, isolation_level='AUTOCOMMIT')
|
|
@@ -566,13 +574,7 @@ class Env:
|
|
|
566
574
|
with engine.begin() as conn:
|
|
567
575
|
# terminate active connections
|
|
568
576
|
if self._db_server is not None:
|
|
569
|
-
|
|
570
|
-
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
|
571
|
-
FROM pg_stat_activity
|
|
572
|
-
WHERE pg_stat_activity.datname = '{self._db_name}'
|
|
573
|
-
AND pid <> pg_backend_pid()
|
|
574
|
-
"""
|
|
575
|
-
conn.execute(sql.text(stmt))
|
|
577
|
+
conn.execute(sql.text(self._pgserver_terminate_connections_stmt()))
|
|
576
578
|
# drop db
|
|
577
579
|
stmt = self._dbms.drop_db_stmt(preparer.quote(self._db_name))
|
|
578
580
|
conn.execute(sql.text(stmt))
|
|
@@ -750,12 +752,6 @@ class Env:
|
|
|
750
752
|
else:
|
|
751
753
|
os.remove(path)
|
|
752
754
|
|
|
753
|
-
def num_tmp_files(self) -> int:
|
|
754
|
-
return len(glob.glob(f'{self._tmp_dir}/*'))
|
|
755
|
-
|
|
756
|
-
def create_tmp_path(self, extension: str = '') -> Path:
|
|
757
|
-
return self._tmp_dir / f'{uuid.uuid4()}{extension}'
|
|
758
|
-
|
|
759
755
|
# def get_resource_pool_info(self, pool_id: str, pool_info_cls: Optional[Type[T]]) -> T:
|
|
760
756
|
def get_resource_pool_info(self, pool_id: str, make_pool_info: Optional[Callable[[], T]] = None) -> T:
|
|
761
757
|
"""Returns the info object for the given id, creating it if necessary."""
|
|
@@ -816,6 +812,63 @@ class Env:
|
|
|
816
812
|
except Exception as exc:
|
|
817
813
|
raise excs.Error(f'Failed to load spaCy model: {spacy_model}') from exc
|
|
818
814
|
|
|
815
|
+
def _clean_up(self) -> None:
|
|
816
|
+
"""
|
|
817
|
+
Internal cleanup method that properly closes all resources and resets state.
|
|
818
|
+
This is called before destroying the singleton instance.
|
|
819
|
+
"""
|
|
820
|
+
assert self._current_session is None
|
|
821
|
+
assert self._current_conn is None
|
|
822
|
+
|
|
823
|
+
# Stop HTTP server
|
|
824
|
+
if self._httpd is not None:
|
|
825
|
+
try:
|
|
826
|
+
self._httpd.shutdown()
|
|
827
|
+
self._httpd.server_close()
|
|
828
|
+
except Exception as e:
|
|
829
|
+
_logger.warning(f'Error stopping HTTP server: {e}')
|
|
830
|
+
|
|
831
|
+
# First terminate all connections to the database
|
|
832
|
+
if self._db_server is not None:
|
|
833
|
+
assert self._dbms is not None
|
|
834
|
+
assert self._db_name is not None
|
|
835
|
+
try:
|
|
836
|
+
temp_engine = sql.create_engine(self._dbms.default_system_db_url(), isolation_level='AUTOCOMMIT')
|
|
837
|
+
try:
|
|
838
|
+
with temp_engine.begin() as conn:
|
|
839
|
+
conn.execute(sql.text(self._pgserver_terminate_connections_stmt()))
|
|
840
|
+
_logger.info(f"Terminated all connections to database '{self._db_name}'")
|
|
841
|
+
except Exception as e:
|
|
842
|
+
_logger.warning(f'Error terminating database connections: {e}')
|
|
843
|
+
finally:
|
|
844
|
+
temp_engine.dispose()
|
|
845
|
+
except Exception as e:
|
|
846
|
+
_logger.warning(f'Error stopping database server: {e}')
|
|
847
|
+
|
|
848
|
+
# Dispose of SQLAlchemy engine (after stopping db server)
|
|
849
|
+
if self._sa_engine is not None:
|
|
850
|
+
try:
|
|
851
|
+
self._sa_engine.dispose()
|
|
852
|
+
except Exception as e:
|
|
853
|
+
_logger.warning(f'Error disposing engine: {e}')
|
|
854
|
+
|
|
855
|
+
# Close event loop
|
|
856
|
+
if self._event_loop is not None:
|
|
857
|
+
try:
|
|
858
|
+
if self._event_loop.is_running():
|
|
859
|
+
self._event_loop.stop()
|
|
860
|
+
self._event_loop.close()
|
|
861
|
+
except Exception as e:
|
|
862
|
+
_logger.warning(f'Error closing event loop: {e}')
|
|
863
|
+
|
|
864
|
+
# Remove logging handlers
|
|
865
|
+
for handler in self._logger.handlers[:]:
|
|
866
|
+
try:
|
|
867
|
+
handler.close()
|
|
868
|
+
self._logger.removeHandler(handler)
|
|
869
|
+
except Exception as e:
|
|
870
|
+
_logger.warning(f'Error removing handler: {e}')
|
|
871
|
+
|
|
819
872
|
|
|
820
873
|
def register_client(name: str) -> Callable:
|
|
821
874
|
"""Decorator that registers a third-party API client for use by Pixeltable.
|
|
@@ -890,6 +943,10 @@ class RateLimitsInfo:
|
|
|
890
943
|
get_request_resources: Callable[..., dict[str, int]]
|
|
891
944
|
|
|
892
945
|
resource_limits: dict[str, RateLimitInfo] = field(default_factory=dict)
|
|
946
|
+
has_exc: bool = False
|
|
947
|
+
|
|
948
|
+
def debug_str(self) -> str:
|
|
949
|
+
return ','.join(info.debug_str() for info in self.resource_limits.values())
|
|
893
950
|
|
|
894
951
|
def is_initialized(self) -> bool:
|
|
895
952
|
return len(self.resource_limits) > 0
|
|
@@ -897,7 +954,7 @@ class RateLimitsInfo:
|
|
|
897
954
|
def reset(self) -> None:
|
|
898
955
|
self.resource_limits.clear()
|
|
899
956
|
|
|
900
|
-
def record(self, **kwargs: Any) -> None:
|
|
957
|
+
def record(self, reset_exc: bool = False, **kwargs: Any) -> None:
|
|
901
958
|
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
902
959
|
if len(self.resource_limits) == 0:
|
|
903
960
|
self.resource_limits = {k: RateLimitInfo(k, now, *v) for k, v in kwargs.items() if v is not None}
|
|
@@ -908,14 +965,30 @@ class RateLimitsInfo:
|
|
|
908
965
|
f'reset={info.reset_at.strftime(TIME_FORMAT)} delta={(info.reset_at - now).total_seconds()}'
|
|
909
966
|
)
|
|
910
967
|
else:
|
|
968
|
+
if self.has_exc and not reset_exc:
|
|
969
|
+
# ignore updates until we're asked to reset
|
|
970
|
+
_logger.debug(f'rate_limits.record(): ignoring update {kwargs}')
|
|
971
|
+
return
|
|
972
|
+
self.has_exc = False
|
|
911
973
|
for k, v in kwargs.items():
|
|
912
974
|
if v is not None:
|
|
913
975
|
self.resource_limits[k].update(now, *v)
|
|
914
976
|
|
|
915
|
-
|
|
977
|
+
def record_exc(self, exc: Exception) -> None:
|
|
978
|
+
"""Update self.resource_limits based on the exception headers"""
|
|
979
|
+
self.has_exc = True
|
|
980
|
+
|
|
916
981
|
def get_retry_delay(self, exc: Exception) -> Optional[float]:
|
|
917
982
|
"""Returns number of seconds to wait before retry, or None if not retryable"""
|
|
918
|
-
|
|
983
|
+
if len(self.resource_limits) == 0:
|
|
984
|
+
return 1.0
|
|
985
|
+
# we're looking for the maximum delay across all depleted resources
|
|
986
|
+
max_delay = 0.0
|
|
987
|
+
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
988
|
+
for limit_info in self.resource_limits.values():
|
|
989
|
+
if limit_info.remaining < 0.05 * limit_info.limit:
|
|
990
|
+
max_delay = max(max_delay, (limit_info.reset_at - now).total_seconds())
|
|
991
|
+
return max_delay if max_delay > 0 else None
|
|
919
992
|
|
|
920
993
|
|
|
921
994
|
@dataclass
|
|
@@ -928,9 +1001,15 @@ class RateLimitInfo:
|
|
|
928
1001
|
remaining: int
|
|
929
1002
|
reset_at: datetime.datetime
|
|
930
1003
|
|
|
1004
|
+
def debug_str(self) -> str:
|
|
1005
|
+
return (
|
|
1006
|
+
f'{self.resource}@{self.recorded_at.strftime(TIME_FORMAT)}: '
|
|
1007
|
+
f'{self.limit}/{self.remaining}/{self.reset_at.strftime(TIME_FORMAT)}'
|
|
1008
|
+
)
|
|
1009
|
+
|
|
931
1010
|
def update(self, recorded_at: datetime.datetime, limit: int, remaining: int, reset_at: datetime.datetime) -> None:
|
|
932
1011
|
# we always update everything, even though responses may come back out-of-order: we can't use reset_at to
|
|
933
|
-
# determine order, because it doesn't increase monotonically (the
|
|
1012
|
+
# determine order, because it doesn't increase monotonically (the reset duration shortens as output_tokens
|
|
934
1013
|
# are freed up - going from max to actual)
|
|
935
1014
|
self.recorded_at = recorded_at
|
|
936
1015
|
self.limit = limit
|
|
@@ -942,3 +1021,16 @@ class RateLimitInfo:
|
|
|
942
1021
|
f'Update {self.resource} rate limit: rem={self.remaining} reset={self.reset_at.strftime(TIME_FORMAT)} '
|
|
943
1022
|
f'reset_delta={reset_delta.total_seconds()} recorded_delta={(self.reset_at - recorded_at).total_seconds()}'
|
|
944
1023
|
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
@dataclass
|
|
1027
|
+
class RuntimeCtx:
|
|
1028
|
+
"""
|
|
1029
|
+
Container for runtime data provided by the execution system to udfs.
|
|
1030
|
+
|
|
1031
|
+
Udfs that accept the special _runtime_ctx parameter receive an instance of this class.
|
|
1032
|
+
"""
|
|
1033
|
+
|
|
1034
|
+
# Indicates a retry attempt following a rate limit error (error code: 429). Requires a 'rate-limits' resource pool.
|
|
1035
|
+
# If True, call RateLimitsInfo.record() with reset_exc=True.
|
|
1036
|
+
is_retry: bool = False
|
|
@@ -103,6 +103,6 @@ class AggregationNode(ExecNode):
|
|
|
103
103
|
self.row_builder.eval(prev_row, self.agg_fn_eval_ctx, profile=self.ctx.profile)
|
|
104
104
|
self.output_batch.add_row(prev_row)
|
|
105
105
|
|
|
106
|
-
self.output_batch.flush_imgs(None, self.stored_img_cols, self.flushed_img_slots)
|
|
106
|
+
self.output_batch.flush_imgs(None, self.row_builder.stored_img_cols, self.flushed_img_slots)
|
|
107
107
|
_logger.debug(f'AggregateNode: consumed {num_input_rows} rows, returning {len(self.output_batch.rows)} rows')
|
|
108
108
|
yield self.output_batch
|
|
@@ -12,8 +12,9 @@ from pathlib import Path
|
|
|
12
12
|
from typing import Any, AsyncIterator, Iterator, Optional
|
|
13
13
|
from uuid import UUID
|
|
14
14
|
|
|
15
|
-
from pixeltable import
|
|
15
|
+
from pixeltable import exceptions as excs, exprs
|
|
16
16
|
from pixeltable.utils.filecache import FileCache
|
|
17
|
+
from pixeltable.utils.media_store import TempStore
|
|
17
18
|
|
|
18
19
|
from .data_row_batch import DataRowBatch
|
|
19
20
|
from .exec_node import ExecNode
|
|
@@ -219,7 +220,7 @@ class CachePrefetchNode(ExecNode):
|
|
|
219
220
|
self.in_flight_requests[f] = url
|
|
220
221
|
|
|
221
222
|
def __fetch_url(self, url: str) -> tuple[Optional[Path], Optional[Exception]]:
|
|
222
|
-
"""Fetches a remote URL into
|
|
223
|
+
"""Fetches a remote URL into the TempStore and returns its path"""
|
|
223
224
|
_logger.debug(f'fetching url={url} thread_name={threading.current_thread().name}')
|
|
224
225
|
parsed = urllib.parse.urlparse(url)
|
|
225
226
|
# Use len(parsed.scheme) > 1 here to ensure we're not being passed
|
|
@@ -230,7 +231,7 @@ class CachePrefetchNode(ExecNode):
|
|
|
230
231
|
if parsed.path:
|
|
231
232
|
p = Path(urllib.parse.unquote(urllib.request.url2pathname(parsed.path)))
|
|
232
233
|
extension = p.suffix
|
|
233
|
-
tmp_path =
|
|
234
|
+
tmp_path = TempStore.create_path(extension=extension)
|
|
234
235
|
try:
|
|
235
236
|
_logger.debug(f'Downloading {url} to {tmp_path}')
|
|
236
237
|
if parsed.scheme == 's3':
|
pixeltable/exec/exec_node.py
CHANGED
|
@@ -20,7 +20,6 @@ class ExecNode(abc.ABC):
|
|
|
20
20
|
row_builder: exprs.RowBuilder
|
|
21
21
|
input: Optional[ExecNode]
|
|
22
22
|
flushed_img_slots: list[int] # idxs of image slots of our output_exprs dependencies
|
|
23
|
-
stored_img_cols: list[exprs.ColumnSlotIdx]
|
|
24
23
|
ctx: Optional[ExecContext]
|
|
25
24
|
|
|
26
25
|
def __init__(
|
|
@@ -40,7 +39,6 @@ class ExecNode(abc.ABC):
|
|
|
40
39
|
self.flushed_img_slots = [
|
|
41
40
|
e.slot_idx for e in output_dependencies if e.col_type.is_image_type() and e.slot_idx not in output_slot_idxs
|
|
42
41
|
]
|
|
43
|
-
self.stored_img_cols = []
|
|
44
42
|
self.ctx = None # all nodes of a tree share the same context
|
|
45
43
|
|
|
46
44
|
def set_ctx(self, ctx: ExecContext) -> None:
|
|
@@ -48,12 +46,6 @@ class ExecNode(abc.ABC):
|
|
|
48
46
|
if self.input is not None:
|
|
49
47
|
self.input.set_ctx(ctx)
|
|
50
48
|
|
|
51
|
-
def set_stored_img_cols(self, stored_img_cols: list[exprs.ColumnSlotIdx]) -> None:
|
|
52
|
-
self.stored_img_cols = stored_img_cols
|
|
53
|
-
# propagate batch size to the source
|
|
54
|
-
if self.input is not None:
|
|
55
|
-
self.input.set_stored_img_cols(stored_img_cols)
|
|
56
|
-
|
|
57
49
|
@abc.abstractmethod
|
|
58
50
|
def __aiter__(self) -> AsyncIterator[DataRowBatch]:
|
|
59
51
|
pass
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import traceback
|
|
6
6
|
from types import TracebackType
|
|
7
|
-
from typing import AsyncIterator, Iterable, Optional
|
|
7
|
+
from typing import AsyncIterator, Iterable, Optional
|
|
8
8
|
|
|
9
9
|
import numpy as np
|
|
10
10
|
|
|
@@ -49,7 +49,7 @@ class ExprEvalNode(ExecNode):
|
|
|
49
49
|
# execution state
|
|
50
50
|
tasks: set[asyncio.Task] # collects all running tasks to prevent them from getting gc'd
|
|
51
51
|
exc_event: asyncio.Event # set if an exception needs to be propagated
|
|
52
|
-
error: Optional[
|
|
52
|
+
error: Optional[Exception] # exception that needs to be propagated
|
|
53
53
|
completed_rows: asyncio.Queue[exprs.DataRow] # rows that have completed evaluation
|
|
54
54
|
completed_event: asyncio.Event # set when completed_rows is non-empty
|
|
55
55
|
input_iter: AsyncIterator[DataRowBatch]
|
|
@@ -56,6 +56,7 @@ class Scheduler(abc.ABC):
|
|
|
56
56
|
request: FnCallArgs
|
|
57
57
|
num_retries: int
|
|
58
58
|
exec_ctx: ExecCtx
|
|
59
|
+
retry_after: Optional[float] = None # time.monotonic()
|
|
59
60
|
|
|
60
61
|
def __lt__(self, other: Scheduler.QueueItem) -> bool:
|
|
61
62
|
# prioritize by number of retries (more retries = higher priority)
|
|
@@ -81,6 +81,8 @@ class RateLimitsScheduler(Scheduler):
|
|
|
81
81
|
while True:
|
|
82
82
|
if item is None:
|
|
83
83
|
item = await self.queue.get()
|
|
84
|
+
assert isinstance(item.request.fn_call.fn, func.CallableFunction)
|
|
85
|
+
assert '_runtime_ctx' in item.request.fn_call.fn.signature.system_parameters
|
|
84
86
|
if item.num_retries > 0:
|
|
85
87
|
self.total_retried += 1
|
|
86
88
|
|
|
@@ -97,7 +99,6 @@ class RateLimitsScheduler(Scheduler):
|
|
|
97
99
|
continue
|
|
98
100
|
|
|
99
101
|
# check rate limits
|
|
100
|
-
_logger.debug(f'checking rate limits for {self.resource_pool}')
|
|
101
102
|
request_resources = self._get_request_resources(item.request)
|
|
102
103
|
limits_info = self._check_resource_limits(request_resources)
|
|
103
104
|
aws: list[Awaitable[None]] = []
|
|
@@ -116,21 +117,31 @@ class RateLimitsScheduler(Scheduler):
|
|
|
116
117
|
reset_at = limits_info.reset_at
|
|
117
118
|
if reset_at > now:
|
|
118
119
|
# we're waiting for the rate limit to reset
|
|
119
|
-
|
|
120
|
+
wait_duration = (reset_at - now).total_seconds()
|
|
121
|
+
wait_for_reset = asyncio.create_task(asyncio.sleep(wait_duration))
|
|
120
122
|
aws.append(wait_for_reset)
|
|
121
|
-
_logger.debug(
|
|
123
|
+
_logger.debug(
|
|
124
|
+
f'waiting {wait_duration:.2f}s for rate limit reset of '
|
|
125
|
+
f'{self.resource_pool}:{limits_info.resource} (remaining={limits_info.remaining})'
|
|
126
|
+
)
|
|
122
127
|
|
|
123
128
|
if len(aws) > 0:
|
|
124
129
|
# we have something to wait for
|
|
130
|
+
report_ts = limits_info.recorded_at
|
|
125
131
|
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
|
|
126
132
|
for task in pending:
|
|
127
133
|
task.cancel()
|
|
128
134
|
if completed_aw in done:
|
|
129
135
|
_logger.debug(f'wait(): completed request for {self.resource_pool}')
|
|
130
136
|
if wait_for_reset in done:
|
|
131
|
-
_logger.debug(f'wait(): rate limit reset for {self.resource_pool}')
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
_logger.debug(f'wait(): rate limit reset for {self.resource_pool}:{limits_info.resource}')
|
|
138
|
+
last_report_ts = self.pool_info.resource_limits[limits_info.resource].recorded_at
|
|
139
|
+
if report_ts == last_report_ts:
|
|
140
|
+
# if we haven't seen a new report since we started waiting, force waiting for another rate limit
|
|
141
|
+
# report before making any scheduling decisions
|
|
142
|
+
# TODO: is it a good idea to discard the information we have?
|
|
143
|
+
_logger.debug(f'resetting {self.resource_pool}: currently at {self.pool_info.debug_str()}')
|
|
144
|
+
self.pool_info.reset()
|
|
134
145
|
# re-evaluate current capacity for current item
|
|
135
146
|
continue
|
|
136
147
|
|
|
@@ -158,16 +169,22 @@ class RateLimitsScheduler(Scheduler):
|
|
|
158
169
|
|
|
159
170
|
def _check_resource_limits(self, request_resources: dict[str, int]) -> Optional[env.RateLimitInfo]:
|
|
160
171
|
"""Returns the most depleted resource, relative to its limit, or None if all resources are within limits"""
|
|
161
|
-
candidates: list[tuple[env.RateLimitInfo, float]] = [] # (info, relative
|
|
172
|
+
candidates: list[tuple[env.RateLimitInfo, float]] = [] # (info, relative remaining)
|
|
162
173
|
for resource, usage in request_resources.items():
|
|
163
|
-
# 0.05: leave some headroom, we don't have perfect information
|
|
164
174
|
info = self.pool_info.resource_limits[resource]
|
|
165
175
|
est_remaining = info.remaining - self.est_usage[resource] - usage
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
candidates.append((info, est_remaining / info.limit))
|
|
177
|
+
assert len(candidates) > 0
|
|
178
|
+
candidates.sort(key=lambda x: x[1]) # most depleted first
|
|
179
|
+
most_depleted = candidates[0]
|
|
180
|
+
_logger.debug(
|
|
181
|
+
f'check_resource_limits({request_resources}): '
|
|
182
|
+
f'most_depleted={most_depleted[0].resource}, rel_remaining={most_depleted[1]}'
|
|
183
|
+
)
|
|
184
|
+
# 0.05: leave some headroom, we don't have perfect information
|
|
185
|
+
if most_depleted[1] < 0.05:
|
|
186
|
+
return most_depleted[0]
|
|
187
|
+
return None
|
|
171
188
|
|
|
172
189
|
async def _exec(self, request: FnCallArgs, exec_ctx: ExecCtx, num_retries: int, is_task: bool) -> None:
|
|
173
190
|
assert all(not row.has_val[request.fn_call.slot_idx] for row in request.rows)
|
|
@@ -188,7 +205,8 @@ class RateLimitsScheduler(Scheduler):
|
|
|
188
205
|
for row, result in zip(request.rows, batch_result):
|
|
189
206
|
row[request.fn_call.slot_idx] = result
|
|
190
207
|
else:
|
|
191
|
-
|
|
208
|
+
request_kwargs = {**request.kwargs, '_runtime_ctx': env.RuntimeCtx(is_retry=num_retries > 0)}
|
|
209
|
+
result = await pxt_fn.aexec(*request.args, **request_kwargs)
|
|
192
210
|
request.row[request.fn_call.slot_idx] = result
|
|
193
211
|
end_ts = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
194
212
|
_logger.debug(
|
|
@@ -202,10 +220,14 @@ class RateLimitsScheduler(Scheduler):
|
|
|
202
220
|
self.dispatcher.dispatch(request.rows, exec_ctx)
|
|
203
221
|
except Exception as exc:
|
|
204
222
|
_logger.debug(f'scheduler {self.resource_pool}: exception in slot {request.fn_call.slot_idx}: {exc}')
|
|
223
|
+
if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
|
|
224
|
+
_logger.debug(f'scheduler {self.resource_pool}: exception headers: {exc.response.headers}')
|
|
205
225
|
if self.pool_info is None:
|
|
206
226
|
# our pool info should be available at this point
|
|
207
227
|
self._set_pool_info()
|
|
208
228
|
assert self.pool_info is not None
|
|
229
|
+
self.pool_info.record_exc(exc)
|
|
230
|
+
|
|
209
231
|
if num_retries < self.MAX_RETRIES:
|
|
210
232
|
retry_delay = self.pool_info.get_retry_delay(exc)
|
|
211
233
|
if retry_delay is not None:
|
|
@@ -214,7 +236,6 @@ class RateLimitsScheduler(Scheduler):
|
|
|
214
236
|
await asyncio.sleep(retry_delay)
|
|
215
237
|
self.queue.put_nowait(self.QueueItem(request, num_retries + 1, exec_ctx))
|
|
216
238
|
return
|
|
217
|
-
# TODO: update resource limits reported in exc.response.headers, if present
|
|
218
239
|
|
|
219
240
|
# record the exception
|
|
220
241
|
_, _, exc_tb = sys.exc_info()
|
|
@@ -249,6 +270,7 @@ class RequestRateScheduler(Scheduler):
|
|
|
249
270
|
num_in_flight: int
|
|
250
271
|
total_requests: int
|
|
251
272
|
total_retried: int
|
|
273
|
+
total_errors: int
|
|
252
274
|
|
|
253
275
|
TIME_FORMAT = '%H:%M.%S %f'
|
|
254
276
|
MAX_RETRIES = 3
|
|
@@ -273,6 +295,7 @@ class RequestRateScheduler(Scheduler):
|
|
|
273
295
|
self.num_in_flight = 0
|
|
274
296
|
self.total_requests = 0
|
|
275
297
|
self.total_retried = 0
|
|
298
|
+
self.total_errors = 0
|
|
276
299
|
|
|
277
300
|
# try to get the rate limit from the config
|
|
278
301
|
elems = resource_pool.split(':')
|
|
@@ -291,6 +314,7 @@ class RequestRateScheduler(Scheduler):
|
|
|
291
314
|
key = model
|
|
292
315
|
requests_per_min = Config.get().get_int_value(key, section=section)
|
|
293
316
|
requests_per_min = requests_per_min or self.DEFAULT_RATE_LIMIT
|
|
317
|
+
_logger.debug(f'rate limit for {self.resource_pool}: {requests_per_min} RPM')
|
|
294
318
|
self.secs_per_request = 1 / (requests_per_min / 60)
|
|
295
319
|
|
|
296
320
|
@classmethod
|
|
@@ -304,8 +328,12 @@ class RequestRateScheduler(Scheduler):
|
|
|
304
328
|
if item.num_retries > 0:
|
|
305
329
|
self.total_retried += 1
|
|
306
330
|
now = time.monotonic()
|
|
331
|
+
wait_duration = 0.0
|
|
332
|
+
if item.retry_after is not None:
|
|
333
|
+
wait_duration = item.retry_after - now
|
|
307
334
|
if now - last_request_ts < self.secs_per_request:
|
|
308
|
-
wait_duration = self.secs_per_request - (now - last_request_ts)
|
|
335
|
+
wait_duration = max(wait_duration, self.secs_per_request - (now - last_request_ts))
|
|
336
|
+
if wait_duration > 0:
|
|
309
337
|
_logger.debug(f'waiting for {wait_duration} for {self.resource_pool}')
|
|
310
338
|
await asyncio.sleep(wait_duration)
|
|
311
339
|
|
|
@@ -351,15 +379,20 @@ class RequestRateScheduler(Scheduler):
|
|
|
351
379
|
|
|
352
380
|
except Exception as exc:
|
|
353
381
|
_logger.debug(f'exception for {self.resource_pool}: type={type(exc)}\n{exc}')
|
|
382
|
+
if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
|
|
383
|
+
_logger.debug(f'scheduler {self.resource_pool}: exception headers: {exc.response.headers}')
|
|
354
384
|
is_rate_limit_error, retry_after = self._is_rate_limit_error(exc)
|
|
355
385
|
if is_rate_limit_error and num_retries < self.MAX_RETRIES:
|
|
356
386
|
retry_delay = self._compute_retry_delay(num_retries, retry_after)
|
|
357
387
|
_logger.debug(f'scheduler {self.resource_pool}: retrying after {retry_delay}')
|
|
358
|
-
|
|
359
|
-
|
|
388
|
+
now = time.monotonic()
|
|
389
|
+
# put the request back in the queue right away, which prevents new requests from being generated until
|
|
390
|
+
# this one succeeds or exceeds its retry limit
|
|
391
|
+
self.queue.put_nowait(self.QueueItem(request, num_retries + 1, exec_ctx, retry_after=now + retry_delay))
|
|
360
392
|
return
|
|
361
393
|
|
|
362
394
|
# record the exception
|
|
395
|
+
self.total_errors += 1
|
|
363
396
|
_, _, exc_tb = sys.exc_info()
|
|
364
397
|
for row in request.rows:
|
|
365
398
|
row.set_exc(request.fn_call.slot_idx, exc)
|
|
@@ -367,7 +400,7 @@ class RequestRateScheduler(Scheduler):
|
|
|
367
400
|
finally:
|
|
368
401
|
_logger.debug(
|
|
369
402
|
f'Scheduler stats: #in-flight={self.num_in_flight} #requests={self.total_requests}, '
|
|
370
|
-
f'#retried={self.total_retried}'
|
|
403
|
+
f'#retried={self.total_retried} #errors={self.total_errors}'
|
|
371
404
|
)
|
|
372
405
|
if is_task:
|
|
373
406
|
self.num_in_flight -= 1
|
|
@@ -2,7 +2,7 @@ import logging
|
|
|
2
2
|
from typing import Any, AsyncIterator, Optional
|
|
3
3
|
|
|
4
4
|
from pixeltable import catalog, exprs
|
|
5
|
-
from pixeltable.utils.media_store import
|
|
5
|
+
from pixeltable.utils.media_store import TempStore
|
|
6
6
|
|
|
7
7
|
from .data_row_batch import DataRowBatch
|
|
8
8
|
from .exec_node import ExecNode
|
|
@@ -67,8 +67,7 @@ class InMemoryDataNode(ExecNode):
|
|
|
67
67
|
col = col_info.col
|
|
68
68
|
if col.col_type.is_image_type() and isinstance(val, bytes):
|
|
69
69
|
# this is a literal media file, ie, a sequence of bytes; save it as a binary file and store the path
|
|
70
|
-
|
|
71
|
-
filepath, _ = MediaStore.save_media_object(val, col, format=None)
|
|
70
|
+
filepath, _ = TempStore.save_media_object(val, col, format=None)
|
|
72
71
|
output_row[col_info.slot_idx] = str(filepath)
|
|
73
72
|
else:
|
|
74
73
|
output_row[col_info.slot_idx] = val
|
pixeltable/exprs/array_slice.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Optional
|
|
3
|
+
from typing import Any, Optional
|
|
4
4
|
|
|
5
5
|
import sqlalchemy as sql
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ class ArraySlice(Expr):
|
|
|
16
16
|
Slice operation on an array, eg, t.array_col[:, 1:2].
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
def __init__(self, arr: Expr, index: tuple[
|
|
19
|
+
def __init__(self, arr: Expr, index: tuple[int | slice, ...]):
|
|
20
20
|
assert arr.col_type.is_array_type()
|
|
21
21
|
# determine result type
|
|
22
22
|
super().__init__(arr.col_type)
|
pixeltable/exprs/data_row.py
CHANGED
|
@@ -14,7 +14,7 @@ import PIL.Image
|
|
|
14
14
|
import sqlalchemy as sql
|
|
15
15
|
|
|
16
16
|
from pixeltable import catalog, env
|
|
17
|
-
from pixeltable.utils.media_store import MediaStore
|
|
17
|
+
from pixeltable.utils.media_store import MediaStore, TempStore
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class DataRow:
|
|
@@ -270,7 +270,7 @@ class DataRow:
|
|
|
270
270
|
# Default to JPEG unless the image has a transparency layer (which isn't supported by JPEG).
|
|
271
271
|
# In that case, use WebP instead.
|
|
272
272
|
format = 'webp' if image.has_transparency_data else 'jpeg'
|
|
273
|
-
filepath, url = MediaStore.save_media_object(image, col, format=format)
|
|
273
|
+
filepath, url = MediaStore.get().save_media_object(image, col, format=format)
|
|
274
274
|
self.file_paths[index] = str(filepath)
|
|
275
275
|
self.file_urls[index] = url
|
|
276
276
|
else:
|
|
@@ -281,6 +281,19 @@ class DataRow:
|
|
|
281
281
|
pass
|
|
282
282
|
self.vals[index] = None
|
|
283
283
|
|
|
284
|
+
def move_tmp_media_file(self, index: int, col: catalog.Column) -> None:
|
|
285
|
+
"""If a media url refers to data in a temporary file, move the data to a MediaStore"""
|
|
286
|
+
if self.file_urls[index] is None:
|
|
287
|
+
return
|
|
288
|
+
assert self.excs[index] is None
|
|
289
|
+
assert col.col_type.is_media_type()
|
|
290
|
+
src_path = TempStore.resolve_url(self.file_urls[index])
|
|
291
|
+
if src_path is None:
|
|
292
|
+
# The media url does not point to a temporary file, leave it as is
|
|
293
|
+
return
|
|
294
|
+
new_file_url = MediaStore.get().relocate_local_media_file(src_path, col)
|
|
295
|
+
self.file_urls[index] = new_file_url
|
|
296
|
+
|
|
284
297
|
@property
|
|
285
298
|
def rowid(self) -> tuple[int, ...]:
|
|
286
299
|
return self.pk[:-1]
|
pixeltable/exprs/expr.py
CHANGED
|
@@ -7,7 +7,7 @@ import inspect
|
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
9
|
import typing
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional, TypeVar,
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional, TypeVar, overload
|
|
11
11
|
from uuid import UUID
|
|
12
12
|
|
|
13
13
|
import numpy as np
|
|
@@ -550,7 +550,7 @@ class Expr(abc.ABC):
|
|
|
550
550
|
else:
|
|
551
551
|
return InPredicate(self, value_set_literal=value_set)
|
|
552
552
|
|
|
553
|
-
def astype(self, new_type:
|
|
553
|
+
def astype(self, new_type: ts.ColumnType | type | _AnnotatedAlias) -> 'exprs.TypeCast':
|
|
554
554
|
from pixeltable.exprs import TypeCast
|
|
555
555
|
|
|
556
556
|
# Interpret the type argument the same way we would if given in a schema
|
|
@@ -562,7 +562,7 @@ class Expr(abc.ABC):
|
|
|
562
562
|
return TypeCast(self, col_type)
|
|
563
563
|
|
|
564
564
|
def apply(
|
|
565
|
-
self, fn: Callable, *, col_type:
|
|
565
|
+
self, fn: Callable, *, col_type: ts.ColumnType | type | _AnnotatedAlias | None = None
|
|
566
566
|
) -> 'exprs.FunctionCall':
|
|
567
567
|
if col_type is not None:
|
|
568
568
|
col_type = ts.ColumnType.normalize_type(col_type)
|
|
@@ -646,7 +646,7 @@ class Expr(abc.ABC):
|
|
|
646
646
|
|
|
647
647
|
def _make_comparison(self, op: ComparisonOperator, other: object) -> 'exprs.Comparison':
|
|
648
648
|
"""
|
|
649
|
-
other:
|
|
649
|
+
other: Expr | LiteralPythonTypes
|
|
650
650
|
"""
|
|
651
651
|
# TODO: check for compatibility
|
|
652
652
|
from .comparison import Comparison
|
|
@@ -661,7 +661,7 @@ class Expr(abc.ABC):
|
|
|
661
661
|
def __neg__(self) -> 'exprs.ArithmeticExpr':
|
|
662
662
|
return self._make_arithmetic_expr(ArithmeticOperator.MUL, -1)
|
|
663
663
|
|
|
664
|
-
def __add__(self, other: object) ->
|
|
664
|
+
def __add__(self, other: object) -> exprs.ArithmeticExpr | exprs.StringOp:
|
|
665
665
|
if isinstance(self, str) or (isinstance(self, Expr) and self.col_type.is_string_type()):
|
|
666
666
|
return self._make_string_expr(StringOperator.CONCAT, other)
|
|
667
667
|
return self._make_arithmetic_expr(ArithmeticOperator.ADD, other)
|
|
@@ -669,7 +669,7 @@ class Expr(abc.ABC):
|
|
|
669
669
|
def __sub__(self, other: object) -> 'exprs.ArithmeticExpr':
|
|
670
670
|
return self._make_arithmetic_expr(ArithmeticOperator.SUB, other)
|
|
671
671
|
|
|
672
|
-
def __mul__(self, other: object) ->
|
|
672
|
+
def __mul__(self, other: object) -> 'exprs.ArithmeticExpr' | 'exprs.StringOp':
|
|
673
673
|
if isinstance(self, str) or (isinstance(self, Expr) and self.col_type.is_string_type()):
|
|
674
674
|
return self._make_string_expr(StringOperator.REPEAT, other)
|
|
675
675
|
return self._make_arithmetic_expr(ArithmeticOperator.MUL, other)
|
|
@@ -683,7 +683,7 @@ class Expr(abc.ABC):
|
|
|
683
683
|
def __floordiv__(self, other: object) -> 'exprs.ArithmeticExpr':
|
|
684
684
|
return self._make_arithmetic_expr(ArithmeticOperator.FLOORDIV, other)
|
|
685
685
|
|
|
686
|
-
def __radd__(self, other: object) ->
|
|
686
|
+
def __radd__(self, other: object) -> 'exprs.ArithmeticExpr' | 'exprs.StringOp':
|
|
687
687
|
if isinstance(other, str) or (isinstance(other, Expr) and other.col_type.is_string_type()):
|
|
688
688
|
return self._rmake_string_expr(StringOperator.CONCAT, other)
|
|
689
689
|
return self._rmake_arithmetic_expr(ArithmeticOperator.ADD, other)
|
|
@@ -691,7 +691,7 @@ class Expr(abc.ABC):
|
|
|
691
691
|
def __rsub__(self, other: object) -> 'exprs.ArithmeticExpr':
|
|
692
692
|
return self._rmake_arithmetic_expr(ArithmeticOperator.SUB, other)
|
|
693
693
|
|
|
694
|
-
def __rmul__(self, other: object) ->
|
|
694
|
+
def __rmul__(self, other: object) -> 'exprs.ArithmeticExpr' | 'exprs.StringOp':
|
|
695
695
|
if isinstance(other, str) or (isinstance(other, Expr) and other.col_type.is_string_type()):
|
|
696
696
|
return self._rmake_string_expr(StringOperator.REPEAT, other)
|
|
697
697
|
return self._rmake_arithmetic_expr(ArithmeticOperator.MUL, other)
|
|
@@ -733,7 +733,7 @@ class Expr(abc.ABC):
|
|
|
733
733
|
|
|
734
734
|
def _make_arithmetic_expr(self, op: ArithmeticOperator, other: object) -> 'exprs.ArithmeticExpr':
|
|
735
735
|
"""
|
|
736
|
-
other:
|
|
736
|
+
other: Expr | LiteralPythonTypes
|
|
737
737
|
"""
|
|
738
738
|
# TODO: check for compatibility
|
|
739
739
|
from .arithmetic_expr import ArithmeticExpr
|