dbos 0.16.0a2__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dbos might be problematic. Click here for more details.

Files changed (79) hide show
  1. {dbos-0.16.0a2 → dbos-0.17.0}/PKG-INFO +1 -1
  2. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_core.py +3 -3
  3. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_dbos.py +3 -2
  4. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_outcome.py +23 -7
  5. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_queue.py +2 -2
  6. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_sys_db.py +6 -3
  7. {dbos-0.16.0a2 → dbos-0.17.0}/pyproject.toml +1 -1
  8. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_dbos.py +127 -0
  9. {dbos-0.16.0a2 → dbos-0.17.0}/LICENSE +0 -0
  10. {dbos-0.16.0a2 → dbos-0.17.0}/README.md +0 -0
  11. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/__init__.py +0 -0
  12. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_admin_server.py +0 -0
  13. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_app_db.py +0 -0
  14. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_classproperty.py +0 -0
  15. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_context.py +0 -0
  16. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_croniter.py +0 -0
  17. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_dbos_config.py +0 -0
  18. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_error.py +0 -0
  19. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_fastapi.py +0 -0
  20. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_flask.py +0 -0
  21. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_kafka.py +0 -0
  22. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_kafka_message.py +0 -0
  23. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_logger.py +0 -0
  24. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/env.py +0 -0
  25. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/script.py.mako +0 -0
  26. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  27. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  28. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  29. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  30. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  31. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  32. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_recovery.py +0 -0
  33. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_registrations.py +0 -0
  34. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_request.py +0 -0
  35. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_roles.py +0 -0
  36. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_scheduler.py +0 -0
  37. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_schemas/__init__.py +0 -0
  38. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_schemas/application_database.py +0 -0
  39. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_schemas/system_database.py +0 -0
  40. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_serialization.py +0 -0
  41. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/README.md +0 -0
  42. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/__package/__init__.py +0 -0
  43. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/__package/main.py +0 -0
  44. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/__package/schema.py +0 -0
  45. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/alembic.ini +0 -0
  46. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/dbos-config.yaml.dbos +0 -0
  47. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/migrations/env.py.dbos +0 -0
  48. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/migrations/script.py.mako +0 -0
  49. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/migrations/versions/2024_07_31_180642_init.py +0 -0
  50. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_templates/hello/start_postgres_docker.py +0 -0
  51. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/_tracer.py +0 -0
  52. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/cli.py +0 -0
  53. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/dbos-config.schema.json +0 -0
  54. {dbos-0.16.0a2 → dbos-0.17.0}/dbos/py.typed +0 -0
  55. {dbos-0.16.0a2 → dbos-0.17.0}/tests/__init__.py +0 -0
  56. {dbos-0.16.0a2 → dbos-0.17.0}/tests/atexit_no_ctor.py +0 -0
  57. {dbos-0.16.0a2 → dbos-0.17.0}/tests/atexit_no_launch.py +0 -0
  58. {dbos-0.16.0a2 → dbos-0.17.0}/tests/classdefs.py +0 -0
  59. {dbos-0.16.0a2 → dbos-0.17.0}/tests/conftest.py +0 -0
  60. {dbos-0.16.0a2 → dbos-0.17.0}/tests/more_classdefs.py +0 -0
  61. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_admin_server.py +0 -0
  62. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_async.py +0 -0
  63. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_classdecorators.py +0 -0
  64. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_concurrency.py +0 -0
  65. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_config.py +0 -0
  66. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_croniter.py +0 -0
  67. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_failures.py +0 -0
  68. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_fastapi.py +0 -0
  69. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_fastapi_roles.py +0 -0
  70. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_flask.py +0 -0
  71. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_kafka.py +0 -0
  72. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_outcome.py +0 -0
  73. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_package.py +0 -0
  74. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_queue.py +0 -0
  75. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_scheduler.py +0 -0
  76. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_schema_migration.py +0 -0
  77. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_singleton.py +0 -0
  78. {dbos-0.16.0a2 → dbos-0.17.0}/tests/test_spans.py +0 -0
  79. {dbos-0.16.0a2 → dbos-0.17.0}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.16.0a2
3
+ Version: 0.17.0
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -21,7 +21,7 @@ from typing import (
21
21
  overload,
22
22
  )
23
23
 
24
- from dbos._outcome import Immediate, Outcome, Pending
24
+ from dbos._outcome import Immediate, NoResult, Outcome, Pending
25
25
 
26
26
  from ._app_db import ApplicationDatabase, TransactionResultInternal
27
27
 
@@ -719,7 +719,7 @@ def decorate_step(
719
719
  finally:
720
720
  dbos._sys_db.record_operation_result(step_output)
721
721
 
722
- def check_existing_result() -> Optional[R]:
722
+ def check_existing_result() -> Union[NoResult, R]:
723
723
  ctx = assert_current_dbos_context()
724
724
  recorded_output = dbos._sys_db.check_operation_execution(
725
725
  ctx.workflow_id, ctx.function_id
@@ -743,7 +743,7 @@ def decorate_step(
743
743
  dbos.logger.debug(
744
744
  f"Running step, id: {ctx.function_id}, name: {attributes['name']}"
745
745
  )
746
- return None
746
+ return NoResult()
747
747
 
748
748
  stepOutcome = Outcome[R].make(functools.partial(func, *args, **kwargs))
749
749
  if retries_allowed:
@@ -45,7 +45,7 @@ from ._core import (
45
45
  start_workflow,
46
46
  workflow_wrapper,
47
47
  )
48
- from ._queue import Queue, _queue_thread
48
+ from ._queue import Queue, queue_thread
49
49
  from ._recovery import recover_pending_workflows, startup_recovery_thread
50
50
  from ._registrations import (
51
51
  DEFAULT_MAX_RECOVERY_ATTEMPTS,
@@ -283,6 +283,7 @@ class DBOS:
283
283
  self.flask: Optional["Flask"] = flask
284
284
  self._executor_field: Optional[ThreadPoolExecutor] = None
285
285
  self._background_threads: List[threading.Thread] = []
286
+ self._executor_id: str = os.environ.get("DBOS__VMID", "local")
286
287
 
287
288
  # If using FastAPI, set up middleware and lifecycle events
288
289
  if self.fastapi is not None:
@@ -383,7 +384,7 @@ class DBOS:
383
384
  evt = threading.Event()
384
385
  self.stop_events.append(evt)
385
386
  bg_queue_thread = threading.Thread(
386
- target=_queue_thread, args=(evt, self), daemon=True
387
+ target=queue_thread, args=(evt, self), daemon=True
387
388
  )
388
389
  bg_queue_thread.start()
389
390
  self._background_threads.append(bg_queue_thread)
@@ -8,6 +8,16 @@ T = TypeVar("T")
8
8
  R = TypeVar("R")
9
9
 
10
10
 
11
+ class NoResult:
12
+ _instance: Optional["NoResult"] = None
13
+ __slots__ = ()
14
+
15
+ def __new__(cls, *args: Any, **kwargs: Any) -> "NoResult":
16
+ if not cls._instance:
17
+ cls._instance = super(NoResult, cls).__new__(cls, *args, **kwargs)
18
+ return cls._instance
19
+
20
+
11
21
  # define Outcome protocol w/ common composition methods
12
22
  class Outcome(Protocol[T]):
13
23
 
@@ -28,7 +38,9 @@ class Outcome(Protocol[T]):
28
38
  exceeded_retries: Callable[[int], BaseException],
29
39
  ) -> "Outcome[T]": ...
30
40
 
31
- def intercept(self, interceptor: Callable[[], Optional[T]]) -> "Outcome[T]": ...
41
+ def intercept(
42
+ self, interceptor: Callable[[], Union[NoResult, T]]
43
+ ) -> "Outcome[T]": ...
32
44
 
33
45
  def __call__(self) -> Union[T, Coroutine[Any, Any, T]]: ...
34
46
 
@@ -58,11 +70,15 @@ class Immediate(Outcome[T]):
58
70
  return Immediate(lambda: before()(self._func))
59
71
 
60
72
  @staticmethod
61
- def _intercept(func: Callable[[], T], interceptor: Callable[[], Optional[T]]) -> T:
73
+ def _intercept(
74
+ func: Callable[[], T], interceptor: Callable[[], Union[NoResult, T]]
75
+ ) -> T:
62
76
  intercepted = interceptor()
63
- return intercepted if intercepted else func()
77
+ return intercepted if not isinstance(intercepted, NoResult) else func()
64
78
 
65
- def intercept(self, interceptor: Callable[[], Optional[T]]) -> "Immediate[T]":
79
+ def intercept(
80
+ self, interceptor: Callable[[], Union[NoResult, T]]
81
+ ) -> "Immediate[T]":
66
82
  return Immediate[T](lambda: Immediate._intercept(self._func, interceptor))
67
83
 
68
84
  @staticmethod
@@ -151,12 +167,12 @@ class Pending(Outcome[T]):
151
167
  @staticmethod
152
168
  async def _intercept(
153
169
  func: Callable[[], Coroutine[Any, Any, T]],
154
- interceptor: Callable[[], Optional[T]],
170
+ interceptor: Callable[[], Union[NoResult, T]],
155
171
  ) -> T:
156
172
  intercepted = await asyncio.to_thread(interceptor)
157
- return intercepted if intercepted else await func()
173
+ return intercepted if not isinstance(intercepted, NoResult) else await func()
158
174
 
159
- def intercept(self, interceptor: Callable[[], Optional[T]]) -> "Pending[T]":
175
+ def intercept(self, interceptor: Callable[[], Union[NoResult, T]]) -> "Pending[T]":
160
176
  return Pending[T](lambda: Pending._intercept(self._func, interceptor))
161
177
 
162
178
  @staticmethod
@@ -51,13 +51,13 @@ class Queue:
51
51
  return start_workflow(dbos, func, self.name, False, *args, **kwargs)
52
52
 
53
53
 
54
- def _queue_thread(stop_event: threading.Event, dbos: "DBOS") -> None:
54
+ def queue_thread(stop_event: threading.Event, dbos: "DBOS") -> None:
55
55
  while not stop_event.is_set():
56
56
  if stop_event.wait(timeout=1):
57
57
  return
58
58
  for _, queue in dbos._registry.queue_info_map.items():
59
59
  try:
60
- wf_ids = dbos._sys_db.start_queued_workflows(queue)
60
+ wf_ids = dbos._sys_db.start_queued_workflows(queue, dbos._executor_id)
61
61
  for id in wf_ids:
62
62
  execute_workflow_by_id(dbos, id)
63
63
  except Exception:
@@ -1104,7 +1104,7 @@ class SystemDatabase:
1104
1104
  .on_conflict_do_nothing()
1105
1105
  )
1106
1106
 
1107
- def start_queued_workflows(self, queue: "Queue") -> List[str]:
1107
+ def start_queued_workflows(self, queue: "Queue", executor_id: str) -> List[str]:
1108
1108
  start_time_ms = int(time.time() * 1000)
1109
1109
  if queue.limiter is not None:
1110
1110
  limiter_period_ms = int(queue.limiter["period"] * 1000)
@@ -1159,7 +1159,7 @@ class SystemDatabase:
1159
1159
  if len(ret_ids) + num_recent_queries >= queue.limiter["limit"]:
1160
1160
  break
1161
1161
 
1162
- # To start a function, first set its status to PENDING
1162
+ # To start a function, first set its status to PENDING and update its executor ID
1163
1163
  c.execute(
1164
1164
  SystemSchema.workflow_status.update()
1165
1165
  .where(SystemSchema.workflow_status.c.workflow_uuid == id)
@@ -1167,7 +1167,10 @@ class SystemDatabase:
1167
1167
  SystemSchema.workflow_status.c.status
1168
1168
  == WorkflowStatusString.ENQUEUED.value
1169
1169
  )
1170
- .values(status=WorkflowStatusString.PENDING.value)
1170
+ .values(
1171
+ status=WorkflowStatusString.PENDING.value,
1172
+ executor_id=executor_id,
1173
+ )
1171
1174
  )
1172
1175
 
1173
1176
  # Then give it a start time
@@ -23,7 +23,7 @@ dependencies = [
23
23
  ]
24
24
  requires-python = ">=3.9"
25
25
  readme = "README.md"
26
- version = "0.16.0a2"
26
+ version = "0.17.0"
27
27
 
28
28
  [project.license]
29
29
  text = "MIT"
@@ -318,6 +318,7 @@ def test_temp_workflow_errors(dbos: DBOS) -> None:
318
318
 
319
319
  def test_recovery_workflow(dbos: DBOS) -> None:
320
320
  txn_counter: int = 0
321
+ txn_return_none_counter: int = 0
321
322
  wf_counter: int = 0
322
323
 
323
324
  @DBOS.workflow()
@@ -325,6 +326,8 @@ def test_recovery_workflow(dbos: DBOS) -> None:
325
326
  nonlocal wf_counter
326
327
  wf_counter += 1
327
328
  res = test_transaction(var2)
329
+ should_be_none = test_transaction_return_none()
330
+ assert should_be_none is None
328
331
  return res + var
329
332
 
330
333
  @DBOS.transaction()
@@ -334,6 +337,13 @@ def test_recovery_workflow(dbos: DBOS) -> None:
334
337
  txn_counter += 1
335
338
  return var2 + str(rows[0][0])
336
339
 
340
+ @DBOS.transaction()
341
+ def test_transaction_return_none() -> None:
342
+ nonlocal txn_return_none_counter
343
+ DBOS.sql_session.execute(sa.text("SELECT 1")).fetchall()
344
+ txn_return_none_counter += 1
345
+ return
346
+
337
347
  wfuuid = str(uuid.uuid4())
338
348
  with SetWorkflowID(wfuuid):
339
349
  assert test_workflow("bob", "bob") == "bob1bob"
@@ -367,6 +377,123 @@ def test_recovery_workflow(dbos: DBOS) -> None:
367
377
  assert workflow_handles[0].get_result() == "bob1bob"
368
378
  assert wf_counter == 2
369
379
  assert txn_counter == 1
380
+ assert txn_return_none_counter == 1
381
+
382
+ # Test that there was a recovery attempt of this
383
+ stat = workflow_handles[0].get_status()
384
+ assert stat
385
+ assert stat.recovery_attempts == 1
386
+
387
+
388
+ def test_recovery_workflow_step(dbos: DBOS) -> None:
389
+ step_counter: int = 0
390
+ wf_counter: int = 0
391
+
392
+ @DBOS.workflow()
393
+ def test_workflow(var: str, var2: str) -> str:
394
+ nonlocal wf_counter
395
+ wf_counter += 1
396
+ should_be_none = test_step(var2)
397
+ assert should_be_none is None
398
+ return var
399
+
400
+ @DBOS.step()
401
+ def test_step(var2: str) -> None:
402
+ nonlocal step_counter
403
+ step_counter += 1
404
+ print(f"I'm a test_step {var2}!")
405
+ return
406
+
407
+ wfuuid = str(uuid.uuid4())
408
+ with SetWorkflowID(wfuuid):
409
+ assert test_workflow("bob", "bob") == "bob"
410
+
411
+ dbos._sys_db.wait_for_buffer_flush()
412
+ # Change the workflow status to pending
413
+ dbos._sys_db.update_workflow_status(
414
+ {
415
+ "workflow_uuid": wfuuid,
416
+ "status": "PENDING",
417
+ "name": test_workflow.__qualname__,
418
+ "class_name": None,
419
+ "config_name": None,
420
+ "output": None,
421
+ "error": None,
422
+ "executor_id": None,
423
+ "app_id": None,
424
+ "app_version": None,
425
+ "request": None,
426
+ "recovery_attempts": None,
427
+ "authenticated_user": None,
428
+ "authenticated_roles": None,
429
+ "assumed_role": None,
430
+ "queue_name": None,
431
+ }
432
+ )
433
+
434
+ # Recovery should execute the workflow again but skip the transaction
435
+ workflow_handles = DBOS.recover_pending_workflows()
436
+ assert len(workflow_handles) == 1
437
+ assert workflow_handles[0].get_result() == "bob"
438
+ assert wf_counter == 2
439
+ assert step_counter == 1
440
+
441
+ # Test that there was a recovery attempt of this
442
+ stat = workflow_handles[0].get_status()
443
+ assert stat
444
+ assert stat.recovery_attempts == 1
445
+
446
+
447
+ def test_workflow_returns_none(dbos: DBOS) -> None:
448
+ wf_counter: int = 0
449
+
450
+ @DBOS.workflow()
451
+ def test_workflow(var: str, var2: str) -> None:
452
+ nonlocal wf_counter
453
+ wf_counter += 1
454
+ assert var == var2 == "bob"
455
+ return
456
+
457
+ wfuuid = str(uuid.uuid4())
458
+ with SetWorkflowID(wfuuid):
459
+ assert test_workflow("bob", "bob") is None
460
+ assert wf_counter == 1
461
+
462
+ dbos._sys_db.wait_for_buffer_flush()
463
+ with SetWorkflowID(wfuuid):
464
+ assert test_workflow("bob", "bob") is None
465
+ assert wf_counter == 2
466
+
467
+ handle: WorkflowHandle[None] = DBOS.retrieve_workflow(wfuuid)
468
+ assert handle.get_result() == None
469
+ assert wf_counter == 2
470
+
471
+ # Change the workflow status to pending
472
+ dbos._sys_db.update_workflow_status(
473
+ {
474
+ "workflow_uuid": wfuuid,
475
+ "status": "PENDING",
476
+ "name": test_workflow.__qualname__,
477
+ "class_name": None,
478
+ "config_name": None,
479
+ "output": None,
480
+ "error": None,
481
+ "executor_id": None,
482
+ "app_id": None,
483
+ "app_version": None,
484
+ "request": None,
485
+ "recovery_attempts": None,
486
+ "authenticated_user": None,
487
+ "authenticated_roles": None,
488
+ "assumed_role": None,
489
+ "queue_name": None,
490
+ }
491
+ )
492
+
493
+ workflow_handles = DBOS.recover_pending_workflows()
494
+ assert len(workflow_handles) == 1
495
+ assert workflow_handles[0].get_result() is None
496
+ assert wf_counter == 3
370
497
 
371
498
  # Test that there was a recovery attempt of this
372
499
  stat = workflow_handles[0].get_status()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes