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.

Files changed (69) hide show
  1. pixeltable/__init__.py +4 -2
  2. pixeltable/catalog/__init__.py +1 -1
  3. pixeltable/catalog/catalog.py +7 -9
  4. pixeltable/catalog/column.py +49 -0
  5. pixeltable/catalog/insertable_table.py +0 -7
  6. pixeltable/catalog/schema_object.py +1 -14
  7. pixeltable/catalog/table.py +180 -67
  8. pixeltable/catalog/table_version.py +42 -146
  9. pixeltable/catalog/table_version_path.py +6 -5
  10. pixeltable/catalog/view.py +2 -1
  11. pixeltable/config.py +24 -9
  12. pixeltable/dataframe.py +5 -6
  13. pixeltable/env.py +113 -21
  14. pixeltable/exec/aggregation_node.py +1 -1
  15. pixeltable/exec/cache_prefetch_node.py +4 -3
  16. pixeltable/exec/exec_node.py +0 -8
  17. pixeltable/exec/expr_eval/expr_eval_node.py +2 -2
  18. pixeltable/exec/expr_eval/globals.py +1 -0
  19. pixeltable/exec/expr_eval/schedulers.py +52 -19
  20. pixeltable/exec/in_memory_data_node.py +2 -3
  21. pixeltable/exprs/array_slice.py +2 -2
  22. pixeltable/exprs/data_row.py +15 -2
  23. pixeltable/exprs/expr.py +9 -9
  24. pixeltable/exprs/function_call.py +61 -23
  25. pixeltable/exprs/globals.py +1 -2
  26. pixeltable/exprs/json_path.py +3 -3
  27. pixeltable/exprs/row_builder.py +25 -21
  28. pixeltable/exprs/string_op.py +3 -3
  29. pixeltable/func/expr_template_function.py +6 -3
  30. pixeltable/func/query_template_function.py +2 -2
  31. pixeltable/func/signature.py +30 -3
  32. pixeltable/func/tools.py +2 -2
  33. pixeltable/functions/anthropic.py +76 -27
  34. pixeltable/functions/deepseek.py +5 -1
  35. pixeltable/functions/gemini.py +11 -2
  36. pixeltable/functions/globals.py +2 -2
  37. pixeltable/functions/huggingface.py +6 -12
  38. pixeltable/functions/llama_cpp.py +9 -1
  39. pixeltable/functions/openai.py +76 -55
  40. pixeltable/functions/video.py +59 -6
  41. pixeltable/functions/vision.py +2 -2
  42. pixeltable/globals.py +86 -13
  43. pixeltable/io/datarows.py +3 -3
  44. pixeltable/io/fiftyone.py +7 -7
  45. pixeltable/io/globals.py +3 -3
  46. pixeltable/io/hf_datasets.py +4 -4
  47. pixeltable/io/label_studio.py +2 -1
  48. pixeltable/io/pandas.py +6 -6
  49. pixeltable/io/parquet.py +3 -3
  50. pixeltable/io/table_data_conduit.py +2 -2
  51. pixeltable/io/utils.py +2 -2
  52. pixeltable/iterators/audio.py +3 -2
  53. pixeltable/iterators/document.py +2 -8
  54. pixeltable/iterators/video.py +49 -9
  55. pixeltable/plan.py +0 -16
  56. pixeltable/share/packager.py +51 -42
  57. pixeltable/share/publish.py +134 -7
  58. pixeltable/store.py +5 -25
  59. pixeltable/type_system.py +5 -8
  60. pixeltable/utils/__init__.py +2 -2
  61. pixeltable/utils/arrow.py +5 -5
  62. pixeltable/utils/description_helper.py +3 -3
  63. pixeltable/utils/iceberg.py +1 -2
  64. pixeltable/utils/media_store.py +131 -66
  65. {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/METADATA +238 -122
  66. {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/RECORD +69 -69
  67. {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/WHEEL +0 -0
  68. {pixeltable-0.4.6.dist-info → pixeltable-0.4.8.dist-info}/entry_points.txt +0 -0
  69. {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' if for_write else 'REPEATABLE_READ'
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 = os.environ.get('PIXELTABLE_DB', 'pixeltable')
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
- stmt = f"""
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
- @abstractmethod
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
- pass
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 reeset duration shortens as output_tokens
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 env, exceptions as excs, exprs
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 Env.tmp_dir and returns its path"""
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 = env.Env.get().create_tmp_path(extension=extension)
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':
@@ -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, Union
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[Union[Exception]] # exception that needs to be propagated
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
- wait_for_reset = asyncio.create_task(asyncio.sleep((reset_at - now).total_seconds()))
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(f'waiting for rate limit reset for {self.resource_pool}')
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
- # force waiting for another rate limit report before making any scheduling decisions
133
- self.pool_info.reset()
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 usage)
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
- if est_remaining < 0.05 * info.limit:
167
- candidates.append((info, est_remaining / info.limit))
168
- if len(candidates) == 0:
169
- return None
170
- return min(candidates, key=lambda x: x[1])[0]
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
- result = await pxt_fn.aexec(*request.args, **request.kwargs)
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
- await asyncio.sleep(retry_delay)
359
- self.queue.put_nowait(self.QueueItem(request, num_retries + 1, exec_ctx))
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 MediaStore
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
- assert col.tbl.id == self.tbl.id
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
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, Union
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[Union[int, slice], ...]):
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)
@@ -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, Union, overload
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: Union[ts.ColumnType, type, _AnnotatedAlias]) -> 'exprs.TypeCast':
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: Union[ts.ColumnType, type, _AnnotatedAlias, None] = None
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: Union[Expr, LiteralPythonTypes]
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) -> Union[exprs.ArithmeticExpr, exprs.StringOp]:
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) -> Union['exprs.ArithmeticExpr', 'exprs.StringOp']:
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) -> Union['exprs.ArithmeticExpr', 'exprs.StringOp']:
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) -> Union['exprs.ArithmeticExpr', 'exprs.StringOp']:
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: Union[Expr, LiteralPythonTypes]
736
+ other: Expr | LiteralPythonTypes
737
737
  """
738
738
  # TODO: check for compatibility
739
739
  from .arithmetic_expr import ArithmeticExpr