UncountablePythonSDK 0.0.190__py3-none-any.whl → 0.0.191__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 (23) hide show
  1. pkgs/type_spec/emit_typescript_util.py +25 -4
  2. pkgs/type_spec/parts/base.ts.prepart +6 -1
  3. uncountable/integration/cli.py +2 -0
  4. uncountable/integration/job.py +47 -30
  5. uncountable/integration/job_queue.py +14 -0
  6. uncountable/integration/ownership_lock.py +77 -0
  7. uncountable/integration/queue_runner/command_server/command_client.py +2 -0
  8. uncountable/integration/queue_runner/command_server/command_server.py +2 -0
  9. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +1 -0
  10. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +4 -4
  11. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +2 -0
  12. uncountable/integration/queue_runner/command_server/types.py +1 -0
  13. uncountable/integration/queue_runner/datastore/datastore_sql_base.py +14 -7
  14. uncountable/integration/queue_runner/datastore/interface.py +6 -1
  15. uncountable/integration/queue_runner/job_scheduler.py +66 -23
  16. uncountable/integration/queue_runner/worker.py +8 -0
  17. uncountable/integration/scheduler.py +86 -57
  18. uncountable/integration/webhook_server/entrypoint.py +17 -5
  19. uncountable/types/entity_t.py +2 -0
  20. {uncountablepythonsdk-0.0.190.dist-info → uncountablepythonsdk-0.0.191.dist-info}/METADATA +1 -1
  21. {uncountablepythonsdk-0.0.190.dist-info → uncountablepythonsdk-0.0.191.dist-info}/RECORD +23 -21
  22. {uncountablepythonsdk-0.0.190.dist-info → uncountablepythonsdk-0.0.191.dist-info}/WHEEL +0 -0
  23. {uncountablepythonsdk-0.0.190.dist-info → uncountablepythonsdk-0.0.191.dist-info}/top_level.txt +0 -0
@@ -175,6 +175,27 @@ def emit_constant_ts(ctx: EmitTypescriptContext, sconst: builder.SpecConstant) -
175
175
  ctx.out.write(f"export const {const_name} = {value}\n")
176
176
 
177
177
 
178
+ def _collect_object_properties(
179
+ stype: builder.SpecTypeDefnObject,
180
+ ) -> dict[str, builder.SpecProperty]:
181
+ base_properties = (
182
+ _collect_object_properties(stype.base) if stype.base is not None else {}
183
+ )
184
+ return {**base_properties, **(stype.properties or {})}
185
+
186
+
187
+ def _type_info_ref_type_ts(*, stype: builder.SpecTypeDefnObject, base_ref: str) -> str:
188
+ defaulted_names = sorted(
189
+ ts_property_name(prop)
190
+ for prop in _collect_object_properties(stype).values()
191
+ if prop.has_default and not prop.parse_require and prop.default is not None
192
+ )
193
+ if len(defaulted_names) == 0:
194
+ return f"{base_ref}.TypeInfoRef<{stype.name}>"
195
+ defaulted_keys = " | ".join(f'"{name}"' for name in defaulted_names)
196
+ return f"{base_ref}.TypeInfoRef<{stype.name}, {base_ref}.WithDefaultsApplied<{stype.name}, {defaulted_keys}>>"
197
+
198
+
178
199
  def emit_type_info_ref_ts(
179
200
  ctx: EmitTypescriptContext,
180
201
  stype: builder.SpecTypeDefn,
@@ -184,9 +205,9 @@ def emit_type_info_ref_ts(
184
205
  assert ctx.namespace.emit_type_info, (
185
206
  f"{ctx.namespace.name}: $emit_type_info_refs requires $emit_type_info"
186
207
  )
187
- assert (
188
- isinstance(stype, builder.SpecTypeDefnObject) and len(stype.parameters) == 0
189
- ), (
208
+ if not isinstance(stype, builder.SpecTypeDefnObject):
209
+ return
210
+ assert len(stype.parameters) == 0, (
190
211
  f"{ctx.namespace.name}.{stype.name}: type info refs support non-generic objects only"
191
212
  )
192
213
 
@@ -195,7 +216,7 @@ def emit_type_info_ref_ts(
195
216
  ctx.out.write("\n")
196
217
  ctx.out.write(MODIFY_NOTICE)
197
218
  ctx.out.write(
198
- f"export const {stype.name}TypeInfoRef: {base_ref}.TypeInfoRef<{stype.name}> = {{\n"
219
+ f"export const {stype.name}TypeInfoRef: {_type_info_ref_type_ts(stype=stype, base_ref=base_ref)} = {{\n"
199
220
  )
200
221
  ctx.out.write(f'{INDENT}typeName: "{ctx.namespace.name}.{stype.name}",\n')
201
222
  ctx.out.write("}\n")
@@ -40,11 +40,16 @@ declare const brand: unique symbol
40
40
  export type Branded<TBase, TBrand extends string> = TBase & { readonly [brand]: TBrand }
41
41
 
42
42
  declare const typeInfoValue: unique symbol
43
- export interface TypeInfoRef<T> {
43
+ declare const typeInfoParsedValue: unique symbol
44
+ export interface TypeInfoRef<T, TParsed extends T = T> {
44
45
  readonly typeName: string
45
46
  readonly [typeInfoValue]?: T
47
+ readonly [typeInfoParsedValue]?: TParsed
46
48
  }
47
49
 
50
+ export type WithDefaultsApplied<T, TDefaultedKey extends keyof T> = Omit<T, TDefaultedKey> &
51
+ Required<Pick<T, TDefaultedKey>>
52
+
48
53
  // Ids matching a strict integer number are converted to integers
49
54
  export const ID_REGEX = /^-?[1-9][0-9]{0,20}$/
50
55
  // Matches ISO 8601 dates. Stricter than `Date.parse`.
@@ -89,6 +89,8 @@ def register_cancel_queued_job_parser(
89
89
  print(
90
90
  "Job successfully cancelled. The integration server will restart."
91
91
  )
92
+ case CommandCancelJobStatus.CANCELLED_QUEUED:
93
+ print("Queued job successfully cancelled.")
92
94
  case CommandCancelJobStatus.NO_JOB_FOUND:
93
95
  print("Job not found.")
94
96
  case CommandCancelJobStatus.JOB_ALREADY_COMPLETED:
@@ -10,7 +10,6 @@ from pkgs.argument_parser import CachedParser
10
10
  from pkgs.serialization_util import serialize_for_api
11
11
  from uncountable.core.async_batch import AsyncBatchProcessor
12
12
  from uncountable.core.client import Client
13
- from uncountable.core.environment import get_local_admin_server_port
14
13
  from uncountable.core.file_upload import FileUpload
15
14
  from uncountable.core.types import AuthDetailsOAuth
16
15
  from uncountable.integration.http_server import (
@@ -18,12 +17,7 @@ from uncountable.integration.http_server import (
18
17
  GenericHttpResponse,
19
18
  HttpException,
20
19
  )
21
- from uncountable.integration.queue_runner.command_server.command_client import (
22
- send_job_queue_message,
23
- )
24
- from uncountable.integration.queue_runner.command_server.types import (
25
- CommandServerException,
26
- )
20
+ from uncountable.integration.job_queue import JobQueue
27
21
  from uncountable.integration.request_context import extract_request_context
28
22
  from uncountable.integration.secret_retrieval import retrieve_secret
29
23
  from uncountable.integration.telemetry import JobLogger
@@ -111,11 +105,7 @@ class _RequestValidatorClient(Client):
111
105
  return self._oauth_bearer_token
112
106
 
113
107
 
114
- class CustomHttpJob(Job[GenericHttpRequest]):
115
- @property
116
- def payload_type(self) -> type[GenericHttpRequest]:
117
- return GenericHttpRequest
118
-
108
+ class HttpJob(ABC):
119
109
  @staticmethod
120
110
  @abstractmethod
121
111
  def validate_request(
@@ -130,6 +120,22 @@ class CustomHttpJob(Job[GenericHttpRequest]):
130
120
  """
131
121
  ...
132
122
 
123
+ @abstractmethod
124
+ def respond_to_request(
125
+ self,
126
+ *,
127
+ request: GenericHttpRequest,
128
+ job_definition: HttpJobDefinitionBase,
129
+ profile_meta: ProfileMetadata,
130
+ job_queue: JobQueue,
131
+ ) -> GenericHttpResponse: ...
132
+
133
+
134
+ class CustomHttpJob(Job[GenericHttpRequest], HttpJob):
135
+ @property
136
+ def payload_type(self) -> type[GenericHttpRequest]:
137
+ return GenericHttpRequest
138
+
133
139
  @staticmethod
134
140
  def get_validated_oauth_request_user_id(
135
141
  *, profile_metadata: ProfileMetadata, request: GenericHttpRequest
@@ -162,6 +168,19 @@ class CustomHttpJob(Job[GenericHttpRequest]):
162
168
  """
163
169
  ...
164
170
 
171
+ @typing.override
172
+ def respond_to_request(
173
+ self,
174
+ *,
175
+ request: GenericHttpRequest,
176
+ job_definition: HttpJobDefinitionBase,
177
+ profile_meta: ProfileMetadata,
178
+ job_queue: JobQueue,
179
+ ) -> GenericHttpResponse:
180
+ return self.handle_request(
181
+ request=request, job_definition=job_definition, profile_meta=profile_meta
182
+ )
183
+
165
184
  def run_outer(self, args: JobArguments) -> JobResult:
166
185
  args.logger.log_warning(
167
186
  message=f"Unexpected call to run_outer for CustomHttpJob: {args.job_definition.id}"
@@ -189,7 +208,7 @@ def _resolve_webhook_signature_key(
189
208
  )
190
209
 
191
210
 
192
- class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
211
+ class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload], HttpJob):
193
212
  @property
194
213
  def payload_type(self) -> type[webhook_job_t.WebhookEventPayload]:
195
214
  return webhook_job_t.WebhookEventPayload
@@ -219,12 +238,14 @@ class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
219
238
  if not hmac.compare_digest(request_body_signature, passed_signature):
220
239
  raise HttpException.payload_failed_signature()
221
240
 
222
- @staticmethod
223
- def handle_request(
241
+ @typing.override
242
+ def respond_to_request(
243
+ self,
224
244
  *,
225
245
  request: GenericHttpRequest,
226
- job_definition: job_definition_t.HttpJobDefinitionBase,
227
- profile_meta: ProfileMetadata, # noqa: ARG004
246
+ job_definition: HttpJobDefinitionBase,
247
+ profile_meta: ProfileMetadata,
248
+ job_queue: JobQueue,
228
249
  ) -> GenericHttpResponse:
229
250
  try:
230
251
  request_body = simplejson.loads(request.body_text)
@@ -234,19 +255,15 @@ class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
234
255
 
235
256
  request_context = extract_request_context(headers=request.headers)
236
257
 
237
- try:
238
- send_job_queue_message(
239
- job_ref_name=job_definition.id,
240
- payload=queued_job_t.QueuedJobPayload(
241
- invocation_context=queued_job_t.InvocationContextWebhook(
242
- webhook_payload=webhook_payload,
243
- request_context=request_context,
244
- )
245
- ),
246
- port=get_local_admin_server_port(),
247
- )
248
- except CommandServerException as e:
249
- raise HttpException.unknown_error() from e
258
+ job_queue.enqueue(
259
+ job_ref_name=job_definition.id,
260
+ payload=queued_job_t.QueuedJobPayload(
261
+ invocation_context=queued_job_t.InvocationContextWebhook(
262
+ webhook_payload=webhook_payload,
263
+ request_context=request_context,
264
+ )
265
+ ),
266
+ )
250
267
 
251
268
  return GenericHttpResponse(
252
269
  response=simplejson.dumps(serialize_for_api(WebhookResponse())),
@@ -0,0 +1,14 @@
1
+ from uncountable.integration.queue_runner.datastore import Datastore
2
+ from uncountable.types import queued_job_t
3
+
4
+
5
+ class JobQueue:
6
+ def __init__(self, datastore: Datastore) -> None:
7
+ self._datastore = datastore
8
+
9
+ def enqueue(
10
+ self, *, job_ref_name: str, payload: queued_job_t.QueuedJobPayload
11
+ ) -> str:
12
+ return self._datastore.add_job_to_queue(
13
+ job_payload=payload, job_ref_name=job_ref_name
14
+ ).queued_job_uuid
@@ -0,0 +1,77 @@
1
+ import time
2
+ from collections.abc import Callable, Generator
3
+ from contextlib import contextmanager
4
+ from enum import StrEnum
5
+
6
+ from opentelemetry.trace import get_current_span
7
+ from sqlalchemy import Connection, text
8
+ from sqlalchemy.engine import Engine
9
+
10
+ from uncountable.integration.telemetry import Logger
11
+
12
+ OWNERSHIP_ADVISORY_LOCK_KEY = 0x756E635F696E7467
13
+ OWNERSHIP_LOCK_POLL_INTERVAL_SECS = 1
14
+
15
+
16
+ class OwnershipOutcome(StrEnum):
17
+ ACQUIRED = "acquired"
18
+ SHUTDOWN_REQUESTED = "shutdown_requested"
19
+
20
+
21
+ def _try_acquire_advisory_lock(connection: Connection) -> bool:
22
+ return bool(
23
+ connection.execute(
24
+ text("select pg_try_advisory_lock(:key)"),
25
+ {"key": OWNERSHIP_ADVISORY_LOCK_KEY},
26
+ ).scalar_one()
27
+ )
28
+
29
+
30
+ def _release_advisory_lock(connection: Connection) -> None:
31
+ connection.execute(
32
+ text("select pg_advisory_unlock(:key)"),
33
+ {"key": OWNERSHIP_ADVISORY_LOCK_KEY},
34
+ )
35
+
36
+
37
+ def _wait_for_advisory_lock(
38
+ *, connection: Connection, is_shutdown_requested: Callable[[], bool]
39
+ ) -> OwnershipOutcome:
40
+ logger = Logger(get_current_span())
41
+ if is_shutdown_requested():
42
+ return OwnershipOutcome.SHUTDOWN_REQUESTED
43
+ if _try_acquire_advisory_lock(connection):
44
+ return OwnershipOutcome.ACQUIRED
45
+ logger.log_info("waiting for the integration ownership lock held by another pod")
46
+ while not is_shutdown_requested():
47
+ time.sleep(OWNERSHIP_LOCK_POLL_INTERVAL_SECS)
48
+ if is_shutdown_requested():
49
+ return OwnershipOutcome.SHUTDOWN_REQUESTED
50
+ if _try_acquire_advisory_lock(connection):
51
+ logger.log_info("acquired the integration ownership lock")
52
+ return OwnershipOutcome.ACQUIRED
53
+ return OwnershipOutcome.SHUTDOWN_REQUESTED
54
+
55
+
56
+ @contextmanager
57
+ def hold_integration_ownership(
58
+ *, engine: Engine, is_shutdown_requested: Callable[[], bool]
59
+ ) -> Generator[OwnershipOutcome]:
60
+ dialect_name = engine.dialect.name
61
+ match dialect_name:
62
+ case "sqlite":
63
+ yield OwnershipOutcome.ACQUIRED
64
+ case "postgresql":
65
+ with engine.connect().execution_options(
66
+ isolation_level="AUTOCOMMIT"
67
+ ) as connection:
68
+ outcome = _wait_for_advisory_lock(
69
+ connection=connection, is_shutdown_requested=is_shutdown_requested
70
+ )
71
+ try:
72
+ yield outcome
73
+ finally:
74
+ if outcome == OwnershipOutcome.ACQUIRED:
75
+ _release_advisory_lock(connection)
76
+ case _:
77
+ raise ValueError(f"unsupported database dialect: {dialect_name!r}")
@@ -85,6 +85,8 @@ def send_job_cancellation_message(
85
85
  return CommandCancelJobStatus.NO_JOB_FOUND
86
86
  case CancelJobStatus.CANCELLED_WITH_RESTART:
87
87
  return CommandCancelJobStatus.CANCELLED_WITH_RESTART
88
+ case CancelJobStatus.CANCELLED_QUEUED:
89
+ return CommandCancelJobStatus.CANCELLED_QUEUED
88
90
  case CancelJobStatus.JOB_ALREADY_COMPLETED:
89
91
  return CommandCancelJobStatus.JOB_ALREADY_COMPLETED
90
92
  case _:
@@ -94,6 +94,8 @@ async def serve(command_queue: CommandQueue, datastore: Datastore) -> None:
94
94
  proto_status = CancelJobStatus.NO_JOB_FOUND
95
95
  case CommandCancelJobStatus.CANCELLED_WITH_RESTART:
96
96
  proto_status = CancelJobStatus.CANCELLED_WITH_RESTART
97
+ case CommandCancelJobStatus.CANCELLED_QUEUED:
98
+ proto_status = CancelJobStatus.CANCELLED_QUEUED
97
99
  case CommandCancelJobStatus.JOB_ALREADY_COMPLETED:
98
100
  proto_status = CancelJobStatus.JOB_ALREADY_COMPLETED
99
101
  case _:
@@ -71,6 +71,7 @@ enum CancelJobStatus {
71
71
  CANCELLED_WITH_RESTART = 0;
72
72
  NO_JOB_FOUND = 1;
73
73
  JOB_ALREADY_COMPLETED = 2;
74
+ CANCELLED_QUEUED = 3;
74
75
  }
75
76
 
76
77
  message CancelJobResult {
@@ -29,7 +29,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
29
29
 
30
30
 
31
31
  DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
32
- b'\nQuncountable/integration/queue_runner/command_server/protocol/command_server.proto\x1a\x1fgoogle/protobuf/timestamp.proto"E\n\x11\x45nqueueJobRequest\x12\x14\n\x0cjob_ref_name\x18\x01 \x01(\t\x12\x1a\n\x12serialized_payload\x18\x02 \x01(\t"H\n\x10\x45nqueueJobResult\x12\x1b\n\x13successfully_queued\x18\x01 \x01(\x08\x12\x17\n\x0fqueued_job_uuid\x18\x02 \x01(\t"\x1f\n\x0fRetryJobRequest\x12\x0c\n\x04uuid\x18\x01 \x01(\t"F\n\x0eRetryJobResult\x12\x1b\n\x13successfully_queued\x18\x01 \x01(\x08\x12\x17\n\x0fqueued_job_uuid\x18\x02 \x01(\t"\x1a\n\x18VaccuumQueuedJobsRequest"\x19\n\x17VaccuumQueuedJobsResult"\x14\n\x12\x43heckHealthRequest"$\n\x11\x43heckHealthResult\x12\x0f\n\x07success\x18\x01 \x01(\x08"\xc2\x01\n\x15ListQueuedJobsRequest\x12\x0e\n\x06offset\x18\x01 \x01(\r\x12\r\n\x05limit\x18\x02 \x01(\r\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x14\n\x0cjob_ref_name\x18\x04 \x01(\t\x12\x32\n\x0esubmitted_from\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0csubmitted_to\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xf4\x01\n\x14ListQueuedJobsResult\x12\x43\n\x0bqueued_jobs\x18\x01 \x03(\x0b\x32..ListQueuedJobsResult.ListQueuedJobsResultItem\x1a\x96\x01\n\x18ListQueuedJobsResultItem\x12\x0c\n\x04uuid\x18\x01 \x01(\t\x12\x14\n\x0cjob_ref_name\x18\x02 \x01(\t\x12\x14\n\x0cnum_attempts\x18\x03 \x01(\x03\x12\x30\n\x0csubmitted_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06status\x18\x05 \x01(\t"$\n\x10\x43\x61ncelJobRequest\x12\x10\n\x08job_uuid\x18\x01 \x01(\t"3\n\x0f\x43\x61ncelJobResult\x12 \n\x06status\x18\x01 \x01(\x0e\x32\x10.CancelJobStatus")\n\x17ProvisionWebhookRequest\x12\x0e\n\x06job_id\x18\x01 \x01(\t".\n\x16ProvisionWebhookResult\x12\x14\n\x0cprofile_name\x18\x01 \x01(\t*Z\n\x0f\x43\x61ncelJobStatus\x12\x1a\n\x16\x43\x41NCELLED_WITH_RESTART\x10\x00\x12\x10\n\x0cNO_JOB_FOUND\x10\x01\x12\x19\n\x15JOB_ALREADY_COMPLETED\x10\x02\x32\xbd\x03\n\rCommandServer\x12\x35\n\nEnqueueJob\x12\x12.EnqueueJobRequest\x1a\x11.EnqueueJobResult"\x00\x12/\n\x08RetryJob\x12\x10.RetryJobRequest\x1a\x0f.RetryJobResult"\x00\x12\x38\n\x0b\x43heckHealth\x12\x13.CheckHealthRequest\x1a\x12.CheckHealthResult"\x00\x12\x41\n\x0eListQueuedJobs\x12\x16.ListQueuedJobsRequest\x1a\x15.ListQueuedJobsResult"\x00\x12J\n\x11VaccuumQueuedJobs\x12\x19.VaccuumQueuedJobsRequest\x1a\x18.VaccuumQueuedJobsResult"\x00\x12\x32\n\tCancelJob\x12\x11.CancelJobRequest\x1a\x10.CancelJobResult"\x00\x12G\n\x10ProvisionWebhook\x12\x18.ProvisionWebhookRequest\x1a\x17.ProvisionWebhookResult"\x00\x62\x06proto3'
32
+ b'\nQuncountable/integration/queue_runner/command_server/protocol/command_server.proto\x1a\x1fgoogle/protobuf/timestamp.proto"E\n\x11\x45nqueueJobRequest\x12\x14\n\x0cjob_ref_name\x18\x01 \x01(\t\x12\x1a\n\x12serialized_payload\x18\x02 \x01(\t"H\n\x10\x45nqueueJobResult\x12\x1b\n\x13successfully_queued\x18\x01 \x01(\x08\x12\x17\n\x0fqueued_job_uuid\x18\x02 \x01(\t"\x1f\n\x0fRetryJobRequest\x12\x0c\n\x04uuid\x18\x01 \x01(\t"F\n\x0eRetryJobResult\x12\x1b\n\x13successfully_queued\x18\x01 \x01(\x08\x12\x17\n\x0fqueued_job_uuid\x18\x02 \x01(\t"\x1a\n\x18VaccuumQueuedJobsRequest"\x19\n\x17VaccuumQueuedJobsResult"\x14\n\x12\x43heckHealthRequest"$\n\x11\x43heckHealthResult\x12\x0f\n\x07success\x18\x01 \x01(\x08"\xc2\x01\n\x15ListQueuedJobsRequest\x12\x0e\n\x06offset\x18\x01 \x01(\r\x12\r\n\x05limit\x18\x02 \x01(\r\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x14\n\x0cjob_ref_name\x18\x04 \x01(\t\x12\x32\n\x0esubmitted_from\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0csubmitted_to\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp"\xf4\x01\n\x14ListQueuedJobsResult\x12\x43\n\x0bqueued_jobs\x18\x01 \x03(\x0b\x32..ListQueuedJobsResult.ListQueuedJobsResultItem\x1a\x96\x01\n\x18ListQueuedJobsResultItem\x12\x0c\n\x04uuid\x18\x01 \x01(\t\x12\x14\n\x0cjob_ref_name\x18\x02 \x01(\t\x12\x14\n\x0cnum_attempts\x18\x03 \x01(\x03\x12\x30\n\x0csubmitted_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06status\x18\x05 \x01(\t"$\n\x10\x43\x61ncelJobRequest\x12\x10\n\x08job_uuid\x18\x01 \x01(\t"3\n\x0f\x43\x61ncelJobResult\x12 \n\x06status\x18\x01 \x01(\x0e\x32\x10.CancelJobStatus")\n\x17ProvisionWebhookRequest\x12\x0e\n\x06job_id\x18\x01 \x01(\t".\n\x16ProvisionWebhookResult\x12\x14\n\x0cprofile_name\x18\x01 \x01(\t*p\n\x0f\x43\x61ncelJobStatus\x12\x1a\n\x16\x43\x41NCELLED_WITH_RESTART\x10\x00\x12\x10\n\x0cNO_JOB_FOUND\x10\x01\x12\x19\n\x15JOB_ALREADY_COMPLETED\x10\x02\x12\x14\n\x10\x43\x41NCELLED_QUEUED\x10\x03\x32\xbd\x03\n\rCommandServer\x12\x35\n\nEnqueueJob\x12\x12.EnqueueJobRequest\x1a\x11.EnqueueJobResult"\x00\x12/\n\x08RetryJob\x12\x10.RetryJobRequest\x1a\x0f.RetryJobResult"\x00\x12\x38\n\x0b\x43heckHealth\x12\x13.CheckHealthRequest\x1a\x12.CheckHealthResult"\x00\x12\x41\n\x0eListQueuedJobs\x12\x16.ListQueuedJobsRequest\x1a\x15.ListQueuedJobsResult"\x00\x12J\n\x11VaccuumQueuedJobs\x12\x19.VaccuumQueuedJobsRequest\x1a\x18.VaccuumQueuedJobsResult"\x00\x12\x32\n\tCancelJob\x12\x11.CancelJobRequest\x1a\x10.CancelJobResult"\x00\x12G\n\x10ProvisionWebhook\x12\x18.ProvisionWebhookRequest\x1a\x17.ProvisionWebhookResult"\x00\x62\x06proto3'
33
33
  )
34
34
 
35
35
  _globals = globals()
@@ -42,7 +42,7 @@ _builder.BuildTopDescriptorsAndMessages(
42
42
  if not _descriptor._USE_C_DESCRIPTORS:
43
43
  DESCRIPTOR._loaded_options = None
44
44
  _globals["_CANCELJOBSTATUS"]._serialized_start = 1109
45
- _globals["_CANCELJOBSTATUS"]._serialized_end = 1199
45
+ _globals["_CANCELJOBSTATUS"]._serialized_end = 1221
46
46
  _globals["_ENQUEUEJOBREQUEST"]._serialized_start = 118
47
47
  _globals["_ENQUEUEJOBREQUEST"]._serialized_end = 187
48
48
  _globals["_ENQUEUEJOBRESULT"]._serialized_start = 189
@@ -73,6 +73,6 @@ if not _descriptor._USE_C_DESCRIPTORS:
73
73
  _globals["_PROVISIONWEBHOOKREQUEST"]._serialized_end = 1059
74
74
  _globals["_PROVISIONWEBHOOKRESULT"]._serialized_start = 1061
75
75
  _globals["_PROVISIONWEBHOOKRESULT"]._serialized_end = 1107
76
- _globals["_COMMANDSERVER"]._serialized_start = 1202
77
- _globals["_COMMANDSERVER"]._serialized_end = 1647
76
+ _globals["_COMMANDSERVER"]._serialized_start = 1224
77
+ _globals["_COMMANDSERVER"]._serialized_end = 1669
78
78
  # @@protoc_insertion_point(module_scope)
@@ -16,10 +16,12 @@ class CancelJobStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
16
16
  CANCELLED_WITH_RESTART: _ClassVar[CancelJobStatus]
17
17
  NO_JOB_FOUND: _ClassVar[CancelJobStatus]
18
18
  JOB_ALREADY_COMPLETED: _ClassVar[CancelJobStatus]
19
+ CANCELLED_QUEUED: _ClassVar[CancelJobStatus]
19
20
 
20
21
  CANCELLED_WITH_RESTART: CancelJobStatus
21
22
  NO_JOB_FOUND: CancelJobStatus
22
23
  JOB_ALREADY_COMPLETED: CancelJobStatus
24
+ CANCELLED_QUEUED: CancelJobStatus
23
25
 
24
26
  class EnqueueJobRequest(_message.Message):
25
27
  __slots__ = ("job_ref_name", "serialized_payload")
@@ -8,6 +8,7 @@ from uncountable.types import queued_job_t
8
8
 
9
9
  class CommandCancelJobStatus(StrEnum):
10
10
  CANCELLED_WITH_RESTART = "cancelled_with_restart"
11
+ CANCELLED_QUEUED = "cancelled_queued"
11
12
  NO_JOB_FOUND = "no_job_found"
12
13
  JOB_ALREADY_COMPLETED = "job_already_completed"
13
14
 
@@ -204,7 +204,19 @@ class DatastoreSqlBase(Datastore):
204
204
 
205
205
  return None
206
206
 
207
- def load_job_queue(self) -> list[queued_job_t.QueuedJob]:
207
+ def list_queued_job_uuids(self) -> list[str]:
208
+ with self.session_maker() as session:
209
+ select_stmt = select(QueuedJob.id).filter(
210
+ or_(
211
+ QueuedJob.status == queued_job_t.JobStatus.QUEUED,
212
+ QueuedJob.status.is_(None),
213
+ )
214
+ )
215
+ return [row.id for row in session.execute(select_stmt)]
216
+
217
+ def load_queued_jobs(
218
+ self, *, queued_job_uuids: list[str]
219
+ ) -> list[queued_job_t.QueuedJob]:
208
220
  with self.session_maker() as session:
209
221
  select_stmt = (
210
222
  select(
@@ -215,12 +227,7 @@ class DatastoreSqlBase(Datastore):
215
227
  QueuedJob.status,
216
228
  QueuedJob.submitted_at,
217
229
  )
218
- .filter(
219
- or_(
220
- QueuedJob.status == queued_job_t.JobStatus.QUEUED,
221
- QueuedJob.status.is_(None),
222
- )
223
- )
230
+ .filter(QueuedJob.id.in_(queued_job_uuids))
224
231
  .order_by(QueuedJob.submitted_at)
225
232
  )
226
233
 
@@ -31,7 +31,12 @@ class Datastore(ABC):
31
31
  def increment_num_attempts(self, queued_job_uuid: str) -> int: ...
32
32
 
33
33
  @abstractmethod
34
- def load_job_queue(self) -> list[queued_job_t.QueuedJob]: ...
34
+ def list_queued_job_uuids(self) -> list[str]: ...
35
+
36
+ @abstractmethod
37
+ def load_queued_jobs(
38
+ self, *, queued_job_uuids: list[str]
39
+ ) -> list[queued_job_t.QueuedJob]: ...
35
40
 
36
41
  @abstractmethod
37
42
  def get_next_queued_job_for_ref_name(
@@ -31,6 +31,9 @@ from uncountable.types import job_definition_t, queued_job_t
31
31
 
32
32
  from .types import RESTART_EXIT_CODE, ResultQueue, ResultTask
33
33
 
34
+ QUEUED_JOB_POLL_INTERVAL_SECS = 1
35
+ RESTART_DELAY_SECS = 5
36
+
34
37
 
35
38
  @dataclass(kw_only=True, frozen=True)
36
39
  class JobListenerKey:
@@ -65,10 +68,12 @@ def on_worker_crash(
65
68
 
66
69
 
67
70
  def _start_workers(
71
+ *,
68
72
  process_pool: ProcessPoolExecutor,
69
73
  result_queue: ResultQueue,
70
74
  datastore: Datastore,
71
75
  worker_tasks: list[asyncio.Task],
76
+ running_job_uuids: set[str],
72
77
  ) -> dict[str, Worker]:
73
78
  profiles = load_profiles()
74
79
  job_queue_worker_lookup: dict[JobListenerKey, Worker] = {}
@@ -84,6 +89,7 @@ def _start_workers(
84
89
  listen_queue=asyncio.Queue(),
85
90
  result_queue=result_queue,
86
91
  datastore=datastore,
92
+ running_job_uuids=running_job_uuids,
87
93
  )
88
94
  task = asyncio.create_task(worker.run_worker_loop())
89
95
  task.add_done_callback(on_worker_crash(job_worker_key))
@@ -108,11 +114,16 @@ async def start_scheduler(
108
114
  with ProcessPoolExecutor(
109
115
  max_workers=get_max_job_workers(), max_tasks_per_child=1
110
116
  ) as process_pool:
117
+ running_job_uuids: set[str] = set()
111
118
  job_worker_lookup = _start_workers(
112
- process_pool, result_queue, datastore=datastore, worker_tasks=worker_tasks
119
+ process_pool=process_pool,
120
+ result_queue=result_queue,
121
+ datastore=datastore,
122
+ worker_tasks=worker_tasks,
123
+ running_job_uuids=running_job_uuids,
113
124
  )
114
125
 
115
- queued_jobs = datastore.load_job_queue()
126
+ dispatched_job_uuids: set[str] = set()
116
127
 
117
128
  async def enqueue_queued_job(queued_job: queued_job_t.QueuedJob) -> None:
118
129
  try:
@@ -123,8 +134,22 @@ async def start_scheduler(
123
134
  queued_job.queued_job_uuid, queued_job_t.JobStatus.FAILED
124
135
  )
125
136
  return
137
+ dispatched_job_uuids.add(queued_job.queued_job_uuid)
126
138
  await worker.listen_queue.put(queued_job)
127
139
 
140
+ async def _dispatch_undispatched_queued_jobs() -> None:
141
+ undispatched_job_uuids = [
142
+ queued_job_uuid
143
+ for queued_job_uuid in datastore.list_queued_job_uuids()
144
+ if queued_job_uuid not in dispatched_job_uuids
145
+ ]
146
+ if len(undispatched_job_uuids) == 0:
147
+ return
148
+ for queued_job in datastore.load_queued_jobs(
149
+ queued_job_uuids=undispatched_job_uuids
150
+ ):
151
+ await enqueue_queued_job(queued_job)
152
+
128
153
  async def _enqueue_or_deduplicate_job(
129
154
  job_ref_name: str,
130
155
  payload: queued_job_t.QueuedJobPayload,
@@ -172,30 +197,36 @@ async def start_scheduler(
172
197
  )
173
198
  return
174
199
 
175
- if queued_job.status == queued_job_t.JobStatus.QUEUED:
176
- datastore.remove_job_from_queue(command.queued_job_uuid)
200
+ if queued_job.status != queued_job_t.JobStatus.QUEUED:
177
201
  await command.response_queue.put(
178
202
  CommandCancelJobResponse(
179
- status=CommandCancelJobStatus.CANCELLED_WITH_RESTART
203
+ status=CommandCancelJobStatus.JOB_ALREADY_COMPLETED
180
204
  )
181
205
  )
206
+ return
182
207
 
183
- logger.log_debug(
184
- "Job cancelled successfully.",
185
- attributes={"job.queued_job_uuid": command.queued_job_uuid},
186
- )
187
-
188
- def delayed_exit() -> None:
189
- os._exit(RESTART_EXIT_CODE)
190
-
191
- threading.Timer(interval=5, function=delayed_exit).start()
192
-
208
+ datastore.remove_job_from_queue(command.queued_job_uuid)
209
+ if command.queued_job_uuid in running_job_uuids:
210
+ cancel_status = CommandCancelJobStatus.CANCELLED_WITH_RESTART
211
+ threading.Timer(
212
+ interval=RESTART_DELAY_SECS,
213
+ function=os._exit,
214
+ args=[RESTART_EXIT_CODE],
215
+ ).start()
193
216
  else:
194
- await command.response_queue.put(
195
- CommandCancelJobResponse(
196
- status=CommandCancelJobStatus.JOB_ALREADY_COMPLETED
197
- )
198
- )
217
+ dispatched_job_uuids.discard(command.queued_job_uuid)
218
+ cancel_status = CommandCancelJobStatus.CANCELLED_QUEUED
219
+
220
+ await command.response_queue.put(
221
+ CommandCancelJobResponse(status=cancel_status)
222
+ )
223
+ logger.log_debug(
224
+ "Job cancelled successfully.",
225
+ attributes={
226
+ "job.queued_job_uuid": command.queued_job_uuid,
227
+ "job.cancel_status": cancel_status,
228
+ },
229
+ )
199
230
 
200
231
  async def _handle_retry_job_command(command: CommandRetryJob) -> None:
201
232
  queued_job = datastore.retry_job(command.queued_job_uuid)
@@ -221,17 +252,21 @@ async def start_scheduler(
221
252
  attributes={"job.queued_job_uuids": deleted_uuids},
222
253
  )
223
254
 
224
- for queued_job in queued_jobs:
225
- await enqueue_queued_job(queued_job)
255
+ await _dispatch_undispatched_queued_jobs()
226
256
 
227
257
  result_task: ResultTask = asyncio.create_task(result_queue.get())
228
258
  command_task: CommandTask = asyncio.create_task(command_queue.get())
259
+ poll_task = asyncio.create_task(asyncio.sleep(QUEUED_JOB_POLL_INTERVAL_SECS))
229
260
  shutdown_task: asyncio.Task[object] | None = None
230
261
  if shutdown_event is not None:
231
262
  shutdown_task = asyncio.create_task(shutdown_event.wait())
232
263
 
233
264
  while True:
234
- waitables: list[asyncio.Task[object]] = [result_task, command_task]
265
+ waitables: list[asyncio.Task[object]] = [
266
+ result_task,
267
+ command_task,
268
+ poll_task,
269
+ ]
235
270
  if shutdown_task is not None:
236
271
  waitables.append(shutdown_task)
237
272
 
@@ -246,6 +281,7 @@ async def start_scheduler(
246
281
  worker_task.cancel()
247
282
  command_task.cancel()
248
283
  result_task.cancel()
284
+ poll_task.cancel()
249
285
  process_pool.shutdown(wait=False, cancel_futures=True)
250
286
  return
251
287
  if task == command_task:
@@ -275,4 +311,11 @@ async def start_scheduler(
275
311
  queued_job_result.queued_job_uuid,
276
312
  queued_job_t.JobStatus.FAILED,
277
313
  )
314
+ dispatched_job_uuids.discard(queued_job_result.queued_job_uuid)
315
+ running_job_uuids.discard(queued_job_result.queued_job_uuid)
278
316
  result_task = asyncio.create_task(result_queue.get())
317
+ elif task == poll_task:
318
+ await _dispatch_undispatched_queued_jobs()
319
+ poll_task = asyncio.create_task(
320
+ asyncio.sleep(QUEUED_JOB_POLL_INTERVAL_SECS)
321
+ )
@@ -21,17 +21,25 @@ class Worker:
21
21
  listen_queue: ListenQueue,
22
22
  result_queue: ResultQueue,
23
23
  datastore: Datastore,
24
+ running_job_uuids: set[str],
24
25
  ) -> None:
25
26
  self.process_pool = process_pool
26
27
  self.listen_queue = listen_queue
27
28
  self.result_queue = result_queue
28
29
  self.datastore = datastore
30
+ self.running_job_uuids = running_job_uuids
29
31
 
30
32
  async def run_worker_loop(self) -> None:
31
33
  logger = Logger(get_current_span())
32
34
  while True:
33
35
  try:
34
36
  queued_job = await self.listen_queue.get()
37
+ if (
38
+ self.datastore.get_queued_job(uuid=queued_job.queued_job_uuid)
39
+ is None
40
+ ):
41
+ continue
42
+ self.running_job_uuids.add(queued_job.queued_job_uuid)
35
43
  self.datastore.increment_num_attempts(queued_job.queued_job_uuid)
36
44
  loop = asyncio.get_event_loop()
37
45
  result = await loop.run_in_executor(
@@ -12,7 +12,12 @@ from typing import assert_never
12
12
  from opentelemetry.trace import get_current_span
13
13
 
14
14
  from uncountable.core.environment import get_local_admin_server_port
15
+ from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
15
16
  from uncountable.integration.entrypoint import main as cron_target
17
+ from uncountable.integration.ownership_lock import (
18
+ OwnershipOutcome,
19
+ hold_integration_ownership,
20
+ )
16
21
  from uncountable.integration.queue_runner.command_server import (
17
22
  CommandServerTimeout,
18
23
  check_health,
@@ -259,15 +264,16 @@ def main() -> None:
259
264
  )
260
265
  )
261
266
 
262
- _start_queue_runner()
267
+ shutdown_requested = False
263
268
 
264
- cron_process = multiprocessing.Process(target=cron_target)
265
- cron_process.start()
266
- add_process(
267
- ProcessInfo(
268
- key=CoreProcessKey(name=ProcessName.CRON_SERVER), process=cron_process
269
- )
270
- )
269
+ def _sigterm_handler(signum: int, frame: object) -> None:
270
+ nonlocal shutdown_requested
271
+ shutdown_requested = True
272
+
273
+ def _is_shutdown_requested() -> bool:
274
+ return shutdown_requested
275
+
276
+ signal.signal(signal.SIGTERM, _sigterm_handler)
271
277
 
272
278
  webhook_server_process = subprocess.Popen([
273
279
  sys.executable,
@@ -284,59 +290,82 @@ def main() -> None:
284
290
  )
285
291
  )
286
292
 
287
- services_by_id: dict[str, ServiceToStart] = {
288
- service_to_start.service.id: service_to_start
289
- for service_to_start in select_enabled_services(load_profiles())
290
- }
291
- for service_to_start in services_by_id.values():
292
- _start_service(service_to_start)
293
+ def _run_owned_processes() -> None:
294
+ if _is_shutdown_requested():
295
+ return
296
+ _start_queue_runner()
297
+ if _is_shutdown_requested():
298
+ return
293
299
 
294
- shutdown_requested = False
300
+ cron_process = multiprocessing.Process(target=cron_target)
301
+ cron_process.start()
302
+ add_process(
303
+ ProcessInfo(
304
+ key=CoreProcessKey(name=ProcessName.CRON_SERVER), process=cron_process
305
+ )
306
+ )
295
307
 
296
- def _sigterm_handler(signum: int, frame: object) -> None:
297
- nonlocal shutdown_requested
298
- shutdown_requested = True
308
+ services_by_id: dict[str, ServiceToStart] = {
309
+ service_to_start.service.id: service_to_start
310
+ for service_to_start in select_enabled_services(load_profiles())
311
+ }
312
+ for service_to_start in services_by_id.values():
313
+ if _is_shutdown_requested():
314
+ return
315
+ _start_service(service_to_start)
299
316
 
300
- signal.signal(signal.SIGTERM, _sigterm_handler)
317
+ try:
318
+ while not _is_shutdown_requested():
319
+ process_alarm = check_process_alarms(logger, processes=processes)
320
+ match process_alarm:
321
+ case ProcessAlarmRestart():
322
+ match process_alarm.process.key:
323
+ case CoreProcessKey() as core_key:
324
+ match core_key.name:
325
+ case ProcessName.QUEUE_RUNNER:
326
+ del processes[
327
+ CoreProcessKey(
328
+ name=ProcessName.QUEUE_RUNNER
329
+ )
330
+ ]
331
+ _start_queue_runner()
332
+ case (
333
+ ProcessName.CRON_SERVER
334
+ | ProcessName.WEBHOOK_SERVER
335
+ ):
336
+ raise NotImplementedError(
337
+ f"restarting {core_key.label} not yet implemented"
338
+ )
339
+ case _:
340
+ assert_never(core_key.name)
341
+ case ServiceProcessKey() as service_key:
342
+ del processes[service_key]
343
+ _start_service(services_by_id[service_key.service_id])
344
+ case _:
345
+ assert_never(process_alarm.process.key)
346
+ case ProcessAlarmShutdownAll():
347
+ handle_shutdown(logger, processes)
348
+ sys.exit(1)
349
+ case None:
350
+ pass
351
+ case _:
352
+ assert_never(process_alarm)
353
+ time.sleep(1)
354
+ except KeyboardInterrupt:
355
+ pass
301
356
 
302
- try:
303
- while not shutdown_requested:
304
- process_alarm = check_process_alarms(logger, processes=processes)
305
- match process_alarm:
306
- case ProcessAlarmRestart():
307
- match process_alarm.process.key:
308
- case CoreProcessKey() as core_key:
309
- match core_key.name:
310
- case ProcessName.QUEUE_RUNNER:
311
- del processes[
312
- CoreProcessKey(name=ProcessName.QUEUE_RUNNER)
313
- ]
314
- _start_queue_runner()
315
- case (
316
- ProcessName.CRON_SERVER | ProcessName.WEBHOOK_SERVER
317
- ):
318
- raise NotImplementedError(
319
- f"restarting {core_key.label} not yet implemented"
320
- )
321
- case _:
322
- assert_never(core_key.name)
323
- case ServiceProcessKey() as service_key:
324
- del processes[service_key]
325
- _start_service(services_by_id[service_key.service_id])
326
- case _:
327
- assert_never(process_alarm.process.key)
328
- case ProcessAlarmShutdownAll():
329
- handle_shutdown(logger, processes)
330
- sys.exit(1)
331
- case None:
332
- pass
333
- case _:
334
- assert_never(process_alarm)
335
- time.sleep(1)
336
- except KeyboardInterrupt:
337
- pass
338
-
339
- handle_shutdown(logger, processes=processes)
357
+ with hold_integration_ownership(
358
+ engine=create_db_engine(IntegrationDBService.RUNNER),
359
+ is_shutdown_requested=_is_shutdown_requested,
360
+ ) as ownership_outcome:
361
+ match ownership_outcome:
362
+ case OwnershipOutcome.ACQUIRED:
363
+ _run_owned_processes()
364
+ case OwnershipOutcome.SHUTDOWN_REQUESTED:
365
+ pass
366
+ case _:
367
+ assert_never(ownership_outcome)
368
+ handle_shutdown(logger, processes=processes)
340
369
 
341
370
 
342
371
  if __name__ == "__main__":
@@ -8,9 +8,12 @@ from uncountable.core.environment import (
8
8
  get_http_server_port,
9
9
  get_server_env,
10
10
  )
11
+ from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
11
12
  from uncountable.integration.executors.script_executor import resolve_script_executor
12
13
  from uncountable.integration.http_server import GenericHttpRequest, HttpException
13
- from uncountable.integration.job import CustomHttpJob, WebhookJob
14
+ from uncountable.integration.job import HttpJob
15
+ from uncountable.integration.job_queue import JobQueue
16
+ from uncountable.integration.queue_runner.datastore import create_datastore
14
17
  from uncountable.integration.scan_profiles import load_profiles
15
18
  from uncountable.integration.telemetry import Logger
16
19
  from uncountable.types import job_definition_t
@@ -42,6 +45,7 @@ def register_route(
42
45
  server_logger: Logger,
43
46
  profile_meta: job_definition_t.ProfileMetadata,
44
47
  job: HttpJobDefinition,
48
+ job_queue: JobQueue,
45
49
  ) -> None:
46
50
  route = f"/{profile_meta.name}/{job.id}"
47
51
  log_attributes = {
@@ -69,9 +73,9 @@ def register_route(
69
73
  job_instance = resolve_script_executor(
70
74
  executor=job.executor, profile_metadata=profile_meta
71
75
  )
72
- if not isinstance(job_instance, (CustomHttpJob, WebhookJob)):
76
+ if not isinstance(job_instance, HttpJob):
73
77
  raise HttpException.configuration_error(
74
- message="[internal] http job must descend from CustomHttpJob"
78
+ message="[internal] http job must descend from HttpJob"
75
79
  )
76
80
  http_request = GenericHttpRequest(
77
81
  body_base64=base64.b64encode(flask.request.get_data()).decode(),
@@ -81,8 +85,11 @@ def register_route(
81
85
  job_instance.validate_request(
82
86
  request=http_request, job_definition=job, profile_meta=profile_meta
83
87
  )
84
- http_response = job_instance.handle_request(
85
- request=http_request, job_definition=job, profile_meta=profile_meta
88
+ http_response = job_instance.respond_to_request(
89
+ request=http_request,
90
+ job_definition=job,
91
+ profile_meta=profile_meta,
92
+ job_queue=job_queue,
86
93
  )
87
94
 
88
95
  return flask.make_response(
@@ -121,6 +128,10 @@ def register_route(
121
128
  def main() -> None:
122
129
  app.add_url_rule("/health", "health", lambda: ("OK", 200))
123
130
 
131
+ job_queue = JobQueue(
132
+ create_datastore(create_db_engine(IntegrationDBService.RUNNER))
133
+ )
134
+
124
135
  profiles = load_profiles()
125
136
  for profile_metadata in profiles:
126
137
  server_logger = Logger(get_current_span())
@@ -137,6 +148,7 @@ def main() -> None:
137
148
  server_logger=server_logger,
138
149
  profile_meta=profile_metadata,
139
150
  job=job,
151
+ job_queue=job_queue,
140
152
  )
141
153
  case _:
142
154
  assert_never(job)
@@ -313,6 +313,7 @@ __all__: list[str] = [
313
313
  "user_group": "User Group",
314
314
  "user_group_assignment": "User Group Assignment",
315
315
  "user_list_set": "User List Set",
316
+ "user_preference": "User Preference",
316
317
  "value_spec_property": "Value Spec Property",
317
318
  "webhook": "Webhook",
318
319
  "webhook_event": "Webhook Event",
@@ -610,6 +611,7 @@ class EntityType(StrEnum):
610
611
  USER_GROUP = "user_group"
611
612
  USER_GROUP_ASSIGNMENT = "user_group_assignment"
612
613
  USER_LIST_SET = "user_list_set"
614
+ USER_PREFERENCE = "user_preference"
613
615
  VALUE_SPEC_PROPERTY = "value_spec_property"
614
616
  WEBHOOK = "webhook"
615
617
  WEBHOOK_EVENT = "webhook_event"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UncountablePythonSDK
3
- Version: 0.0.190
3
+ Version: 0.0.191
4
4
  Summary: Uncountable SDK
5
5
  Project-URL: Homepage, https://github.com/uncountableinc/uncountable-python-sdk
6
6
  Project-URL: Repository, https://github.com/uncountableinc/uncountable-python-sdk.git
@@ -104,7 +104,7 @@ pkgs/type_spec/emit_open_api_util.py,sha256=bTmRvrGP82-eB75hwf9ySI7pDEC87FNQTF18
104
104
  pkgs/type_spec/emit_python.py,sha256=6ZGh9F73Eqb4W8-bHLYkV5k8snEAQH9aXL9_T4onblM,65465
105
105
  pkgs/type_spec/emit_scss.py,sha256=6psmBAJyu7cvfQAIvIIMk2U6VFG_xeWy1Ea4afv0XyM,1497
106
106
  pkgs/type_spec/emit_typescript.py,sha256=XRMMovJz_g2j0rpn1Nrz4uzHRCs6O6HLIxdEB1QMNIE,11939
107
- pkgs/type_spec/emit_typescript_util.py,sha256=1-Q4p1ztLL3veQpMMeY3qtjqNFHlbr_IOCb7P7FzVZI,16764
107
+ pkgs/type_spec/emit_typescript_util.py,sha256=X3EUGcZ8qJ2vkYZSjeV774fTa19t4VuYXNRKrFU9WDs,17653
108
108
  pkgs/type_spec/load_types.py,sha256=k5hWxFvhj8aT_9vAwtkjn9OdudQFK9IIUzZI7-Gpx2Y,5277
109
109
  pkgs/type_spec/non_discriminated_union_exceptions.py,sha256=jgQ4Naz8A6FbhgfR3c9SI4IMvkkft0MT6CyQXWu9RB0,559
110
110
  pkgs/type_spec/open_api_util.py,sha256=LX6ny4xNOWXQO3qWVM8elrSDTqWsj_7jFplax2POShE,7992
@@ -115,7 +115,7 @@ pkgs/type_spec/actions_registry/__main__.py,sha256=hWUnmiG68xeW5GhnIolfHOvPY2GI_
115
115
  pkgs/type_spec/actions_registry/emit_python.py,sha256=atY1_Z9Ue6r1nBsMeoeX02vQCc_UpAnDLK7gdVNemZ4,3753
116
116
  pkgs/type_spec/actions_registry/emit_typescript.py,sha256=XqmbX5OrgjdCGQf4-iNc_aZ7JiXgaVGt1wJcpAUh8SA,6787
117
117
  pkgs/type_spec/parts/base.py.prepart,sha256=ju50TWzxO7qgLjRwJSGJYnKGiLBT1g_8aC-01-f2fhk,2286
118
- pkgs/type_spec/parts/base.ts.prepart,sha256=bSnYpl7ZPTcaVwMdZOWONJM7_BtSkUoA9vwMN2Niqac,1959
118
+ pkgs/type_spec/parts/base.ts.prepart,sha256=MOAkZgpF6KDWbIRIQODz8A-uXIcf04x3mNWG_hSfYpg,2203
119
119
  pkgs/type_spec/type_info/__main__.py,sha256=TLNvCHGcmaj_8Sj5bAQNpuNaaw2dpDzoFDWZds0V4Qo,1002
120
120
  pkgs/type_spec/type_info/emit_type_info.py,sha256=xoEFfK3H4r6w3Sur6-55I2efevdV9nKVHw1VtuCElR8,17990
121
121
  pkgs/type_spec/ui_entry_actions/__init__.py,sha256=WiHE_BexOEZWbkkbD7EnFau1aMLNmfgQywG9PTQNCkw,135
@@ -139,14 +139,16 @@ uncountable/core/query/column.py,sha256=WR5VRX30kGrObMxxWhnaH-ueJkn_P-K73kz0GSfZ
139
139
  uncountable/core/query/row.py,sha256=dy68YKvMz336nyOsj_g2oNTLKBuUnKRUz5n99sApfT0,1600
140
140
  uncountable/core/query/types.py,sha256=xZgc0_O1PIxZoJWQhxkg3qnSvHj3yt-bv9QX1UCOFGw,481
141
141
  uncountable/integration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
- uncountable/integration/cli.py,sha256=5RzlfScaZHapiXp4XoaYh0QqSenIjkEf-kyjEue1ycg,9301
142
+ uncountable/integration/cli.py,sha256=0s0cY9FcYHqWwpR7eeffGgsyrECOFacn6jMRJSTbD4o,9419
143
143
  uncountable/integration/construct_client.py,sha256=9c8ZqMjyXebsptiGkJbg53_af4TLOumY1hOQtIbC15w,2229
144
144
  uncountable/integration/cron.py,sha256=6eH-kIs3sdYPCyb62_L2M7U_uQTdMTdwY5hreEJb0hw,887
145
145
  uncountable/integration/entrypoint.py,sha256=seXMXxUPjBLzpP6UAB6fRVodkgGea6vnl2oy4FWvCyE,591
146
- uncountable/integration/job.py,sha256=Jeiiwvk-T3TxNYpesGbyTuQirw8gX1qQlmCIZ7r7Wuc,9187
146
+ uncountable/integration/job.py,sha256=gMcPvV_8mS1mtyYCg7fkRPB0Nj4K1VCD2KrTILRwUpE,9460
147
+ uncountable/integration/job_queue.py,sha256=6hnNWvifqtup2e3uG-EWKjcCNF-Xl4KW_432GaXBoAc,461
148
+ uncountable/integration/ownership_lock.py,sha256=UDv9MKWiDRkTMUlznBLSd-hqtQcrbXEBQ5aU7PBA0E8,2638
147
149
  uncountable/integration/request_context.py,sha256=N_FJJxqvfUJ0yV9h3I3vFTGNJiDfyLYObczcYa44pw8,999
148
150
  uncountable/integration/scan_profiles.py,sha256=Fl8vr2jhW9BmnatLHZSvMnruUUXYgnXVVlbftYetY9g,3647
149
- uncountable/integration/scheduler.py,sha256=9nd2OZli9qQ9kQ5hIfR6nb1JBmjdSHu5yAS7jEyPq7o,11153
151
+ uncountable/integration/scheduler.py,sha256=OvWK2VbqDM8LhuthVf90oJpCIRNDPnT4zyyi1TDx6wA,12425
150
152
  uncountable/integration/server.py,sha256=wnqpowr3xdAXfi4gCTdfFBvmQVyTro439kqrXewBauM,6002
151
153
  uncountable/integration/service.py,sha256=i3GSrNu7QzMfK1jOIKLdJQCM5Tzp9eF5nfWldj6Cx-M,2144
152
154
  uncountable/integration/service_runner.py,sha256=u9J90fh4CA9nluvfuTUaF6MhCJc2VKMD4Ahmk2x6Hzc,1435
@@ -165,32 +167,32 @@ uncountable/integration/http_server/types.py,sha256=0J59HhEyntDjy0o37Qze0v0o-wC8
165
167
  uncountable/integration/queue_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
166
168
  uncountable/integration/queue_runner/job_process.py,sha256=OONJ7aLsE80qIJ2ABLY3HYyKI7VdpBjI_pPQ3GCC13U,2586
167
169
  uncountable/integration/queue_runner/job_runner.py,sha256=Yh9hYfEYOya3BR6ypsal3LKPKrrEb4VQwfGuh4qyU2w,6115
168
- uncountable/integration/queue_runner/job_scheduler.py,sha256=ePGX7QXSDReU7NZX6YqtifQPEB0VH6NjZKHe3LGf7Zw,10798
170
+ uncountable/integration/queue_runner/job_scheduler.py,sha256=1gdm0PRbjZBFlwWM0sLIkhVFlv9K0x5BjqRvbFMsZKA,12599
169
171
  uncountable/integration/queue_runner/queue_runner.py,sha256=G3HhYwKqjx863C_Kjg7rWe5BXy9d-0jkzIf9xuKCYGo,1181
170
172
  uncountable/integration/queue_runner/types.py,sha256=8HS6KnYMS_vc5XHeMpg0BFAQC-5P3QLzd-aDYDMMt3E,244
171
- uncountable/integration/queue_runner/worker.py,sha256=hyLJxBE5GQNIXo7YAYJfj0t1vvNR_6wMTrbBuCiBZjc,1688
173
+ uncountable/integration/queue_runner/worker.py,sha256=b-tmOLNMaSOA3VQRtOvymBZYyZomq0kSA5LpQlWI_jM,2027
172
174
  uncountable/integration/queue_runner/command_server/__init__.py,sha256=hMCDLWct8zW4B2a9BaIAsMhtaEgFlxONjeow-6nf6dg,675
173
- uncountable/integration/queue_runner/command_server/command_client.py,sha256=s2E4v3ATGF1Ii1ZyP13Pa4Abdnj1ocB5tPUgP7J5-J0,6394
174
- uncountable/integration/queue_runner/command_server/command_server.py,sha256=-8FhL67-XQxx_sIIoQ0ImCOJYZHjwCpucCuayvRdocQ,8614
175
+ uncountable/integration/queue_runner/command_server/command_client.py,sha256=9WTKWCD6o3h9xSOJP4lK8oyGzlQgXeoOLO8D7fWJZy4,6508
176
+ uncountable/integration/queue_runner/command_server/command_server.py,sha256=ixcamAbYZ1R0C9-kV3aCUD0xKOov38bV7GW0UMyjcIM,8744
175
177
  uncountable/integration/queue_runner/command_server/constants.py,sha256=7J9mQIAMOfV50wnwpn7HgrPFEi3Ritj6HwrGYwxGLoU,88
176
178
  uncountable/integration/queue_runner/command_server/provision_webhook.py,sha256=TqH_6YjRZz8EHMKxOcCxvmK6vweR8nuY8ilWNEQoogk,1010
177
- uncountable/integration/queue_runner/command_server/types.py,sha256=ZNqJE6b6rfMZGdcPS-7umB_8x2a7dzTfRBsdGoCzDjY,2077
179
+ uncountable/integration/queue_runner/command_server/types.py,sha256=PCEhvCMxLW1GlDjFHAXQmR5R3-0bd9To44XeOW3PLEQ,2119
178
180
  uncountable/integration/queue_runner/command_server/protocol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
179
- uncountable/integration/queue_runner/command_server/protocol/command_server.proto,sha256=mvgCHAIQocdtou7e8SZmmJIBK7jIzJ2fT4yDz6Bur3c,1869
180
- uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py,sha256=voigJ1ScgpWx0FL59pKinN8MLj0seuqoZuOqvCtSPss,6013
181
- uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi,sha256=CkaTBkhsGngUxD-am691wKFAARCrivqiJHxP1pLe7jw,5984
181
+ uncountable/integration/queue_runner/command_server/protocol/command_server.proto,sha256=YltIWS_vVyuOuaj0HgIYONU0aApIcyVkqM5PF0s9TSQ,1892
182
+ uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py,sha256=2vzQZ8qLnSvhw4FhFVo7CcgvkFDuQUywamuBE55U-Jw,6057
183
+ uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi,sha256=CUurj3pVwW4Zact1i7uOW4vjHa46j6kjQaQpXmnkqrk,6067
182
184
  uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py,sha256=wzPYtvuccPVW1qEXaMxU-KB3ptnZYnjHsuit6L1Pk6U,17415
183
185
  uncountable/integration/queue_runner/datastore/__init__.py,sha256=VdoQquDSbXvlRbluebD_XavVt4AdKvmvg-8hV1yTbXo,298
184
186
  uncountable/integration/queue_runner/datastore/datastore_postgres.py,sha256=oKRwOWlxgodJyqKr-jDcP9VbXKKTrU26lI-7m4HFBuk,359
185
- uncountable/integration/queue_runner/datastore/datastore_sql_base.py,sha256=eDQFDzn8y1IHoA888Hy69dUNl9MeCFQWW_VmnFqKdfo,11107
187
+ uncountable/integration/queue_runner/datastore/datastore_sql_base.py,sha256=UE2dc0QS2mx_tTVYxGAE0SXkRaXRhKEXlrqkWIb29nE,11390
186
188
  uncountable/integration/queue_runner/datastore/datastore_sqlite.py,sha256=W9hWKKvZrp3JyaO_tmK-qZL3ACnMnCfYwvDSJHHd6qs,1197
187
189
  uncountable/integration/queue_runner/datastore/factory.py,sha256=bv_7BxQpgjrggJKz5YDllgP_BihEJ0-CdpLvaG4ooj4,868
188
- uncountable/integration/queue_runner/datastore/interface.py,sha256=kuAU8lrjVN3NTllo99nfJwBojt5dg_wgdivyYCB4EbY,1581
190
+ uncountable/integration/queue_runner/datastore/interface.py,sha256=xIToMWQmPELuEAznfQRbbNWyBjhr-4moeHP5NT5uzFs,1704
189
191
  uncountable/integration/queue_runner/datastore/model.py,sha256=06_fGS0QTALEA_381I1zz63yXYRooaAVJdfYi0ubSd0,851
190
192
  uncountable/integration/secret_retrieval/__init__.py,sha256=sisf8cxCYcJmCXCKjEPoT2xIxm1j-Ru9iTfMVxH6NM0,171
191
193
  uncountable/integration/secret_retrieval/basic_secret.py,sha256=PfpeaEDwdi8nFlHUJnzCob9GYgGG5O_3mmcaxPPY6zI,3272
192
194
  uncountable/integration/secret_retrieval/retrieve_secret.py,sha256=c0VkCvppWKXwgxdblQuj2WhRaEGBPa7vLDoS6mlNOAQ,3919
193
- uncountable/integration/webhook_server/entrypoint.py,sha256=_IgZ98Fpigmx3VxYgKFC1jv55tz8M-u2CGgB9_q_04E,5440
195
+ uncountable/integration/webhook_server/entrypoint.py,sha256=1vk6gjMbp7sGyGtcZBXewarP4OT1dSjF70sf-jznIZQ,5874
194
196
  uncountable/integration/webhook_server/gunicorn_config.py,sha256=F7XiS1z2BSfCMpwTmEFedk18WdGG5dwtCs_hDBV5TIE,232
195
197
  uncountable/types/__init__.py,sha256=d1t7dHkmZ--ykXDKV_IDtWXD4MHCC0bWflT_yUjkRBw,14462
196
198
  uncountable/types/async_batch.py,sha256=yCCWrrLQfxXVqZp-KskxLBNkNmuELdz4PJjx8ULppgs,662
@@ -218,7 +220,7 @@ uncountable/types/data_t.py,sha256=U_dIh5GcyaJlkrHEI4Qf4TOo1uJX07PYO70sYzzKtTo,2
218
220
  uncountable/types/dataset_measurements.py,sha256=CVwWePUC02B_R_UB86yfNBwnclR-5xpZaiCs2jWtu2A,512
219
221
  uncountable/types/dataset_measurements_t.py,sha256=RNGy6g-bjfnCeoYEbcqULC83bS4oZ46fbYKL9whYa8E,1697
220
222
  uncountable/types/entity.py,sha256=Zclk1LYcRaYrMDhqyCjMSLEg0fE6_q8LHvV22Qvscgs,566
221
- uncountable/types/entity_t.py,sha256=fbyHxW5sytqkgZPQC0w9xSJuzCt-AXNDZ4et85QC-9Q,31544
223
+ uncountable/types/entity_t.py,sha256=4brCV8LvOBd8X-d_Jf4GW6quV_hEKiV5f8S5If1BXgY,31630
222
224
  uncountable/types/experiment_groups.py,sha256=qUpFOx1AKgzaT_4khCOv5Xs6jwiQGbvHH-GUh3v1nv4,288
223
225
  uncountable/types/experiment_groups_t.py,sha256=QjZBdjnbsD1D-VpPSSV6hBg-Q7mujjKvYtdMewtXNVU,712
224
226
  uncountable/types/exports.py,sha256=VMmxUO2PpV1Y63hZ2AnVor4H-B6aswJ7YpSru_u89lU,334
@@ -445,7 +447,7 @@ uncountable/types/api/uploader/complete_async_parse.py,sha256=Os1HAubxTlbut6Gylj
445
447
  uncountable/types/api/uploader/invoke_uploader.py,sha256=cmy7zl3AMF_AH5PCAB67rBeTE6JIcaUqPzm2NS4k_AE,1907
446
448
  uncountable/types/api/user/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr8APdZYUniAor8,55
447
449
  uncountable/types/api/user/get_current_user_info.py,sha256=BT7bGp5SOHz3XdKWhhdZ6acnInafT2PgdJ-H4YH3W7c,1181
448
- uncountablepythonsdk-0.0.190.dist-info/METADATA,sha256=0d3sb3VUbxH281yHiHC-sGfc5qJ2A4rx8G_jeIVn_CU,2241
449
- uncountablepythonsdk-0.0.190.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
450
- uncountablepythonsdk-0.0.190.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
451
- uncountablepythonsdk-0.0.190.dist-info/RECORD,,
450
+ uncountablepythonsdk-0.0.191.dist-info/METADATA,sha256=dRu4VuL-ylGKivo1djoLKXA14upN9PpVWtbvDDar00E,2241
451
+ uncountablepythonsdk-0.0.191.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
452
+ uncountablepythonsdk-0.0.191.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
453
+ uncountablepythonsdk-0.0.191.dist-info/RECORD,,