pixeltable 0.2.26__py3-none-any.whl → 0.5.7__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.
Files changed (245) hide show
  1. pixeltable/__init__.py +83 -19
  2. pixeltable/_query.py +1444 -0
  3. pixeltable/_version.py +1 -0
  4. pixeltable/catalog/__init__.py +7 -4
  5. pixeltable/catalog/catalog.py +2394 -119
  6. pixeltable/catalog/column.py +225 -104
  7. pixeltable/catalog/dir.py +38 -9
  8. pixeltable/catalog/globals.py +53 -34
  9. pixeltable/catalog/insertable_table.py +265 -115
  10. pixeltable/catalog/path.py +80 -17
  11. pixeltable/catalog/schema_object.py +28 -43
  12. pixeltable/catalog/table.py +1270 -677
  13. pixeltable/catalog/table_metadata.py +103 -0
  14. pixeltable/catalog/table_version.py +1270 -751
  15. pixeltable/catalog/table_version_handle.py +109 -0
  16. pixeltable/catalog/table_version_path.py +137 -42
  17. pixeltable/catalog/tbl_ops.py +53 -0
  18. pixeltable/catalog/update_status.py +191 -0
  19. pixeltable/catalog/view.py +251 -134
  20. pixeltable/config.py +215 -0
  21. pixeltable/env.py +736 -285
  22. pixeltable/exceptions.py +26 -2
  23. pixeltable/exec/__init__.py +7 -2
  24. pixeltable/exec/aggregation_node.py +39 -21
  25. pixeltable/exec/cache_prefetch_node.py +87 -109
  26. pixeltable/exec/cell_materialization_node.py +268 -0
  27. pixeltable/exec/cell_reconstruction_node.py +168 -0
  28. pixeltable/exec/component_iteration_node.py +25 -28
  29. pixeltable/exec/data_row_batch.py +11 -46
  30. pixeltable/exec/exec_context.py +26 -11
  31. pixeltable/exec/exec_node.py +35 -27
  32. pixeltable/exec/expr_eval/__init__.py +3 -0
  33. pixeltable/exec/expr_eval/evaluators.py +365 -0
  34. pixeltable/exec/expr_eval/expr_eval_node.py +413 -0
  35. pixeltable/exec/expr_eval/globals.py +200 -0
  36. pixeltable/exec/expr_eval/row_buffer.py +74 -0
  37. pixeltable/exec/expr_eval/schedulers.py +413 -0
  38. pixeltable/exec/globals.py +35 -0
  39. pixeltable/exec/in_memory_data_node.py +35 -27
  40. pixeltable/exec/object_store_save_node.py +293 -0
  41. pixeltable/exec/row_update_node.py +44 -29
  42. pixeltable/exec/sql_node.py +414 -115
  43. pixeltable/exprs/__init__.py +8 -5
  44. pixeltable/exprs/arithmetic_expr.py +79 -45
  45. pixeltable/exprs/array_slice.py +5 -5
  46. pixeltable/exprs/column_property_ref.py +40 -26
  47. pixeltable/exprs/column_ref.py +254 -61
  48. pixeltable/exprs/comparison.py +14 -9
  49. pixeltable/exprs/compound_predicate.py +9 -10
  50. pixeltable/exprs/data_row.py +213 -72
  51. pixeltable/exprs/expr.py +270 -104
  52. pixeltable/exprs/expr_dict.py +6 -5
  53. pixeltable/exprs/expr_set.py +20 -11
  54. pixeltable/exprs/function_call.py +383 -284
  55. pixeltable/exprs/globals.py +18 -5
  56. pixeltable/exprs/in_predicate.py +7 -7
  57. pixeltable/exprs/inline_expr.py +37 -37
  58. pixeltable/exprs/is_null.py +8 -4
  59. pixeltable/exprs/json_mapper.py +120 -54
  60. pixeltable/exprs/json_path.py +90 -60
  61. pixeltable/exprs/literal.py +61 -16
  62. pixeltable/exprs/method_ref.py +7 -6
  63. pixeltable/exprs/object_ref.py +19 -8
  64. pixeltable/exprs/row_builder.py +238 -75
  65. pixeltable/exprs/rowid_ref.py +53 -15
  66. pixeltable/exprs/similarity_expr.py +65 -50
  67. pixeltable/exprs/sql_element_cache.py +5 -5
  68. pixeltable/exprs/string_op.py +107 -0
  69. pixeltable/exprs/type_cast.py +25 -13
  70. pixeltable/exprs/variable.py +2 -2
  71. pixeltable/func/__init__.py +9 -5
  72. pixeltable/func/aggregate_function.py +197 -92
  73. pixeltable/func/callable_function.py +119 -35
  74. pixeltable/func/expr_template_function.py +101 -48
  75. pixeltable/func/function.py +375 -62
  76. pixeltable/func/function_registry.py +20 -19
  77. pixeltable/func/globals.py +6 -5
  78. pixeltable/func/mcp.py +74 -0
  79. pixeltable/func/query_template_function.py +151 -35
  80. pixeltable/func/signature.py +178 -49
  81. pixeltable/func/tools.py +164 -0
  82. pixeltable/func/udf.py +176 -53
  83. pixeltable/functions/__init__.py +44 -4
  84. pixeltable/functions/anthropic.py +226 -47
  85. pixeltable/functions/audio.py +148 -11
  86. pixeltable/functions/bedrock.py +137 -0
  87. pixeltable/functions/date.py +188 -0
  88. pixeltable/functions/deepseek.py +113 -0
  89. pixeltable/functions/document.py +81 -0
  90. pixeltable/functions/fal.py +76 -0
  91. pixeltable/functions/fireworks.py +72 -20
  92. pixeltable/functions/gemini.py +249 -0
  93. pixeltable/functions/globals.py +208 -53
  94. pixeltable/functions/groq.py +108 -0
  95. pixeltable/functions/huggingface.py +1088 -95
  96. pixeltable/functions/image.py +155 -84
  97. pixeltable/functions/json.py +8 -11
  98. pixeltable/functions/llama_cpp.py +31 -19
  99. pixeltable/functions/math.py +169 -0
  100. pixeltable/functions/mistralai.py +50 -75
  101. pixeltable/functions/net.py +70 -0
  102. pixeltable/functions/ollama.py +29 -36
  103. pixeltable/functions/openai.py +548 -160
  104. pixeltable/functions/openrouter.py +143 -0
  105. pixeltable/functions/replicate.py +15 -14
  106. pixeltable/functions/reve.py +250 -0
  107. pixeltable/functions/string.py +310 -85
  108. pixeltable/functions/timestamp.py +37 -19
  109. pixeltable/functions/together.py +77 -120
  110. pixeltable/functions/twelvelabs.py +188 -0
  111. pixeltable/functions/util.py +7 -2
  112. pixeltable/functions/uuid.py +30 -0
  113. pixeltable/functions/video.py +1528 -117
  114. pixeltable/functions/vision.py +26 -26
  115. pixeltable/functions/voyageai.py +289 -0
  116. pixeltable/functions/whisper.py +19 -10
  117. pixeltable/functions/whisperx.py +179 -0
  118. pixeltable/functions/yolox.py +112 -0
  119. pixeltable/globals.py +716 -236
  120. pixeltable/index/__init__.py +3 -1
  121. pixeltable/index/base.py +17 -21
  122. pixeltable/index/btree.py +32 -22
  123. pixeltable/index/embedding_index.py +155 -92
  124. pixeltable/io/__init__.py +12 -7
  125. pixeltable/io/datarows.py +140 -0
  126. pixeltable/io/external_store.py +83 -125
  127. pixeltable/io/fiftyone.py +24 -33
  128. pixeltable/io/globals.py +47 -182
  129. pixeltable/io/hf_datasets.py +96 -127
  130. pixeltable/io/label_studio.py +171 -156
  131. pixeltable/io/lancedb.py +3 -0
  132. pixeltable/io/pandas.py +136 -115
  133. pixeltable/io/parquet.py +40 -153
  134. pixeltable/io/table_data_conduit.py +702 -0
  135. pixeltable/io/utils.py +100 -0
  136. pixeltable/iterators/__init__.py +8 -4
  137. pixeltable/iterators/audio.py +207 -0
  138. pixeltable/iterators/base.py +9 -3
  139. pixeltable/iterators/document.py +144 -87
  140. pixeltable/iterators/image.py +17 -38
  141. pixeltable/iterators/string.py +15 -12
  142. pixeltable/iterators/video.py +523 -127
  143. pixeltable/metadata/__init__.py +33 -8
  144. pixeltable/metadata/converters/convert_10.py +2 -3
  145. pixeltable/metadata/converters/convert_13.py +2 -2
  146. pixeltable/metadata/converters/convert_15.py +15 -11
  147. pixeltable/metadata/converters/convert_16.py +4 -5
  148. pixeltable/metadata/converters/convert_17.py +4 -5
  149. pixeltable/metadata/converters/convert_18.py +4 -6
  150. pixeltable/metadata/converters/convert_19.py +6 -9
  151. pixeltable/metadata/converters/convert_20.py +3 -6
  152. pixeltable/metadata/converters/convert_21.py +6 -8
  153. pixeltable/metadata/converters/convert_22.py +3 -2
  154. pixeltable/metadata/converters/convert_23.py +33 -0
  155. pixeltable/metadata/converters/convert_24.py +55 -0
  156. pixeltable/metadata/converters/convert_25.py +19 -0
  157. pixeltable/metadata/converters/convert_26.py +23 -0
  158. pixeltable/metadata/converters/convert_27.py +29 -0
  159. pixeltable/metadata/converters/convert_28.py +13 -0
  160. pixeltable/metadata/converters/convert_29.py +110 -0
  161. pixeltable/metadata/converters/convert_30.py +63 -0
  162. pixeltable/metadata/converters/convert_31.py +11 -0
  163. pixeltable/metadata/converters/convert_32.py +15 -0
  164. pixeltable/metadata/converters/convert_33.py +17 -0
  165. pixeltable/metadata/converters/convert_34.py +21 -0
  166. pixeltable/metadata/converters/convert_35.py +9 -0
  167. pixeltable/metadata/converters/convert_36.py +38 -0
  168. pixeltable/metadata/converters/convert_37.py +15 -0
  169. pixeltable/metadata/converters/convert_38.py +39 -0
  170. pixeltable/metadata/converters/convert_39.py +124 -0
  171. pixeltable/metadata/converters/convert_40.py +73 -0
  172. pixeltable/metadata/converters/convert_41.py +12 -0
  173. pixeltable/metadata/converters/convert_42.py +9 -0
  174. pixeltable/metadata/converters/convert_43.py +44 -0
  175. pixeltable/metadata/converters/util.py +44 -18
  176. pixeltable/metadata/notes.py +21 -0
  177. pixeltable/metadata/schema.py +185 -42
  178. pixeltable/metadata/utils.py +74 -0
  179. pixeltable/mypy/__init__.py +3 -0
  180. pixeltable/mypy/mypy_plugin.py +123 -0
  181. pixeltable/plan.py +616 -225
  182. pixeltable/share/__init__.py +3 -0
  183. pixeltable/share/packager.py +797 -0
  184. pixeltable/share/protocol/__init__.py +33 -0
  185. pixeltable/share/protocol/common.py +165 -0
  186. pixeltable/share/protocol/operation_types.py +33 -0
  187. pixeltable/share/protocol/replica.py +119 -0
  188. pixeltable/share/publish.py +349 -0
  189. pixeltable/store.py +398 -232
  190. pixeltable/type_system.py +730 -267
  191. pixeltable/utils/__init__.py +40 -0
  192. pixeltable/utils/arrow.py +201 -29
  193. pixeltable/utils/av.py +298 -0
  194. pixeltable/utils/azure_store.py +346 -0
  195. pixeltable/utils/coco.py +26 -27
  196. pixeltable/utils/code.py +4 -4
  197. pixeltable/utils/console_output.py +46 -0
  198. pixeltable/utils/coroutine.py +24 -0
  199. pixeltable/utils/dbms.py +92 -0
  200. pixeltable/utils/description_helper.py +11 -12
  201. pixeltable/utils/documents.py +60 -61
  202. pixeltable/utils/exception_handler.py +36 -0
  203. pixeltable/utils/filecache.py +38 -22
  204. pixeltable/utils/formatter.py +88 -51
  205. pixeltable/utils/gcs_store.py +295 -0
  206. pixeltable/utils/http.py +133 -0
  207. pixeltable/utils/http_server.py +14 -13
  208. pixeltable/utils/iceberg.py +13 -0
  209. pixeltable/utils/image.py +17 -0
  210. pixeltable/utils/lancedb.py +90 -0
  211. pixeltable/utils/local_store.py +322 -0
  212. pixeltable/utils/misc.py +5 -0
  213. pixeltable/utils/object_stores.py +573 -0
  214. pixeltable/utils/pydantic.py +60 -0
  215. pixeltable/utils/pytorch.py +20 -20
  216. pixeltable/utils/s3_store.py +527 -0
  217. pixeltable/utils/sql.py +32 -5
  218. pixeltable/utils/system.py +30 -0
  219. pixeltable/utils/transactional_directory.py +4 -3
  220. pixeltable-0.5.7.dist-info/METADATA +579 -0
  221. pixeltable-0.5.7.dist-info/RECORD +227 -0
  222. {pixeltable-0.2.26.dist-info → pixeltable-0.5.7.dist-info}/WHEEL +1 -1
  223. pixeltable-0.5.7.dist-info/entry_points.txt +2 -0
  224. pixeltable/__version__.py +0 -3
  225. pixeltable/catalog/named_function.py +0 -36
  226. pixeltable/catalog/path_dict.py +0 -141
  227. pixeltable/dataframe.py +0 -894
  228. pixeltable/exec/expr_eval_node.py +0 -232
  229. pixeltable/ext/__init__.py +0 -14
  230. pixeltable/ext/functions/__init__.py +0 -8
  231. pixeltable/ext/functions/whisperx.py +0 -77
  232. pixeltable/ext/functions/yolox.py +0 -157
  233. pixeltable/tool/create_test_db_dump.py +0 -311
  234. pixeltable/tool/create_test_video.py +0 -81
  235. pixeltable/tool/doc_plugins/griffe.py +0 -50
  236. pixeltable/tool/doc_plugins/mkdocstrings.py +0 -6
  237. pixeltable/tool/doc_plugins/templates/material/udf.html.jinja +0 -135
  238. pixeltable/tool/embed_udf.py +0 -9
  239. pixeltable/tool/mypy_plugin.py +0 -55
  240. pixeltable/utils/media_store.py +0 -76
  241. pixeltable/utils/s3.py +0 -16
  242. pixeltable-0.2.26.dist-info/METADATA +0 -400
  243. pixeltable-0.2.26.dist-info/RECORD +0 -156
  244. pixeltable-0.2.26.dist-info/entry_points.txt +0 -3
  245. {pixeltable-0.2.26.dist-info → pixeltable-0.5.7.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import datetime
5
+ import inspect
6
+ import logging
7
+ import math
8
+ import sys
9
+ import time
10
+ from typing import Awaitable, Collection
11
+
12
+ from pixeltable import env, func
13
+ from pixeltable.config import Config
14
+ from pixeltable.utils.http import exponential_backoff, is_retriable_error
15
+
16
+ from .globals import Dispatcher, ExecCtx, FnCallArgs, Scheduler
17
+
18
+ _logger = logging.getLogger('pixeltable')
19
+
20
+ __all__ = ['RateLimitsScheduler', 'RequestRateScheduler']
21
+
22
+
23
+ class RateLimitsScheduler(Scheduler):
24
+ """
25
+ Scheduler for FunctionCalls with a RateLimitsInfo pool, which provides information about actual resource usage.
26
+
27
+ Scheduling strategy:
28
+ - try to stay below resource limits by utilizing reported RateLimitInfo.remaining
29
+ - also take into account the estimated resource usage for in-flight requests
30
+ (obtained via RateLimitsInfo.get_request_resources())
31
+ - issue synchronous requests when we don't have a RateLimitsInfo yet or when we depleted a resource and need to
32
+ wait for a reset
33
+
34
+ TODO:
35
+ - limit the number of in-flight requests based on the open file limit
36
+ """
37
+
38
+ get_request_resources_param_names: list[str] # names of parameters of RateLimitsInfo.get_request_resources()
39
+
40
+ # scheduling-related state
41
+ pool_info: env.RateLimitsInfo | None
42
+ est_usage: dict[str, int] # value per resource; accumulated estimates since the last util. report
43
+
44
+ num_in_flight: int # unfinished tasks
45
+ request_completed: asyncio.Event
46
+
47
+ total_requests: int
48
+ total_retried: int
49
+
50
+ TIME_FORMAT = '%H:%M.%S %f'
51
+ MAX_RETRIES = 10
52
+
53
+ def __init__(self, resource_pool: str, dispatcher: Dispatcher):
54
+ super().__init__(resource_pool, dispatcher)
55
+ loop_task = asyncio.create_task(self._main_loop())
56
+ self.dispatcher.register_task(loop_task)
57
+ self.pool_info = None # initialized in _main_loop by the first request
58
+ self.est_usage = {}
59
+ self.num_in_flight = 0
60
+ self.request_completed = asyncio.Event()
61
+ self.total_requests = 0
62
+ self.total_retried = 0
63
+ self.get_request_resources_param_names = []
64
+
65
+ @classmethod
66
+ def matches(cls, resource_pool: str) -> bool:
67
+ return resource_pool.startswith('rate-limits:')
68
+
69
+ def _set_pool_info(self) -> None:
70
+ """Initialize pool_info with the RateLimitsInfo for the resource pool, if available"""
71
+ if self.pool_info is not None:
72
+ return
73
+ self.pool_info = env.Env.get().get_resource_pool_info(self.resource_pool, None)
74
+ if self.pool_info is None:
75
+ return
76
+ assert isinstance(self.pool_info, env.RateLimitsInfo)
77
+ assert hasattr(self.pool_info, 'get_request_resources')
78
+ sig = inspect.signature(self.pool_info.get_request_resources)
79
+ self.get_request_resources_param_names = [p.name for p in sig.parameters.values()]
80
+ self.est_usage = dict.fromkeys(self._resources, 0)
81
+
82
+ async def _main_loop(self) -> None:
83
+ item: RateLimitsScheduler.QueueItem | None = None
84
+ while True:
85
+ if item is None:
86
+ item = await self.queue.get()
87
+ assert isinstance(item.request.fn_call.fn, func.CallableFunction)
88
+ assert '_runtime_ctx' in item.request.fn_call.fn.signature.system_parameters
89
+ if item.num_retries > 0:
90
+ self.total_retried += 1
91
+
92
+ if self.pool_info is None or not self.pool_info.is_initialized():
93
+ # wait for a single request to get rate limits
94
+ _logger.debug(f'initializing rate limits for {self.resource_pool}')
95
+ await self._exec(item.request, item.exec_ctx, item.num_retries, is_task=False)
96
+ _logger.debug(f'initialized rate limits for {self.resource_pool}')
97
+ item = None
98
+ # if this was the first request, it created the pool_info
99
+ if self.pool_info is None:
100
+ self._set_pool_info()
101
+ continue
102
+
103
+ # check rate limits
104
+ request_resources = self._get_request_resources(item.request)
105
+ resource_delay = self._resource_delay(request_resources)
106
+ aws: list[Awaitable[None]] = []
107
+ completed_aw: asyncio.Task | None = None
108
+ wait_for_reset: asyncio.Task | None = None
109
+ if resource_delay > 0:
110
+ # Some resource or resources are nearing depletion
111
+
112
+ if self.num_in_flight > 0:
113
+ # a completed request can free up capacity
114
+ self.request_completed.clear()
115
+ completed_aw = asyncio.create_task(self.request_completed.wait())
116
+ aws.append(completed_aw)
117
+ _logger.debug(f'waiting for completed request for {self.resource_pool}')
118
+
119
+ # Schedule a sleep until sufficient resources are available
120
+ wait_for_reset = asyncio.create_task(asyncio.sleep(resource_delay))
121
+ aws.append(wait_for_reset)
122
+ _logger.debug(f'waiting {resource_delay:.1f}s for resource availability')
123
+
124
+ if len(aws) > 0:
125
+ # we have something to wait for
126
+ done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
127
+ for task in pending:
128
+ task.cancel()
129
+ if completed_aw in done:
130
+ _logger.debug(f'wait(): completed request for {self.resource_pool}')
131
+ # re-evaluate current capacity for current item
132
+ continue
133
+
134
+ # we have a new in-flight request
135
+ for resource, val in request_resources.items():
136
+ self.est_usage[resource] = self.est_usage.get(resource, 0) + val
137
+ _logger.debug(f'creating task for {self.resource_pool}')
138
+ self.num_in_flight += 1
139
+ task = asyncio.create_task(self._exec(item.request, item.exec_ctx, item.num_retries, is_task=True))
140
+ self.dispatcher.register_task(task)
141
+ item = None
142
+
143
+ @property
144
+ def _resources(self) -> Collection[str]:
145
+ return self.pool_info.resource_limits.keys() if self.pool_info is not None else []
146
+
147
+ def _get_request_resources(self, request: FnCallArgs) -> dict[str, int]:
148
+ kwargs_batch = request.fn_call.get_param_values(self.get_request_resources_param_names, request.rows)
149
+ if not request.is_batched:
150
+ return self.pool_info.get_request_resources(**kwargs_batch[0])
151
+ else:
152
+ batch_kwargs = {k: [d[k] for d in kwargs_batch] for k in kwargs_batch[0]}
153
+ constant_kwargs, batch_kwargs = request.pxt_fn.create_batch_kwargs(batch_kwargs)
154
+ return self.pool_info.get_request_resources(**constant_kwargs, **batch_kwargs)
155
+
156
+ def _resource_delay(self, request_resources: dict[str, int]) -> float:
157
+ """For the provided resources and usage, attempts to estimate the time to wait until sufficient resources are
158
+ available."""
159
+ highest_wait = 0.0
160
+ highest_wait_resource = None
161
+ for resource, usage in request_resources.items():
162
+ info = self.pool_info.resource_limits[resource]
163
+ # Note: usage and est_usage are estimated costs of requests, and it may be way off (for example, if max
164
+ # tokens is unspecified for an openAI request).
165
+ time_until = info.estimated_resource_refill_delay(
166
+ math.ceil(info.limit * env.TARGET_RATE_LIMIT_RESOURCE_FRACT + usage + self.est_usage.get(resource, 0))
167
+ )
168
+ if time_until is not None and highest_wait < time_until:
169
+ highest_wait = time_until
170
+ highest_wait_resource = resource
171
+ _logger.debug(f'Determined wait time of {highest_wait:.1f}s for resource {highest_wait_resource}')
172
+ return highest_wait
173
+
174
+ async def _exec(self, request: FnCallArgs, exec_ctx: ExecCtx, num_retries: int, is_task: bool) -> None:
175
+ assert all(not row.has_val[request.fn_call.slot_idx] for row in request.rows)
176
+ assert all(not row.has_exc(request.fn_call.slot_idx) for row in request.rows)
177
+
178
+ start_ts = datetime.datetime.now(tz=datetime.timezone.utc)
179
+ try:
180
+ pxt_fn = request.fn_call.fn
181
+ assert isinstance(pxt_fn, func.CallableFunction)
182
+ _logger.debug(
183
+ f'scheduler {self.resource_pool}: '
184
+ f'start evaluating slot {request.fn_call.slot_idx}, batch_size={len(request.rows)}'
185
+ )
186
+ self.total_requests += 1
187
+ if request.is_batched:
188
+ batch_result = await pxt_fn.aexec_batch(*request.batch_args, **request.batch_kwargs)
189
+ assert len(batch_result) == len(request.rows)
190
+ for row, result in zip(request.rows, batch_result):
191
+ row[request.fn_call.slot_idx] = result
192
+ else:
193
+ request_kwargs = {**request.kwargs, '_runtime_ctx': env.RuntimeCtx(is_retry=num_retries > 0)}
194
+ result = await pxt_fn.aexec(*request.args, **request_kwargs)
195
+ request.row[request.fn_call.slot_idx] = result
196
+ end_ts = datetime.datetime.now(tz=datetime.timezone.utc)
197
+ _logger.debug(
198
+ f'scheduler {self.resource_pool}: evaluated slot {request.fn_call.slot_idx} '
199
+ f'in {end_ts - start_ts}, batch_size={len(request.rows)}'
200
+ )
201
+
202
+ # purge accumulated usage estimate, now that we have a new report
203
+ self.est_usage = dict.fromkeys(self._resources, 0)
204
+
205
+ self.dispatcher.dispatch(request.rows, exec_ctx)
206
+ except Exception as exc:
207
+ _logger.exception(f'scheduler {self.resource_pool}: exception in slot {request.fn_call.slot_idx}: {exc}')
208
+ if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
209
+ _logger.debug(f'scheduler {self.resource_pool}: exception headers: {exc.response.headers}')
210
+
211
+ # If pool info is available, attempt to retry based on the resource information
212
+ # Pool info may not be available yet if the exception occurred before the UDF set it
213
+ if self.pool_info is not None:
214
+ self.pool_info.record_exc(start_ts, exc)
215
+
216
+ if num_retries < self.MAX_RETRIES:
217
+ retry_delay = self.pool_info.get_retry_delay(exc, num_retries)
218
+ if retry_delay is None:
219
+ # The resource pool did not recognize it as a retriable error. Try our generic best-effort logic
220
+ # before giving up.
221
+ is_retriable, retry_delay = is_retriable_error(exc)
222
+ if is_retriable:
223
+ retry_delay = retry_delay or exponential_backoff(num_retries)
224
+ if retry_delay is not None:
225
+ self.total_retried += 1
226
+ _logger.debug(
227
+ f'scheduler {self.resource_pool}: sleeping {retry_delay:.2f}s before retrying'
228
+ f' attempt {num_retries} based on the information in the error'
229
+ )
230
+ await asyncio.sleep(retry_delay)
231
+ self.queue.put_nowait(self.QueueItem(request, num_retries + 1, exec_ctx))
232
+ return
233
+
234
+ # record the exception
235
+ _, _, exc_tb = sys.exc_info()
236
+ for row in request.rows:
237
+ row.set_exc(request.fn_call.slot_idx, exc)
238
+ self.dispatcher.dispatch_exc(request.rows, request.fn_call.slot_idx, exc_tb, exec_ctx)
239
+ finally:
240
+ _logger.debug(f'Scheduler stats: #requests={self.total_requests}, #retried={self.total_retried}')
241
+ if is_task:
242
+ self.num_in_flight -= 1
243
+ self.request_completed.set()
244
+
245
+
246
+ class RequestRateScheduler(Scheduler):
247
+ """
248
+ Scheduler for FunctionCalls with a fixed request rate limit and no runtime resource usage reports.
249
+
250
+ Rate limits are supplied in the config, in one of two ways:
251
+ - resource_pool='request-rate:<endpoint>':
252
+ * a single rate limit for all calls against that endpoint
253
+ * in the config: section '<endpoint>', key 'rate_limit'
254
+ - resource_pool='request-rate:<endpoint>:<model>':
255
+ * a single rate limit for all calls against that model
256
+ * in the config: section '<endpoint>.rate_limits', key '<model>'
257
+ - if no rate limit is found in the config, uses a default of 600 RPM
258
+
259
+ TODO:
260
+ - adaptive rate limiting based on 429 errors
261
+ """
262
+
263
+ secs_per_request: float # inverted rate limit
264
+ num_in_flight: int
265
+ total_requests: int
266
+ total_retried: int
267
+ total_errors: int
268
+
269
+ TIME_FORMAT = '%H:%M.%S %f'
270
+ MAX_RETRIES = 3
271
+ DEFAULT_RATE_LIMIT = 600 # requests per minute
272
+
273
+ # Exponential backoff defaults
274
+ BASE_RETRY_DELAY = 1.0 # in seconds
275
+ MAX_RETRY_DELAY = 60.0 # in seconds
276
+
277
+ def __init__(self, resource_pool: str, dispatcher: Dispatcher):
278
+ super().__init__(resource_pool, dispatcher)
279
+ loop_task = asyncio.create_task(self._main_loop())
280
+ self.dispatcher.register_task(loop_task)
281
+ self.num_in_flight = 0
282
+ self.total_requests = 0
283
+ self.total_retried = 0
284
+ self.total_errors = 0
285
+
286
+ # try to get the rate limit from the config
287
+ elems = resource_pool.split(':')
288
+ section: str
289
+ key: str
290
+ if len(elems) == 2:
291
+ # resource_pool: request-rate:endpoint
292
+ _, endpoint = elems
293
+ section = endpoint
294
+ key = 'rate_limit'
295
+ else:
296
+ # resource_pool: request-rate:endpoint:model
297
+ assert len(elems) == 3
298
+ _, endpoint, model = elems
299
+ section = f'{endpoint}.rate_limits'
300
+ key = model
301
+ requests_per_min = Config.get().get_int_value(key, section=section)
302
+ requests_per_min = requests_per_min or self.DEFAULT_RATE_LIMIT
303
+ _logger.debug(f'rate limit for {self.resource_pool}: {requests_per_min} RPM')
304
+ self.secs_per_request = 1 / (requests_per_min / 60)
305
+
306
+ @classmethod
307
+ def matches(cls, resource_pool: str) -> bool:
308
+ return resource_pool.startswith('request-rate:')
309
+
310
+ async def _main_loop(self) -> None:
311
+ last_request_ts = 0.0
312
+ while True:
313
+ item = await self.queue.get()
314
+ if item.num_retries > 0:
315
+ self.total_retried += 1
316
+ now = time.monotonic()
317
+ wait_duration = 0.0
318
+ if item.retry_after is not None:
319
+ wait_duration = item.retry_after - now
320
+ if now - last_request_ts < self.secs_per_request:
321
+ wait_duration = max(wait_duration, self.secs_per_request - (now - last_request_ts))
322
+ if wait_duration > 0:
323
+ _logger.debug(f'waiting for {wait_duration} for {self.resource_pool}')
324
+ await asyncio.sleep(wait_duration)
325
+
326
+ last_request_ts = time.monotonic()
327
+ if item.num_retries > 0:
328
+ # the last request encountered some problem: retry it synchronously, to wait for the problem to pass
329
+ _logger.debug(f'retrying request for {self.resource_pool}: #retries={item.num_retries}')
330
+ await self._exec(item.request, item.exec_ctx, item.num_retries, is_task=False)
331
+ _logger.debug(f'retried request for {self.resource_pool}: #retries={item.num_retries}')
332
+ else:
333
+ _logger.debug(f'creating task for {self.resource_pool}')
334
+ self.num_in_flight += 1
335
+ task = asyncio.create_task(self._exec(item.request, item.exec_ctx, item.num_retries, is_task=True))
336
+ self.dispatcher.register_task(task)
337
+
338
+ async def _exec(self, request: FnCallArgs, exec_ctx: ExecCtx, num_retries: int, is_task: bool) -> None:
339
+ assert all(not row.has_val[request.fn_call.slot_idx] for row in request.rows)
340
+ assert all(not row.has_exc(request.fn_call.slot_idx) for row in request.rows)
341
+
342
+ try:
343
+ start_ts = datetime.datetime.now(tz=datetime.timezone.utc)
344
+ pxt_fn = request.fn_call.fn
345
+ assert isinstance(pxt_fn, func.CallableFunction)
346
+ _logger.debug(
347
+ f'scheduler {self.resource_pool}: '
348
+ f'start evaluating slot {request.fn_call.slot_idx}, batch_size={len(request.rows)}'
349
+ )
350
+ self.total_requests += 1
351
+ if request.is_batched:
352
+ batch_result = await pxt_fn.aexec_batch(*request.batch_args, **request.batch_kwargs)
353
+ assert len(batch_result) == len(request.rows)
354
+ for row, result in zip(request.rows, batch_result):
355
+ row[request.fn_call.slot_idx] = result
356
+ else:
357
+ result = await pxt_fn.aexec(*request.args, **request.kwargs)
358
+ request.row[request.fn_call.slot_idx] = result
359
+ end_ts = datetime.datetime.now(tz=datetime.timezone.utc)
360
+ _logger.debug(
361
+ f'scheduler {self.resource_pool}: evaluated slot {request.fn_call.slot_idx} '
362
+ f'in {end_ts - start_ts}, batch_size={len(request.rows)}'
363
+ )
364
+ self.dispatcher.dispatch(request.rows, exec_ctx)
365
+
366
+ except Exception as exc:
367
+ _logger.exception(f'exception for {self.resource_pool}: type={type(exc)}\n{exc}')
368
+ if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
369
+ _logger.debug(f'scheduler {self.resource_pool}: exception headers: {exc.response.headers}')
370
+ is_retriable, retry_after = is_retriable_error(exc)
371
+ if is_retriable and num_retries < self.MAX_RETRIES:
372
+ retry_delay = self._compute_retry_delay(num_retries, retry_after)
373
+ _logger.debug(f'scheduler {self.resource_pool}: retrying after {retry_delay}')
374
+ now = time.monotonic()
375
+ # put the request back in the queue right away, which prevents new requests from being generated until
376
+ # this one succeeds or exceeds its retry limit
377
+ self.queue.put_nowait(self.QueueItem(request, num_retries + 1, exec_ctx, retry_after=now + retry_delay))
378
+ return
379
+
380
+ # record the exception
381
+ self.total_errors += 1
382
+ _, _, exc_tb = sys.exc_info()
383
+ for row in request.rows:
384
+ row.set_exc(request.fn_call.slot_idx, exc)
385
+ self.dispatcher.dispatch_exc(request.rows, request.fn_call.slot_idx, exc_tb, exec_ctx)
386
+ finally:
387
+ _logger.debug(
388
+ f'Scheduler stats: #in-flight={self.num_in_flight} #requests={self.total_requests}, '
389
+ f'#retried={self.total_retried} #errors={self.total_errors}'
390
+ )
391
+ if is_task:
392
+ self.num_in_flight -= 1
393
+
394
+ def _compute_retry_delay(self, num_retries: int, retry_after: float | None = None) -> float:
395
+ """
396
+ Calculate exponential backoff delay for rate limit errors.
397
+
398
+ Args:
399
+ retry_count: Number of retries attempted (0-based)
400
+ retry_after: Suggested delay from Retry-After header
401
+
402
+ Returns:
403
+ Delay in seconds
404
+ """
405
+ if retry_after is not None and retry_after > 0:
406
+ # Use server-suggested delay, but cap it at max_delay
407
+ return max(min(retry_after, self.MAX_RETRY_DELAY), self.BASE_RETRY_DELAY)
408
+ else:
409
+ return exponential_backoff(num_retries, max_delay=self.MAX_RETRY_DELAY)
410
+
411
+
412
+ # all concrete Scheduler subclasses that implement matches()
413
+ SCHEDULERS = [RateLimitsScheduler, RequestRateScheduler]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+
5
+ from pixeltable.exprs import ArrayMd, BinaryMd
6
+ from pixeltable.utils.misc import non_none_dict_factory
7
+
8
+ INLINED_OBJECT_MD_KEY = '__pxtinlinedobjmd__'
9
+
10
+
11
+ @dataclasses.dataclass
12
+ class InlinedObjectMd:
13
+ type: str # corresponds to ts.ColumnType.Type
14
+ url_idx: int
15
+ img_start: int | None = None
16
+ img_end: int | None = None
17
+ array_md: ArrayMd | None = None
18
+ binary_md: BinaryMd | None = None
19
+
20
+ @classmethod
21
+ def from_dict(cls, d: dict) -> InlinedObjectMd:
22
+ d = d.copy()
23
+ if 'array_md' in d:
24
+ d['array_md'] = ArrayMd(**d['array_md'])
25
+ if 'binary_md' in d:
26
+ d['binary_md'] = BinaryMd(**d['binary_md'])
27
+ return cls(**d)
28
+
29
+ def as_dict(self) -> dict:
30
+ result = dataclasses.asdict(self, dict_factory=non_none_dict_factory)
31
+ if self.array_md is not None:
32
+ result['array_md'] = self.array_md.as_dict()
33
+ if self.binary_md is not None:
34
+ result['binary_md'] = dataclasses.asdict(self.binary_md)
35
+ return result
@@ -1,15 +1,15 @@
1
1
  import logging
2
- from typing import Any, Iterator, Optional
2
+ from typing import Any, AsyncIterator
3
3
 
4
- import pixeltable.catalog as catalog
5
- import pixeltable.exprs as exprs
6
- from pixeltable.utils.media_store import MediaStore
4
+ from pixeltable import catalog, exprs
5
+ from pixeltable.utils.local_store import TempStore
7
6
 
8
7
  from .data_row_batch import DataRowBatch
9
8
  from .exec_node import ExecNode
10
9
 
11
10
  _logger = logging.getLogger('pixeltable')
12
11
 
12
+
13
13
  class InMemoryDataNode(ExecNode):
14
14
  """
15
15
  Outputs in-memory data as a DataRowBatch of a particular table.
@@ -18,64 +18,72 @@ class InMemoryDataNode(ExecNode):
18
18
  - with the values provided in the input rows
19
19
  - if an input row doesn't provide a value, sets the slot to the column default
20
20
  """
21
- tbl: catalog.TableVersion
21
+
22
+ tbl: catalog.TableVersionHandle
23
+
22
24
  input_rows: list[dict[str, Any]]
23
25
  start_row_id: int
24
- output_rows: Optional[DataRowBatch]
26
+ output_batch: DataRowBatch | None
25
27
 
26
28
  # output_exprs is declared in the superclass, but we redeclare it here with a more specific type
27
29
  output_exprs: list[exprs.ColumnRef]
28
30
 
29
31
  def __init__(
30
- self, tbl: catalog.TableVersion, rows: list[dict[str, Any]],
31
- row_builder: exprs.RowBuilder, start_row_id: int,
32
+ self,
33
+ tbl: catalog.TableVersionHandle,
34
+ rows: list[dict[str, Any]],
35
+ row_builder: exprs.RowBuilder,
36
+ start_row_id: int,
32
37
  ):
33
38
  # we materialize the input slots
34
39
  output_exprs = list(row_builder.input_exprs)
35
40
  super().__init__(row_builder, output_exprs, [], None)
36
- assert tbl.is_insertable()
41
+ assert tbl.get().is_insertable
37
42
  self.tbl = tbl
38
43
  self.input_rows = rows
39
44
  self.start_row_id = start_row_id
40
- self.output_rows = None
45
+ self.output_batch = None
41
46
 
42
47
  def _open(self) -> None:
43
48
  """Create row batch and populate with self.input_rows"""
44
49
  user_cols_by_name = {
45
50
  col_ref.col.name: exprs.ColumnSlotIdx(col_ref.col, col_ref.slot_idx)
46
- for col_ref in self.output_exprs if col_ref.col.name is not None
51
+ for col_ref in self.output_exprs
52
+ if col_ref.col.name is not None
47
53
  }
48
54
  output_cols_by_idx = {
49
- col_ref.slot_idx: exprs.ColumnSlotIdx(col_ref.col, col_ref.slot_idx)
50
- for col_ref in self.output_exprs
55
+ col_ref.slot_idx: exprs.ColumnSlotIdx(col_ref.col, col_ref.slot_idx) for col_ref in self.output_exprs
51
56
  }
52
57
  output_slot_idxs = {e.slot_idx for e in self.output_exprs}
53
58
 
54
- self.output_rows = DataRowBatch(self.tbl, self.row_builder, len(self.input_rows))
55
- for row_idx, input_row in enumerate(self.input_rows):
59
+ self.output_batch = DataRowBatch(self.row_builder)
60
+ for input_row in self.input_rows:
61
+ output_row = self.row_builder.make_row()
56
62
  # populate the output row with the values provided in the input row
57
63
  input_slot_idxs: set[int] = set()
58
64
  for col_name, val in input_row.items():
59
65
  col_info = user_cols_by_name.get(col_name)
60
66
  assert col_info is not None
67
+ col = col_info.col
68
+ if col.col_type.is_image_type() and isinstance(val, bytes):
69
+ # this is a literal media file, ie, a sequence of bytes; save it as a binary file and store the path
70
+ filepath, _ = TempStore.save_media_object(val, col, format=None)
71
+ output_row[col_info.slot_idx] = str(filepath)
72
+ else:
73
+ output_row[col_info.slot_idx] = val
61
74
 
62
- if col_info.col.col_type.is_image_type() and isinstance(val, bytes):
63
- # this is a literal image, ie, a sequence of bytes; we save this as a media file and store the path
64
- path = str(MediaStore.prepare_media_path(self.tbl.id, col_info.col.id, self.tbl.version))
65
- open(path, 'wb').write(val)
66
- val = path
67
- self.output_rows[row_idx][col_info.slot_idx] = val
68
75
  input_slot_idxs.add(col_info.slot_idx)
69
76
 
70
77
  # set the remaining output slots to their default values (presently None)
71
- missing_slot_idxs = output_slot_idxs - input_slot_idxs
78
+ missing_slot_idxs = output_slot_idxs - input_slot_idxs
72
79
  for slot_idx in missing_slot_idxs:
73
80
  col_info = output_cols_by_idx.get(slot_idx)
74
81
  assert col_info is not None
75
- self.output_rows[row_idx][col_info.slot_idx] = None
82
+ output_row[col_info.slot_idx] = None
83
+ self.output_batch.add_row(output_row)
76
84
 
77
- self.ctx.num_rows = len(self.output_rows)
85
+ self.ctx.num_rows = len(self.output_batch)
78
86
 
79
- def __iter__(self) -> Iterator[DataRowBatch]:
80
- _logger.debug(f'InMemoryDataNode: created row batch with {len(self.output_rows)} output_rows')
81
- yield self.output_rows
87
+ async def __aiter__(self) -> AsyncIterator[DataRowBatch]:
88
+ _logger.debug(f'InMemoryDataNode: created row batch with {len(self.output_batch)} rows')
89
+ yield self.output_batch