dbos 1.14.0a4__tar.gz → 1.14.0a6__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.
Files changed (118) hide show
  1. {dbos-1.14.0a4 → dbos-1.14.0a6}/PKG-INFO +1 -1
  2. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/__init__.py +3 -0
  3. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_client.py +17 -9
  4. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_context.py +15 -2
  5. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_core.py +20 -6
  6. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_dbos.py +9 -6
  7. dbos-1.14.0a6/dbos/_debouncer.py +394 -0
  8. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_logger.py +1 -1
  9. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_serialization.py +7 -2
  10. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_sys_db.py +35 -2
  11. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_tracer.py +7 -0
  12. {dbos-1.14.0a4 → dbos-1.14.0a6}/pyproject.toml +2 -1
  13. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_dbos.py +24 -8
  14. dbos-1.14.0a6/tests/test_debouncer.py +271 -0
  15. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_queue.py +38 -1
  16. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_spans.py +128 -34
  17. {dbos-1.14.0a4 → dbos-1.14.0a6}/LICENSE +0 -0
  18. {dbos-1.14.0a4 → dbos-1.14.0a6}/README.md +0 -0
  19. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/__main__.py +0 -0
  20. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_admin_server.py +0 -0
  21. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/env.py +0 -0
  22. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/script.py.mako +0 -0
  23. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/01ce9f07bd10_streaming.py +0 -0
  24. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  25. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/27ac6900c6ad_add_queue_dedup.py +0 -0
  26. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/471b60d64126_dbos_migrations.py +0 -0
  27. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  28. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  29. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/66478e1b95e5_consolidate_queues.py +0 -0
  30. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/83f3732ae8e7_workflow_timeout.py +0 -0
  31. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/933e86bdac6a_add_queue_priority.py +0 -0
  32. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  33. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  34. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  35. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/d994145b47b6_consolidate_inputs.py +0 -0
  36. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  37. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_alembic_migrations/versions/f4b9b32ba814_functionname_childid_op_outputs.py +0 -0
  38. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_app_db.py +0 -0
  39. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_classproperty.py +0 -0
  40. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_conductor/conductor.py +0 -0
  41. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_conductor/protocol.py +0 -0
  42. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_croniter.py +0 -0
  43. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_dbos_config.py +0 -0
  44. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_debug.py +0 -0
  45. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_docker_pg_helper.py +0 -0
  46. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_error.py +0 -0
  47. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_event_loop.py +0 -0
  48. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_fastapi.py +0 -0
  49. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_flask.py +0 -0
  50. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_kafka.py +0 -0
  51. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_kafka_message.py +0 -0
  52. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_migration.py +0 -0
  53. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_outcome.py +0 -0
  54. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_queue.py +0 -0
  55. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_recovery.py +0 -0
  56. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_registrations.py +0 -0
  57. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_roles.py +0 -0
  58. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_scheduler.py +0 -0
  59. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_schemas/__init__.py +0 -0
  60. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_schemas/application_database.py +0 -0
  61. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_schemas/system_database.py +0 -0
  62. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_sys_db_postgres.py +0 -0
  63. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_sys_db_sqlite.py +0 -0
  64. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/README.md +0 -0
  65. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  66. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/__package/main.py.dbos +0 -0
  67. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  68. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  69. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  70. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  71. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  72. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  73. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  74. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_utils.py +0 -0
  75. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/_workflow_commands.py +0 -0
  76. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/cli/_github_init.py +0 -0
  77. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/cli/_template_init.py +0 -0
  78. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/cli/cli.py +0 -0
  79. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/cli/migration.py +0 -0
  80. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/dbos-config.schema.json +0 -0
  81. {dbos-1.14.0a4 → dbos-1.14.0a6}/dbos/py.typed +0 -0
  82. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/__init__.py +0 -0
  83. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/atexit_no_ctor.py +0 -0
  84. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/atexit_no_launch.py +0 -0
  85. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/classdefs.py +0 -0
  86. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/client_collateral.py +0 -0
  87. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/client_worker.py +0 -0
  88. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/conftest.py +0 -0
  89. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/dupname_classdefs1.py +0 -0
  90. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/dupname_classdefsa.py +0 -0
  91. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/more_classdefs.py +0 -0
  92. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/queuedworkflow.py +0 -0
  93. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_admin_server.py +0 -0
  94. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_async.py +0 -0
  95. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_async_workflow_management.py +0 -0
  96. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_classdecorators.py +0 -0
  97. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_cli.py +0 -0
  98. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_client.py +0 -0
  99. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_concurrency.py +0 -0
  100. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_config.py +0 -0
  101. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_croniter.py +0 -0
  102. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_debug.py +0 -0
  103. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_docker_secrets.py +0 -0
  104. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_failures.py +0 -0
  105. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_fastapi.py +0 -0
  106. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_fastapi_roles.py +0 -0
  107. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_flask.py +0 -0
  108. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_kafka.py +0 -0
  109. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_outcome.py +0 -0
  110. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_package.py +0 -0
  111. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_scheduler.py +0 -0
  112. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_schema_migration.py +0 -0
  113. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_singleton.py +0 -0
  114. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_sqlalchemy.py +0 -0
  115. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_streaming.py +0 -0
  116. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_workflow_introspection.py +0 -0
  117. {dbos-1.14.0a4 → dbos-1.14.0a6}/tests/test_workflow_management.py +0 -0
  118. {dbos-1.14.0a4 → dbos-1.14.0a6}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 1.14.0a4
3
+ Version: 1.14.0a6
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -9,6 +9,7 @@ from ._context import (
9
9
  )
10
10
  from ._dbos import DBOS, DBOSConfiguredInstance, WorkflowHandle, WorkflowHandleAsync
11
11
  from ._dbos_config import DBOSConfig
12
+ from ._debouncer import Debouncer, DebouncerClient
12
13
  from ._kafka_message import KafkaMessage
13
14
  from ._queue import Queue
14
15
  from ._sys_db import GetWorkflowsInput, WorkflowStatus, WorkflowStatusString
@@ -32,4 +33,6 @@ __all__ = [
32
33
  "WorkflowStatusString",
33
34
  "error",
34
35
  "Queue",
36
+ "Debouncer",
37
+ "DebouncerClient",
35
38
  ]
@@ -3,6 +3,7 @@ import sys
3
3
  import time
4
4
  import uuid
5
5
  from typing import (
6
+ TYPE_CHECKING,
6
7
  Any,
7
8
  AsyncGenerator,
8
9
  Generator,
@@ -24,7 +25,10 @@ else:
24
25
  from typing import NotRequired
25
26
 
26
27
  from dbos import _serialization
27
- from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
28
+
29
+ if TYPE_CHECKING:
30
+ from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
31
+
28
32
  from dbos._dbos_config import (
29
33
  get_application_database_url,
30
34
  get_system_database_url,
@@ -224,23 +228,25 @@ class DBOSClient:
224
228
 
225
229
  def enqueue(
226
230
  self, options: EnqueueOptions, *args: Any, **kwargs: Any
227
- ) -> WorkflowHandle[R]:
231
+ ) -> "WorkflowHandle[R]":
228
232
  workflow_id = self._enqueue(options, *args, **kwargs)
229
233
  return WorkflowHandleClientPolling[R](workflow_id, self._sys_db)
230
234
 
231
235
  async def enqueue_async(
232
236
  self, options: EnqueueOptions, *args: Any, **kwargs: Any
233
- ) -> WorkflowHandleAsync[R]:
237
+ ) -> "WorkflowHandleAsync[R]":
234
238
  workflow_id = await asyncio.to_thread(self._enqueue, options, *args, **kwargs)
235
239
  return WorkflowHandleClientAsyncPolling[R](workflow_id, self._sys_db)
236
240
 
237
- def retrieve_workflow(self, workflow_id: str) -> WorkflowHandle[R]:
241
+ def retrieve_workflow(self, workflow_id: str) -> "WorkflowHandle[R]":
238
242
  status = get_workflow(self._sys_db, workflow_id)
239
243
  if status is None:
240
244
  raise DBOSNonExistentWorkflowError(workflow_id)
241
245
  return WorkflowHandleClientPolling[R](workflow_id, self._sys_db)
242
246
 
243
- async def retrieve_workflow_async(self, workflow_id: str) -> WorkflowHandleAsync[R]:
247
+ async def retrieve_workflow_async(
248
+ self, workflow_id: str
249
+ ) -> "WorkflowHandleAsync[R]":
244
250
  status = await asyncio.to_thread(get_workflow, self._sys_db, workflow_id)
245
251
  if status is None:
246
252
  raise DBOSNonExistentWorkflowError(workflow_id)
@@ -311,11 +317,13 @@ class DBOSClient:
311
317
  async def cancel_workflow_async(self, workflow_id: str) -> None:
312
318
  await asyncio.to_thread(self.cancel_workflow, workflow_id)
313
319
 
314
- def resume_workflow(self, workflow_id: str) -> WorkflowHandle[Any]:
320
+ def resume_workflow(self, workflow_id: str) -> "WorkflowHandle[Any]":
315
321
  self._sys_db.resume_workflow(workflow_id)
316
322
  return WorkflowHandleClientPolling[Any](workflow_id, self._sys_db)
317
323
 
318
- async def resume_workflow_async(self, workflow_id: str) -> WorkflowHandleAsync[Any]:
324
+ async def resume_workflow_async(
325
+ self, workflow_id: str
326
+ ) -> "WorkflowHandleAsync[Any]":
319
327
  await asyncio.to_thread(self.resume_workflow, workflow_id)
320
328
  return WorkflowHandleClientAsyncPolling[Any](workflow_id, self._sys_db)
321
329
 
@@ -451,7 +459,7 @@ class DBOSClient:
451
459
  start_step: int,
452
460
  *,
453
461
  application_version: Optional[str] = None,
454
- ) -> WorkflowHandle[Any]:
462
+ ) -> "WorkflowHandle[Any]":
455
463
  forked_workflow_id = fork_workflow(
456
464
  self._sys_db,
457
465
  self._app_db,
@@ -467,7 +475,7 @@ class DBOSClient:
467
475
  start_step: int,
468
476
  *,
469
477
  application_version: Optional[str] = None,
470
- ) -> WorkflowHandleAsync[Any]:
478
+ ) -> "WorkflowHandleAsync[Any]":
471
479
  forked_workflow_id = await asyncio.to_thread(
472
480
  fork_workflow,
473
481
  self._sys_db,
@@ -215,11 +215,18 @@ class DBOSContext:
215
215
  def end_handler(self, exc_value: Optional[BaseException]) -> None:
216
216
  self._end_span(exc_value)
217
217
 
218
- def get_current_span(self) -> Optional[Span]:
218
+ """ Return the current DBOS span if any. It must be a span created by DBOS."""
219
+
220
+ def get_current_dbos_span(self) -> Optional[Span]:
219
221
  if len(self.context_spans) > 0:
220
222
  return self.context_spans[-1].span
221
223
  return None
222
224
 
225
+ """ Return the current active span if any. It might not be a DBOS span."""
226
+
227
+ def get_current_active_span(self) -> Optional[Span]:
228
+ return dbos_tracer.get_current_span()
229
+
223
230
  def _start_span(self, attributes: TracedAttributes) -> None:
224
231
  if dbos_tracer.disable_otlp:
225
232
  return
@@ -235,7 +242,7 @@ class DBOSContext:
235
242
  attributes["authenticatedUserAssumedRole"] = self.assumed_role
236
243
  span = dbos_tracer.start_span(
237
244
  attributes,
238
- parent=self.context_spans[-1].span if len(self.context_spans) > 0 else None,
245
+ parent=None, # It'll use the current active span as the parent
239
246
  )
240
247
  # Activate the current span
241
248
  cm = use_span(
@@ -517,6 +524,7 @@ class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
517
524
  self.saved_workflow_timeout: Optional[int] = None
518
525
  self.saved_deduplication_id: Optional[str] = None
519
526
  self.saved_priority: Optional[int] = None
527
+ self.saved_is_within_set_workflow_id_block: bool = False
520
528
 
521
529
  def __enter__(self) -> DBOSContext:
522
530
  # Code to create a basic context
@@ -526,6 +534,9 @@ class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
526
534
  ctx = DBOSContext()
527
535
  _set_local_dbos_context(ctx)
528
536
  assert not ctx.is_within_workflow()
537
+ # Unset is_within_set_workflow_id_block as the workflow is not within a block
538
+ self.saved_is_within_set_workflow_id_block = ctx.is_within_set_workflow_id_block
539
+ ctx.is_within_set_workflow_id_block = False
529
540
  # Unset the workflow_timeout_ms context var so it is not applied to this
530
541
  # workflow's children (instead we propagate the deadline)
531
542
  self.saved_workflow_timeout = ctx.workflow_timeout_ms
@@ -550,6 +561,8 @@ class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
550
561
  ctx = assert_current_dbos_context()
551
562
  assert ctx.is_within_workflow()
552
563
  ctx.end_workflow(exc_value)
564
+ # Restore is_within_set_workflow_id_block
565
+ ctx.is_within_set_workflow_id_block = self.saved_is_within_set_workflow_id_block
553
566
  # Restore the saved workflow timeout
554
567
  ctx.workflow_timeout_ms = self.saved_workflow_timeout
555
568
  # Clear any propagating timeout
@@ -50,6 +50,7 @@ from ._error import (
50
50
  DBOSException,
51
51
  DBOSMaxStepRetriesExceeded,
52
52
  DBOSNonExistentWorkflowError,
53
+ DBOSQueueDeduplicatedError,
53
54
  DBOSRecoveryError,
54
55
  DBOSUnexpectedStepError,
55
56
  DBOSWorkflowCancelledError,
@@ -95,6 +96,7 @@ R = TypeVar("R", covariant=True) # A generic type for workflow return values
95
96
  F = TypeVar("F", bound=Callable[..., Any])
96
97
 
97
98
  TEMP_SEND_WF_NAME = "<temp>.temp_send_workflow"
99
+ DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"
98
100
 
99
101
 
100
102
  def check_is_in_coroutine() -> bool:
@@ -310,10 +312,22 @@ def _init_workflow(
310
312
  }
311
313
 
312
314
  # Synchronously record the status and inputs for workflows
313
- wf_status, workflow_deadline_epoch_ms = dbos._sys_db.init_workflow(
314
- status,
315
- max_recovery_attempts=max_recovery_attempts,
316
- )
315
+ try:
316
+ wf_status, workflow_deadline_epoch_ms = dbos._sys_db.init_workflow(
317
+ status,
318
+ max_recovery_attempts=max_recovery_attempts,
319
+ )
320
+ except DBOSQueueDeduplicatedError as e:
321
+ if ctx.has_parent():
322
+ result: OperationResultInternal = {
323
+ "workflow_uuid": ctx.parent_workflow_id,
324
+ "function_id": ctx.parent_workflow_fid,
325
+ "function_name": wf_name,
326
+ "output": None,
327
+ "error": _serialization.serialize_exception(e),
328
+ }
329
+ dbos._sys_db.record_operation_result(result)
330
+ raise
317
331
 
318
332
  if workflow_deadline_epoch_ms is not None:
319
333
  evt = threading.Event()
@@ -971,7 +985,7 @@ def decorate_transaction(
971
985
  dbapi_error
972
986
  ) or dbos._app_db._is_serialization_error(dbapi_error):
973
987
  # Retry on serialization failure
974
- span = ctx.get_current_span()
988
+ span = ctx.get_current_dbos_span()
975
989
  if span:
976
990
  span.add_event(
977
991
  "Transaction Failure",
@@ -1090,7 +1104,7 @@ def decorate_step(
1090
1104
  exc_info=error,
1091
1105
  )
1092
1106
  ctx = assert_current_dbos_context()
1093
- span = ctx.get_current_span()
1107
+ span = ctx.get_current_dbos_span()
1094
1108
  if span:
1095
1109
  span.add_event(
1096
1110
  f"Step attempt {attempt} failed",
@@ -32,12 +32,14 @@ from opentelemetry.trace import Span
32
32
  from rich import print
33
33
 
34
34
  from dbos._conductor.conductor import ConductorWebsocket
35
+ from dbos._debouncer import debouncer_workflow
35
36
  from dbos._sys_db import SystemDatabase, WorkflowStatus
36
37
  from dbos._utils import INTERNAL_QUEUE_NAME, GlobalParams
37
38
  from dbos._workflow_commands import fork_workflow, list_queued_workflows, list_workflows
38
39
 
39
40
  from ._classproperty import classproperty
40
41
  from ._core import (
42
+ DEBOUNCER_WORKFLOW_NAME,
41
43
  TEMP_SEND_WF_NAME,
42
44
  WorkflowHandleAsyncPolling,
43
45
  WorkflowHandlePolling,
@@ -390,11 +392,12 @@ class DBOS:
390
392
  ) -> None:
391
393
  self.send(destination_id, message, topic)
392
394
 
393
- temp_send_wf = workflow_wrapper(self._registry, send_temp_workflow)
394
- set_dbos_func_name(send_temp_workflow, TEMP_SEND_WF_NAME)
395
- set_dbos_func_name(temp_send_wf, TEMP_SEND_WF_NAME)
396
- set_temp_workflow_type(send_temp_workflow, "send")
397
- self._registry.register_wf_function(TEMP_SEND_WF_NAME, temp_send_wf, "send")
395
+ decorate_workflow(self._registry, TEMP_SEND_WF_NAME, None)(send_temp_workflow)
396
+
397
+ # Register the debouncer workflow
398
+ decorate_workflow(self._registry, DEBOUNCER_WORKFLOW_NAME, None)(
399
+ debouncer_workflow
400
+ )
398
401
 
399
402
  for handler in dbos_logger.handlers:
400
403
  handler.flush()
@@ -1297,7 +1300,7 @@ class DBOS:
1297
1300
  def span(cls) -> Span:
1298
1301
  """Return the tracing `Span` associated with the current context."""
1299
1302
  ctx = assert_current_dbos_context()
1300
- span = ctx.get_current_span()
1303
+ span = ctx.get_current_active_span()
1301
1304
  assert span
1302
1305
  return span
1303
1306
 
@@ -0,0 +1,394 @@
1
+ import asyncio
2
+ import math
3
+ import sys
4
+ import time
5
+ import types
6
+ import uuid
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ Any,
10
+ Callable,
11
+ Coroutine,
12
+ Dict,
13
+ Generic,
14
+ Optional,
15
+ Tuple,
16
+ TypedDict,
17
+ TypeVar,
18
+ Union,
19
+ )
20
+
21
+ if sys.version_info < (3, 10):
22
+ from typing_extensions import ParamSpec
23
+ else:
24
+ from typing import ParamSpec
25
+
26
+ from dbos._client import (
27
+ DBOSClient,
28
+ EnqueueOptions,
29
+ WorkflowHandleClientAsyncPolling,
30
+ WorkflowHandleClientPolling,
31
+ )
32
+ from dbos._context import (
33
+ DBOSContextEnsure,
34
+ SetEnqueueOptions,
35
+ SetWorkflowID,
36
+ SetWorkflowTimeout,
37
+ assert_current_dbos_context,
38
+ )
39
+ from dbos._core import (
40
+ DEBOUNCER_WORKFLOW_NAME,
41
+ WorkflowHandleAsyncPolling,
42
+ WorkflowHandlePolling,
43
+ )
44
+ from dbos._error import DBOSQueueDeduplicatedError
45
+ from dbos._queue import Queue
46
+ from dbos._registrations import get_dbos_func_name
47
+ from dbos._serialization import WorkflowInputs
48
+ from dbos._utils import INTERNAL_QUEUE_NAME
49
+
50
+ if TYPE_CHECKING:
51
+ from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
52
+
53
+ P = ParamSpec("P") # A generic type for workflow parameters
54
+ R = TypeVar("R", covariant=True) # A generic type for workflow return values
55
+
56
+
57
+ _DEBOUNCER_TOPIC = "DEBOUNCER_TOPIC"
58
+
59
+
60
+ # Options saved from the local context to pass through to the debounced function
61
+ class ContextOptions(TypedDict):
62
+ workflow_id: str
63
+ deduplication_id: Optional[str]
64
+ priority: Optional[int]
65
+ app_version: Optional[str]
66
+ workflow_timeout_sec: Optional[float]
67
+
68
+
69
+ # Parameters for the debouncer workflow
70
+ class DebouncerOptions(TypedDict):
71
+ workflow_name: str
72
+ debounce_timeout_sec: Optional[float]
73
+ queue_name: Optional[str]
74
+
75
+
76
+ # The message sent from a debounce to the debouncer workflow
77
+ class DebouncerMessage(TypedDict):
78
+ inputs: WorkflowInputs
79
+ message_id: str
80
+ debounce_period_sec: float
81
+
82
+
83
+ def debouncer_workflow(
84
+ initial_debounce_period_sec: float,
85
+ ctx: ContextOptions,
86
+ options: DebouncerOptions,
87
+ *args: Tuple[Any, ...],
88
+ **kwargs: Dict[str, Any],
89
+ ) -> None:
90
+ from dbos._dbos import DBOS, _get_dbos_instance
91
+
92
+ dbos = _get_dbos_instance()
93
+
94
+ workflow_inputs: WorkflowInputs = {"args": args, "kwargs": kwargs}
95
+ # Every time the debounced workflow is called, a message is sent to this workflow.
96
+ # It waits until debounce_period_sec have passed since the last message or until
97
+ # debounce_timeout_sec has elapsed.
98
+ debounce_deadline_epoch_sec = (
99
+ time.time() + options["debounce_timeout_sec"]
100
+ if options["debounce_timeout_sec"]
101
+ else math.inf
102
+ )
103
+ debounce_period_sec = initial_debounce_period_sec
104
+ while time.time() < debounce_deadline_epoch_sec:
105
+ time_until_deadline = max(debounce_deadline_epoch_sec - time.time(), 0)
106
+ timeout = min(debounce_period_sec, time_until_deadline)
107
+ message: DebouncerMessage = DBOS.recv(_DEBOUNCER_TOPIC, timeout_seconds=timeout)
108
+ if message is None:
109
+ break
110
+ else:
111
+ workflow_inputs = message["inputs"]
112
+ debounce_period_sec = message["debounce_period_sec"]
113
+ # Acknowledge receipt of the message
114
+ DBOS.set_event(message["message_id"], message["message_id"])
115
+ # After the timeout or period has elapsed, start the user workflow with the requested context parameters,
116
+ # either directly or on a queue.
117
+ with SetWorkflowID(ctx["workflow_id"]):
118
+ with SetWorkflowTimeout(ctx["workflow_timeout_sec"]):
119
+ func = dbos._registry.workflow_info_map.get(options["workflow_name"], None)
120
+ if not func:
121
+ raise Exception(
122
+ f"Invalid workflow name provided to debouncer: {options['workflow_name']}"
123
+ )
124
+ if options["queue_name"]:
125
+ queue = dbos._registry.queue_info_map.get(options["queue_name"], None)
126
+ if not queue:
127
+ raise Exception(
128
+ f"Invalid queue name provided to debouncer: {options['queue_name']}"
129
+ )
130
+ with SetEnqueueOptions(
131
+ deduplication_id=ctx["deduplication_id"],
132
+ priority=ctx["priority"],
133
+ app_version=ctx["app_version"],
134
+ ):
135
+ queue.enqueue(
136
+ func, *workflow_inputs["args"], **workflow_inputs["kwargs"]
137
+ )
138
+ else:
139
+ DBOS.start_workflow(
140
+ func, *workflow_inputs["args"], **workflow_inputs["kwargs"]
141
+ )
142
+
143
+
144
+ class Debouncer(Generic[P, R]):
145
+
146
+ def __init__(
147
+ self,
148
+ workflow_name: str,
149
+ *,
150
+ debounce_key: str,
151
+ debounce_timeout_sec: Optional[float] = None,
152
+ queue: Optional[Queue] = None,
153
+ ):
154
+ self.func_name = workflow_name
155
+ self.options: DebouncerOptions = {
156
+ "debounce_timeout_sec": debounce_timeout_sec,
157
+ "queue_name": queue.name if queue else None,
158
+ "workflow_name": workflow_name,
159
+ }
160
+ self.debounce_key = debounce_key
161
+
162
+ @staticmethod
163
+ def create(
164
+ workflow: Callable[P, R],
165
+ *,
166
+ debounce_key: str,
167
+ debounce_timeout_sec: Optional[float] = None,
168
+ queue: Optional[Queue] = None,
169
+ ) -> "Debouncer[P, R]":
170
+
171
+ if isinstance(workflow, (types.MethodType)):
172
+ raise TypeError("Only workflow functions may be debounced, not methods")
173
+ return Debouncer[P, R](
174
+ get_dbos_func_name(workflow),
175
+ debounce_key=debounce_key,
176
+ debounce_timeout_sec=debounce_timeout_sec,
177
+ queue=queue,
178
+ )
179
+
180
+ @staticmethod
181
+ def create_async(
182
+ workflow: Callable[P, Coroutine[Any, Any, R]],
183
+ *,
184
+ debounce_key: str,
185
+ debounce_timeout_sec: Optional[float] = None,
186
+ queue: Optional[Queue] = None,
187
+ ) -> "Debouncer[P, R]":
188
+
189
+ if isinstance(workflow, (types.MethodType)):
190
+ raise TypeError("Only workflow functions may be debounced, not methods")
191
+ return Debouncer[P, R](
192
+ get_dbos_func_name(workflow),
193
+ debounce_key=debounce_key,
194
+ debounce_timeout_sec=debounce_timeout_sec,
195
+ queue=queue,
196
+ )
197
+
198
+ def debounce(
199
+ self, debounce_period_sec: float, *args: P.args, **kwargs: P.kwargs
200
+ ) -> "WorkflowHandle[R]":
201
+ from dbos._dbos import DBOS, _get_dbos_instance
202
+
203
+ dbos = _get_dbos_instance()
204
+ internal_queue = dbos._registry.get_internal_queue()
205
+
206
+ # Read all workflow settings from context, pass them through ContextOptions
207
+ # into the debouncer to apply to the user workflow, then reset the context
208
+ # so workflow settings aren't applied to the debouncer.
209
+ with DBOSContextEnsure():
210
+ ctx = assert_current_dbos_context()
211
+
212
+ # Deterministically generate the user workflow ID and message ID
213
+ def assign_debounce_ids() -> tuple[str, str]:
214
+ return str(uuid.uuid4()), ctx.assign_workflow_id()
215
+
216
+ message_id, user_workflow_id = dbos._sys_db.call_function_as_step(
217
+ assign_debounce_ids, "DBOS.assign_debounce_ids"
218
+ )
219
+ ctx.id_assigned_for_next_workflow = ""
220
+ ctx.is_within_set_workflow_id_block = False
221
+ ctxOptions: ContextOptions = {
222
+ "workflow_id": user_workflow_id,
223
+ "app_version": ctx.app_version,
224
+ "deduplication_id": ctx.deduplication_id,
225
+ "priority": ctx.priority,
226
+ "workflow_timeout_sec": (
227
+ ctx.workflow_timeout_ms / 1000.0
228
+ if ctx.workflow_timeout_ms
229
+ else None
230
+ ),
231
+ }
232
+ while True:
233
+ try:
234
+ # Attempt to enqueue a debouncer for this workflow.
235
+ with SetEnqueueOptions(deduplication_id=self.debounce_key):
236
+ with SetWorkflowTimeout(None):
237
+ internal_queue.enqueue(
238
+ debouncer_workflow,
239
+ debounce_period_sec,
240
+ ctxOptions,
241
+ self.options,
242
+ *args,
243
+ **kwargs,
244
+ )
245
+ return WorkflowHandlePolling(user_workflow_id, dbos)
246
+ except DBOSQueueDeduplicatedError:
247
+ # If there is already a debouncer, send a message to it.
248
+ # Deterministically retrieve the ID of the debouncer
249
+ def get_deduplicated_workflow() -> Optional[str]:
250
+ return dbos._sys_db.get_deduplicated_workflow(
251
+ queue_name=internal_queue.name,
252
+ deduplication_id=self.debounce_key,
253
+ )
254
+
255
+ dedup_wfid = dbos._sys_db.call_function_as_step(
256
+ get_deduplicated_workflow, "DBOS.get_deduplicated_workflow"
257
+ )
258
+ if dedup_wfid is None:
259
+ continue
260
+ else:
261
+ workflow_inputs: WorkflowInputs = {"args": args, "kwargs": kwargs}
262
+ message: DebouncerMessage = {
263
+ "message_id": message_id,
264
+ "inputs": workflow_inputs,
265
+ "debounce_period_sec": debounce_period_sec,
266
+ }
267
+ DBOS.send(dedup_wfid, message, _DEBOUNCER_TOPIC)
268
+ # Wait for the debouncer to acknowledge receipt of the message.
269
+ # If the message is not acknowledged, this likely means the debouncer started its workflow
270
+ # and exited without processing this message, so try again.
271
+ if not DBOS.get_event(dedup_wfid, message_id, timeout_seconds=1):
272
+ continue
273
+ # Retrieve the user workflow ID from the input to the debouncer
274
+ # and return a handle to it
275
+ dedup_workflow_input = (
276
+ DBOS.retrieve_workflow(dedup_wfid).get_status().input
277
+ )
278
+ assert dedup_workflow_input is not None
279
+ user_workflow_id = dedup_workflow_input["args"][1]["workflow_id"]
280
+ return WorkflowHandlePolling(user_workflow_id, dbos)
281
+
282
+ async def debounce_async(
283
+ self,
284
+ debounce_period_sec: float,
285
+ *args: P.args,
286
+ **kwargs: P.kwargs,
287
+ ) -> "WorkflowHandleAsync[R]":
288
+ from dbos._dbos import _get_dbos_instance
289
+
290
+ dbos = _get_dbos_instance()
291
+ handle = await asyncio.to_thread(
292
+ self.debounce, debounce_period_sec, *args, **kwargs
293
+ )
294
+ return WorkflowHandleAsyncPolling(handle.workflow_id, dbos)
295
+
296
+
297
+ class DebouncerClient:
298
+
299
+ def __init__(
300
+ self,
301
+ client: DBOSClient,
302
+ workflow_options: EnqueueOptions,
303
+ *,
304
+ debounce_key: str,
305
+ debounce_timeout_sec: Optional[float] = None,
306
+ queue: Optional[Queue] = None,
307
+ ):
308
+ self.workflow_options = workflow_options
309
+ self.debouncer_options: DebouncerOptions = {
310
+ "debounce_timeout_sec": debounce_timeout_sec,
311
+ "queue_name": queue.name if queue else None,
312
+ "workflow_name": workflow_options["workflow_name"],
313
+ }
314
+ self.debounce_key = debounce_key
315
+ self.client = client
316
+
317
+ def debounce(
318
+ self, debounce_period_sec: float, *args: Any, **kwargs: Any
319
+ ) -> "WorkflowHandle[R]":
320
+
321
+ ctxOptions: ContextOptions = {
322
+ "workflow_id": (
323
+ self.workflow_options["workflow_id"]
324
+ if self.workflow_options.get("workflow_id")
325
+ else str(uuid.uuid4())
326
+ ),
327
+ "app_version": self.workflow_options.get("app_version"),
328
+ "deduplication_id": self.workflow_options.get("deduplication_id"),
329
+ "priority": self.workflow_options.get("priority"),
330
+ "workflow_timeout_sec": self.workflow_options.get("workflow_timeout"),
331
+ }
332
+ message_id = str(uuid.uuid4())
333
+ while True:
334
+ try:
335
+ # Attempt to enqueue a debouncer for this workflow.
336
+ debouncer_options: EnqueueOptions = {
337
+ "workflow_name": DEBOUNCER_WORKFLOW_NAME,
338
+ "queue_name": INTERNAL_QUEUE_NAME,
339
+ "deduplication_id": self.debounce_key,
340
+ }
341
+ self.client.enqueue(
342
+ debouncer_options,
343
+ debounce_period_sec,
344
+ ctxOptions,
345
+ self.debouncer_options,
346
+ *args,
347
+ **kwargs,
348
+ )
349
+ return WorkflowHandleClientPolling[R](
350
+ ctxOptions["workflow_id"], self.client._sys_db
351
+ )
352
+ except DBOSQueueDeduplicatedError:
353
+ # If there is already a debouncer, send a message to it.
354
+ dedup_wfid = self.client._sys_db.get_deduplicated_workflow(
355
+ queue_name=INTERNAL_QUEUE_NAME,
356
+ deduplication_id=self.debounce_key,
357
+ )
358
+ if dedup_wfid is None:
359
+ continue
360
+ else:
361
+ workflow_inputs: WorkflowInputs = {"args": args, "kwargs": kwargs}
362
+ message: DebouncerMessage = {
363
+ "message_id": message_id,
364
+ "inputs": workflow_inputs,
365
+ "debounce_period_sec": debounce_period_sec,
366
+ }
367
+ self.client.send(dedup_wfid, message, _DEBOUNCER_TOPIC)
368
+ # Wait for the debouncer to acknowledge receipt of the message.
369
+ # If the message is not acknowledged, this likely means the debouncer started its workflow
370
+ # and exited without processing this message, so try again.
371
+ if not self.client.get_event(
372
+ dedup_wfid, message_id, timeout_seconds=1
373
+ ):
374
+ continue
375
+ # Retrieve the user workflow ID from the input to the debouncer
376
+ # and return a handle to it
377
+ dedup_workflow_input = (
378
+ self.client.retrieve_workflow(dedup_wfid).get_status().input
379
+ )
380
+ assert dedup_workflow_input is not None
381
+ user_workflow_id = dedup_workflow_input["args"][1]["workflow_id"]
382
+ return WorkflowHandleClientPolling[R](
383
+ user_workflow_id, self.client._sys_db
384
+ )
385
+
386
+ async def debounce_async(
387
+ self, debounce_period_sec: float, *args: Any, **kwargs: Any
388
+ ) -> "WorkflowHandleAsync[R]":
389
+ handle: "WorkflowHandle[R]" = await asyncio.to_thread(
390
+ self.debounce, debounce_period_sec, *args, **kwargs
391
+ )
392
+ return WorkflowHandleClientAsyncPolling[R](
393
+ handle.workflow_id, self.client._sys_db
394
+ )
@@ -39,7 +39,7 @@ class DBOSLogTransformer(logging.Filter):
39
39
  if ctx:
40
40
  if ctx.is_within_workflow():
41
41
  record.operationUUID = ctx.workflow_id
42
- span = ctx.get_current_span()
42
+ span = ctx.get_current_active_span()
43
43
  if span:
44
44
  trace_id = format_trace_id(span.get_span_context().trace_id)
45
45
  record.traceId = trace_id
@@ -12,8 +12,13 @@ class WorkflowInputs(TypedDict):
12
12
 
13
13
 
14
14
  def _validate_item(data: Any) -> None:
15
- if isinstance(data, (types.FunctionType, types.MethodType)):
16
- raise TypeError("Serialized data item should not be a function")
15
+ if isinstance(data, (types.MethodType)):
16
+ raise TypeError("Serialized data item should not be a class method")
17
+ if isinstance(data, (types.FunctionType)):
18
+ if jsonpickle.decode(jsonpickle.encode(data, unpicklable=True)) is None:
19
+ raise TypeError(
20
+ "Serialized function should be defined at the top level of a module"
21
+ )
17
22
 
18
23
 
19
24
  def serialize(data: Any) -> str: