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.
- pixeltable/__init__.py +83 -19
- pixeltable/_query.py +1444 -0
- pixeltable/_version.py +1 -0
- pixeltable/catalog/__init__.py +7 -4
- pixeltable/catalog/catalog.py +2394 -119
- pixeltable/catalog/column.py +225 -104
- pixeltable/catalog/dir.py +38 -9
- pixeltable/catalog/globals.py +53 -34
- pixeltable/catalog/insertable_table.py +265 -115
- pixeltable/catalog/path.py +80 -17
- pixeltable/catalog/schema_object.py +28 -43
- pixeltable/catalog/table.py +1270 -677
- pixeltable/catalog/table_metadata.py +103 -0
- pixeltable/catalog/table_version.py +1270 -751
- pixeltable/catalog/table_version_handle.py +109 -0
- pixeltable/catalog/table_version_path.py +137 -42
- pixeltable/catalog/tbl_ops.py +53 -0
- pixeltable/catalog/update_status.py +191 -0
- pixeltable/catalog/view.py +251 -134
- pixeltable/config.py +215 -0
- pixeltable/env.py +736 -285
- pixeltable/exceptions.py +26 -2
- pixeltable/exec/__init__.py +7 -2
- pixeltable/exec/aggregation_node.py +39 -21
- pixeltable/exec/cache_prefetch_node.py +87 -109
- pixeltable/exec/cell_materialization_node.py +268 -0
- pixeltable/exec/cell_reconstruction_node.py +168 -0
- pixeltable/exec/component_iteration_node.py +25 -28
- pixeltable/exec/data_row_batch.py +11 -46
- pixeltable/exec/exec_context.py +26 -11
- pixeltable/exec/exec_node.py +35 -27
- pixeltable/exec/expr_eval/__init__.py +3 -0
- pixeltable/exec/expr_eval/evaluators.py +365 -0
- pixeltable/exec/expr_eval/expr_eval_node.py +413 -0
- pixeltable/exec/expr_eval/globals.py +200 -0
- pixeltable/exec/expr_eval/row_buffer.py +74 -0
- pixeltable/exec/expr_eval/schedulers.py +413 -0
- pixeltable/exec/globals.py +35 -0
- pixeltable/exec/in_memory_data_node.py +35 -27
- pixeltable/exec/object_store_save_node.py +293 -0
- pixeltable/exec/row_update_node.py +44 -29
- pixeltable/exec/sql_node.py +414 -115
- pixeltable/exprs/__init__.py +8 -5
- pixeltable/exprs/arithmetic_expr.py +79 -45
- pixeltable/exprs/array_slice.py +5 -5
- pixeltable/exprs/column_property_ref.py +40 -26
- pixeltable/exprs/column_ref.py +254 -61
- pixeltable/exprs/comparison.py +14 -9
- pixeltable/exprs/compound_predicate.py +9 -10
- pixeltable/exprs/data_row.py +213 -72
- pixeltable/exprs/expr.py +270 -104
- pixeltable/exprs/expr_dict.py +6 -5
- pixeltable/exprs/expr_set.py +20 -11
- pixeltable/exprs/function_call.py +383 -284
- pixeltable/exprs/globals.py +18 -5
- pixeltable/exprs/in_predicate.py +7 -7
- pixeltable/exprs/inline_expr.py +37 -37
- pixeltable/exprs/is_null.py +8 -4
- pixeltable/exprs/json_mapper.py +120 -54
- pixeltable/exprs/json_path.py +90 -60
- pixeltable/exprs/literal.py +61 -16
- pixeltable/exprs/method_ref.py +7 -6
- pixeltable/exprs/object_ref.py +19 -8
- pixeltable/exprs/row_builder.py +238 -75
- pixeltable/exprs/rowid_ref.py +53 -15
- pixeltable/exprs/similarity_expr.py +65 -50
- pixeltable/exprs/sql_element_cache.py +5 -5
- pixeltable/exprs/string_op.py +107 -0
- pixeltable/exprs/type_cast.py +25 -13
- pixeltable/exprs/variable.py +2 -2
- pixeltable/func/__init__.py +9 -5
- pixeltable/func/aggregate_function.py +197 -92
- pixeltable/func/callable_function.py +119 -35
- pixeltable/func/expr_template_function.py +101 -48
- pixeltable/func/function.py +375 -62
- pixeltable/func/function_registry.py +20 -19
- pixeltable/func/globals.py +6 -5
- pixeltable/func/mcp.py +74 -0
- pixeltable/func/query_template_function.py +151 -35
- pixeltable/func/signature.py +178 -49
- pixeltable/func/tools.py +164 -0
- pixeltable/func/udf.py +176 -53
- pixeltable/functions/__init__.py +44 -4
- pixeltable/functions/anthropic.py +226 -47
- pixeltable/functions/audio.py +148 -11
- pixeltable/functions/bedrock.py +137 -0
- pixeltable/functions/date.py +188 -0
- pixeltable/functions/deepseek.py +113 -0
- pixeltable/functions/document.py +81 -0
- pixeltable/functions/fal.py +76 -0
- pixeltable/functions/fireworks.py +72 -20
- pixeltable/functions/gemini.py +249 -0
- pixeltable/functions/globals.py +208 -53
- pixeltable/functions/groq.py +108 -0
- pixeltable/functions/huggingface.py +1088 -95
- pixeltable/functions/image.py +155 -84
- pixeltable/functions/json.py +8 -11
- pixeltable/functions/llama_cpp.py +31 -19
- pixeltable/functions/math.py +169 -0
- pixeltable/functions/mistralai.py +50 -75
- pixeltable/functions/net.py +70 -0
- pixeltable/functions/ollama.py +29 -36
- pixeltable/functions/openai.py +548 -160
- pixeltable/functions/openrouter.py +143 -0
- pixeltable/functions/replicate.py +15 -14
- pixeltable/functions/reve.py +250 -0
- pixeltable/functions/string.py +310 -85
- pixeltable/functions/timestamp.py +37 -19
- pixeltable/functions/together.py +77 -120
- pixeltable/functions/twelvelabs.py +188 -0
- pixeltable/functions/util.py +7 -2
- pixeltable/functions/uuid.py +30 -0
- pixeltable/functions/video.py +1528 -117
- pixeltable/functions/vision.py +26 -26
- pixeltable/functions/voyageai.py +289 -0
- pixeltable/functions/whisper.py +19 -10
- pixeltable/functions/whisperx.py +179 -0
- pixeltable/functions/yolox.py +112 -0
- pixeltable/globals.py +716 -236
- pixeltable/index/__init__.py +3 -1
- pixeltable/index/base.py +17 -21
- pixeltable/index/btree.py +32 -22
- pixeltable/index/embedding_index.py +155 -92
- pixeltable/io/__init__.py +12 -7
- pixeltable/io/datarows.py +140 -0
- pixeltable/io/external_store.py +83 -125
- pixeltable/io/fiftyone.py +24 -33
- pixeltable/io/globals.py +47 -182
- pixeltable/io/hf_datasets.py +96 -127
- pixeltable/io/label_studio.py +171 -156
- pixeltable/io/lancedb.py +3 -0
- pixeltable/io/pandas.py +136 -115
- pixeltable/io/parquet.py +40 -153
- pixeltable/io/table_data_conduit.py +702 -0
- pixeltable/io/utils.py +100 -0
- pixeltable/iterators/__init__.py +8 -4
- pixeltable/iterators/audio.py +207 -0
- pixeltable/iterators/base.py +9 -3
- pixeltable/iterators/document.py +144 -87
- pixeltable/iterators/image.py +17 -38
- pixeltable/iterators/string.py +15 -12
- pixeltable/iterators/video.py +523 -127
- pixeltable/metadata/__init__.py +33 -8
- pixeltable/metadata/converters/convert_10.py +2 -3
- pixeltable/metadata/converters/convert_13.py +2 -2
- pixeltable/metadata/converters/convert_15.py +15 -11
- pixeltable/metadata/converters/convert_16.py +4 -5
- pixeltable/metadata/converters/convert_17.py +4 -5
- pixeltable/metadata/converters/convert_18.py +4 -6
- pixeltable/metadata/converters/convert_19.py +6 -9
- pixeltable/metadata/converters/convert_20.py +3 -6
- pixeltable/metadata/converters/convert_21.py +6 -8
- pixeltable/metadata/converters/convert_22.py +3 -2
- pixeltable/metadata/converters/convert_23.py +33 -0
- pixeltable/metadata/converters/convert_24.py +55 -0
- pixeltable/metadata/converters/convert_25.py +19 -0
- pixeltable/metadata/converters/convert_26.py +23 -0
- pixeltable/metadata/converters/convert_27.py +29 -0
- pixeltable/metadata/converters/convert_28.py +13 -0
- pixeltable/metadata/converters/convert_29.py +110 -0
- pixeltable/metadata/converters/convert_30.py +63 -0
- pixeltable/metadata/converters/convert_31.py +11 -0
- pixeltable/metadata/converters/convert_32.py +15 -0
- pixeltable/metadata/converters/convert_33.py +17 -0
- pixeltable/metadata/converters/convert_34.py +21 -0
- pixeltable/metadata/converters/convert_35.py +9 -0
- pixeltable/metadata/converters/convert_36.py +38 -0
- pixeltable/metadata/converters/convert_37.py +15 -0
- pixeltable/metadata/converters/convert_38.py +39 -0
- pixeltable/metadata/converters/convert_39.py +124 -0
- pixeltable/metadata/converters/convert_40.py +73 -0
- pixeltable/metadata/converters/convert_41.py +12 -0
- pixeltable/metadata/converters/convert_42.py +9 -0
- pixeltable/metadata/converters/convert_43.py +44 -0
- pixeltable/metadata/converters/util.py +44 -18
- pixeltable/metadata/notes.py +21 -0
- pixeltable/metadata/schema.py +185 -42
- pixeltable/metadata/utils.py +74 -0
- pixeltable/mypy/__init__.py +3 -0
- pixeltable/mypy/mypy_plugin.py +123 -0
- pixeltable/plan.py +616 -225
- pixeltable/share/__init__.py +3 -0
- pixeltable/share/packager.py +797 -0
- pixeltable/share/protocol/__init__.py +33 -0
- pixeltable/share/protocol/common.py +165 -0
- pixeltable/share/protocol/operation_types.py +33 -0
- pixeltable/share/protocol/replica.py +119 -0
- pixeltable/share/publish.py +349 -0
- pixeltable/store.py +398 -232
- pixeltable/type_system.py +730 -267
- pixeltable/utils/__init__.py +40 -0
- pixeltable/utils/arrow.py +201 -29
- pixeltable/utils/av.py +298 -0
- pixeltable/utils/azure_store.py +346 -0
- pixeltable/utils/coco.py +26 -27
- pixeltable/utils/code.py +4 -4
- pixeltable/utils/console_output.py +46 -0
- pixeltable/utils/coroutine.py +24 -0
- pixeltable/utils/dbms.py +92 -0
- pixeltable/utils/description_helper.py +11 -12
- pixeltable/utils/documents.py +60 -61
- pixeltable/utils/exception_handler.py +36 -0
- pixeltable/utils/filecache.py +38 -22
- pixeltable/utils/formatter.py +88 -51
- pixeltable/utils/gcs_store.py +295 -0
- pixeltable/utils/http.py +133 -0
- pixeltable/utils/http_server.py +14 -13
- pixeltable/utils/iceberg.py +13 -0
- pixeltable/utils/image.py +17 -0
- pixeltable/utils/lancedb.py +90 -0
- pixeltable/utils/local_store.py +322 -0
- pixeltable/utils/misc.py +5 -0
- pixeltable/utils/object_stores.py +573 -0
- pixeltable/utils/pydantic.py +60 -0
- pixeltable/utils/pytorch.py +20 -20
- pixeltable/utils/s3_store.py +527 -0
- pixeltable/utils/sql.py +32 -5
- pixeltable/utils/system.py +30 -0
- pixeltable/utils/transactional_directory.py +4 -3
- pixeltable-0.5.7.dist-info/METADATA +579 -0
- pixeltable-0.5.7.dist-info/RECORD +227 -0
- {pixeltable-0.2.26.dist-info → pixeltable-0.5.7.dist-info}/WHEEL +1 -1
- pixeltable-0.5.7.dist-info/entry_points.txt +2 -0
- pixeltable/__version__.py +0 -3
- pixeltable/catalog/named_function.py +0 -36
- pixeltable/catalog/path_dict.py +0 -141
- pixeltable/dataframe.py +0 -894
- pixeltable/exec/expr_eval_node.py +0 -232
- pixeltable/ext/__init__.py +0 -14
- pixeltable/ext/functions/__init__.py +0 -8
- pixeltable/ext/functions/whisperx.py +0 -77
- pixeltable/ext/functions/yolox.py +0 -157
- pixeltable/tool/create_test_db_dump.py +0 -311
- pixeltable/tool/create_test_video.py +0 -81
- pixeltable/tool/doc_plugins/griffe.py +0 -50
- pixeltable/tool/doc_plugins/mkdocstrings.py +0 -6
- pixeltable/tool/doc_plugins/templates/material/udf.html.jinja +0 -135
- pixeltable/tool/embed_udf.py +0 -9
- pixeltable/tool/mypy_plugin.py +0 -55
- pixeltable/utils/media_store.py +0 -76
- pixeltable/utils/s3.py +0 -16
- pixeltable-0.2.26.dist-info/METADATA +0 -400
- pixeltable-0.2.26.dist-info/RECORD +0 -156
- pixeltable-0.2.26.dist-info/entry_points.txt +0 -3
- {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,
|
|
2
|
+
from typing import Any, AsyncIterator
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
21
|
+
|
|
22
|
+
tbl: catalog.TableVersionHandle
|
|
23
|
+
|
|
22
24
|
input_rows: list[dict[str, Any]]
|
|
23
25
|
start_row_id: int
|
|
24
|
-
|
|
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,
|
|
31
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
55
|
-
for
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
85
|
+
self.ctx.num_rows = len(self.output_batch)
|
|
78
86
|
|
|
79
|
-
def
|
|
80
|
-
_logger.debug(f'InMemoryDataNode: created row batch with {len(self.
|
|
81
|
-
yield self.
|
|
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
|