dbos 0.6.1__py3-none-any.whl → 0.7.0__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.

Potentially problematic release.


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

dbos/error.py CHANGED
@@ -32,7 +32,6 @@ class DBOSErrorCode(Enum):
32
32
  InitializationError = 3
33
33
  WorkflowFunctionNotFound = 4
34
34
  NonExistentWorkflowError = 5
35
- DuplicateWorkflowEventError = 6
36
35
  MaxStepRetriesExceeded = 7
37
36
  NotAuthorized = 8
38
37
 
@@ -87,16 +86,6 @@ class DBOSNonExistentWorkflowError(DBOSException):
87
86
  )
88
87
 
89
88
 
90
- class DBOSDuplicateWorkflowEventError(DBOSException):
91
- """Exception raised when a workflow attempts to set an event value more than once per key."""
92
-
93
- def __init__(self, workflow_id: str, key: str):
94
- super().__init__(
95
- f"Workflow {workflow_id} has already emitted an event with key {key}",
96
- dbos_error_code=DBOSErrorCode.DuplicateWorkflowEventError.value,
97
- )
98
-
99
-
100
89
  class DBOSNotAuthorizedError(DBOSException):
101
90
  """Exception raised by DBOS role-based security when the user is not authorized to access a function."""
102
91
 
dbos/fastapi.py CHANGED
@@ -1,8 +1,13 @@
1
1
  import uuid
2
- from typing import Any, Callable
2
+ from typing import Any, Callable, cast
3
3
 
4
4
  from fastapi import FastAPI
5
5
  from fastapi import Request as FastAPIRequest
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
8
+
9
+ from dbos import DBOS
10
+ from dbos.error import DBOSException
6
11
 
7
12
  from .context import (
8
13
  EnterDBOSHandler,
@@ -35,7 +40,46 @@ def make_request(request: FastAPIRequest) -> Request:
35
40
  )
36
41
 
37
42
 
38
- def setup_fastapi_middleware(app: FastAPI) -> None:
43
+ async def dbos_error_handler(request: FastAPIRequest, gexc: Exception) -> JSONResponse:
44
+ exc: DBOSException = cast(DBOSException, gexc)
45
+ status_code = 500
46
+ if exc.status_code is not None:
47
+ status_code = exc.status_code
48
+ return JSONResponse(
49
+ status_code=status_code,
50
+ content={
51
+ "message": str(exc.message),
52
+ "dbos_error_code": str(exc.dbos_error_code),
53
+ "dbos_error": str(exc.__class__.__name__),
54
+ },
55
+ )
56
+
57
+
58
+ class LifespanMiddleware:
59
+ def __init__(self, app: ASGIApp, dbos: DBOS):
60
+ self.app = app
61
+ self.dbos = dbos
62
+
63
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
64
+ if scope["type"] == "lifespan":
65
+ while True:
66
+ message = await receive()
67
+ if message["type"] == "lifespan.startup":
68
+ self.dbos._launch()
69
+ await send({"type": "lifespan.startup.complete"})
70
+ elif message["type"] == "lifespan.shutdown":
71
+ self.dbos._destroy()
72
+ await send({"type": "lifespan.shutdown.complete"})
73
+ break
74
+ else:
75
+ await self.app(scope, receive, send)
76
+
77
+
78
+ def setup_fastapi_middleware(app: FastAPI, dbos: DBOS) -> None:
79
+
80
+ app.add_middleware(LifespanMiddleware, dbos=dbos)
81
+ app.add_exception_handler(DBOSException, dbos_error_handler)
82
+
39
83
  @app.middleware("http")
40
84
  async def dbos_fastapi_middleware(
41
85
  request: FastAPIRequest, call_next: Callable[..., Any]
dbos/kafka.py CHANGED
@@ -1,26 +1,30 @@
1
1
  import threading
2
- import traceback
3
- from dataclasses import dataclass
4
- from typing import TYPE_CHECKING, Any, Callable, Generator, NoReturn, Optional, Union
2
+ from typing import TYPE_CHECKING, Any, Callable, NoReturn
5
3
 
6
4
  from confluent_kafka import Consumer, KafkaError, KafkaException
7
- from confluent_kafka import Message as CTypeMessage
5
+
6
+ from dbos.queue import Queue
8
7
 
9
8
  if TYPE_CHECKING:
10
9
  from dbos.dbos import _DBOSRegistry
11
10
 
12
11
  from .context import SetWorkflowID
12
+ from .error import DBOSInitializationError
13
13
  from .kafka_message import KafkaMessage
14
14
  from .logger import dbos_logger
15
15
 
16
16
  KafkaConsumerWorkflow = Callable[[KafkaMessage], None]
17
17
 
18
+ kafka_queue: Queue
19
+ in_order_kafka_queues: dict[str, Queue] = {}
20
+
18
21
 
19
22
  def _kafka_consumer_loop(
20
23
  func: KafkaConsumerWorkflow,
21
24
  config: dict[str, Any],
22
25
  topics: list[str],
23
26
  stop_event: threading.Event,
27
+ in_order: bool,
24
28
  ) -> None:
25
29
 
26
30
  def on_error(err: KafkaError) -> NoReturn:
@@ -70,24 +74,35 @@ def _kafka_consumer_loop(
70
74
  with SetWorkflowID(
71
75
  f"kafka-unique-id-{msg.topic}-{msg.partition}-{msg.offset}"
72
76
  ):
73
- try:
74
- func(msg)
75
- except Exception as e:
76
- dbos_logger.error(
77
- f"Exception encountered in Kafka consumer: {traceback.format_exc()}"
78
- )
77
+ if in_order:
78
+ assert msg.topic is not None
79
+ queue = in_order_kafka_queues[msg.topic]
80
+ queue.enqueue(func, msg)
81
+ else:
82
+ kafka_queue.enqueue(func, msg)
79
83
 
80
84
  finally:
81
85
  consumer.close()
82
86
 
83
87
 
84
88
  def kafka_consumer(
85
- dbosreg: "_DBOSRegistry", config: dict[str, Any], topics: list[str]
89
+ dbosreg: "_DBOSRegistry", config: dict[str, Any], topics: list[str], in_order: bool
86
90
  ) -> Callable[[KafkaConsumerWorkflow], KafkaConsumerWorkflow]:
87
91
  def decorator(func: KafkaConsumerWorkflow) -> KafkaConsumerWorkflow:
92
+ if in_order:
93
+ for topic in topics:
94
+ if topic.startswith("^"):
95
+ raise DBOSInitializationError(
96
+ f"Error: in-order processing is not supported for regular expression topic selectors ({topic})"
97
+ )
98
+ queue = Queue(f"_dbos_kafka_queue_topic_{topic}", concurrency=1)
99
+ in_order_kafka_queues[topic] = queue
100
+ else:
101
+ global kafka_queue
102
+ kafka_queue = Queue("_dbos_internal_queue")
88
103
  stop_event = threading.Event()
89
104
  dbosreg.register_poller(
90
- stop_event, _kafka_consumer_loop, func, config, topics, stop_event
105
+ stop_event, _kafka_consumer_loop, func, config, topics, stop_event, in_order
91
106
  )
92
107
  return func
93
108
 
@@ -0,0 +1,55 @@
1
+ """job_queue
2
+
3
+ Revision ID: eab0cc1d9a14
4
+ Revises: a3b18ad34abe
5
+ Create Date: 2024-09-13 14:50:00.531294
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "eab0cc1d9a14"
16
+ down_revision: Union[str, None] = "a3b18ad34abe"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.create_table(
23
+ "job_queue",
24
+ sa.Column("workflow_uuid", sa.Text(), nullable=False),
25
+ sa.Column("queue_name", sa.Text(), nullable=False),
26
+ sa.Column(
27
+ "created_at_epoch_ms",
28
+ sa.BigInteger(),
29
+ server_default=sa.text(
30
+ "(EXTRACT(epoch FROM now()) * 1000::numeric)::bigint"
31
+ ),
32
+ nullable=False,
33
+ primary_key=True,
34
+ ),
35
+ sa.ForeignKeyConstraint(
36
+ ["workflow_uuid"],
37
+ ["dbos.workflow_status.workflow_uuid"],
38
+ onupdate="CASCADE",
39
+ ondelete="CASCADE",
40
+ ),
41
+ schema="dbos",
42
+ )
43
+ op.add_column(
44
+ "workflow_status",
45
+ sa.Column(
46
+ "queue_name",
47
+ sa.Text(),
48
+ ),
49
+ schema="dbos",
50
+ )
51
+
52
+
53
+ def downgrade() -> None:
54
+ op.drop_table("job_queue", schema="dbos")
55
+ op.drop_column("workflow_status", "queue_name", schema="dbos")
dbos/queue.py ADDED
@@ -0,0 +1,36 @@
1
+ import threading
2
+ import time
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from dbos.core import P, R, _execute_workflow_id, _start_workflow
6
+ from dbos.error import DBOSInitializationError
7
+
8
+ if TYPE_CHECKING:
9
+ from dbos.dbos import DBOS, Workflow, WorkflowHandle
10
+
11
+
12
+ class Queue:
13
+ def __init__(self, name: str, concurrency: Optional[int] = None) -> None:
14
+ self.name = name
15
+ self.concurrency = concurrency
16
+ from dbos.dbos import _get_or_create_dbos_registry
17
+
18
+ registry = _get_or_create_dbos_registry()
19
+ registry.queue_info_map[self.name] = self
20
+
21
+ def enqueue(
22
+ self, func: "Workflow[P, R]", *args: P.args, **kwargs: P.kwargs
23
+ ) -> "WorkflowHandle[R]":
24
+ from dbos.dbos import _get_dbos_instance
25
+
26
+ dbos = _get_dbos_instance()
27
+ return _start_workflow(dbos, func, self.name, False, *args, **kwargs)
28
+
29
+
30
+ def queue_thread(stop_event: threading.Event, dbos: "DBOS") -> None:
31
+ while not stop_event.is_set():
32
+ time.sleep(1)
33
+ for queue_name, queue in dbos._registry.queue_info_map.items():
34
+ wf_ids = dbos._sys_db.start_queued_workflows(queue_name, queue.concurrency)
35
+ for id in wf_ids:
36
+ _execute_workflow_id(dbos, id)
dbos/recovery.py CHANGED
@@ -41,7 +41,7 @@ def _recover_pending_workflows(
41
41
  f"Skip local recovery because it's running in a VM: {os.environ.get('DBOS__VMID')}"
42
42
  )
43
43
  dbos.logger.debug(f"Recovering pending workflows for executor: {executor_id}")
44
- workflow_ids = dbos.sys_db.get_pending_workflows(executor_id)
44
+ workflow_ids = dbos._sys_db.get_pending_workflows(executor_id)
45
45
  dbos.logger.debug(f"Pending workflows: {workflow_ids}")
46
46
 
47
47
  for workflowID in workflow_ids:
@@ -1,41 +1,39 @@
1
1
  import threading
2
- import traceback
3
2
  from datetime import datetime, timezone
4
3
  from typing import TYPE_CHECKING, Callable
5
4
 
5
+ from dbos.queue import Queue
6
+
6
7
  if TYPE_CHECKING:
7
8
  from dbos.dbos import _DBOSRegistry
8
9
 
9
10
  from ..context import SetWorkflowID
10
- from ..logger import dbos_logger
11
11
  from .croniter import croniter # type: ignore
12
12
 
13
13
  ScheduledWorkflow = Callable[[datetime, datetime], None]
14
14
 
15
+ scheduler_queue: Queue
16
+
15
17
 
16
18
  def scheduler_loop(
17
19
  func: ScheduledWorkflow, cron: str, stop_event: threading.Event
18
20
  ) -> None:
19
- iter = croniter(cron, datetime.now(timezone.utc))
21
+ iter = croniter(cron, datetime.now(timezone.utc), second_at_beginning=True)
20
22
  while not stop_event.is_set():
21
23
  nextExecTime = iter.get_next(datetime)
22
24
  sleepTime = nextExecTime - datetime.now(timezone.utc)
23
25
  if stop_event.wait(timeout=sleepTime.total_seconds()):
24
26
  return
25
27
  with SetWorkflowID(f"sched-{func.__qualname__}-{nextExecTime.isoformat()}"):
26
- try:
27
- func(nextExecTime, datetime.now(timezone.utc))
28
- except Exception as e:
29
- dbos_logger.error(
30
- f"Exception encountered in scheduled workflow: {traceback.format_exc()}"
31
- )
32
- pass # Let the thread keep running
28
+ scheduler_queue.enqueue(func, nextExecTime, datetime.now(timezone.utc))
33
29
 
34
30
 
35
31
  def scheduled(
36
32
  dbosreg: "_DBOSRegistry", cron: str
37
33
  ) -> Callable[[ScheduledWorkflow], ScheduledWorkflow]:
38
34
  def decorator(func: ScheduledWorkflow) -> ScheduledWorkflow:
35
+ global scheduler_queue
36
+ scheduler_queue = Queue("_dbos_internal_queue")
39
37
  stop_event = threading.Event()
40
38
  dbosreg.register_poller(stop_event, scheduler_loop, func, cron, stop_event)
41
39
  return func
@@ -1,5 +1,6 @@
1
1
  from sqlalchemy import (
2
2
  BigInteger,
3
+ Boolean,
3
4
  Column,
4
5
  ForeignKey,
5
6
  Index,
@@ -53,6 +54,7 @@ class SystemSchema:
53
54
  nullable=True,
54
55
  server_default=text("'0'::bigint"),
55
56
  ),
57
+ Column("queue_name", Text),
56
58
  Index("workflow_status_created_at_index", "created_at"),
57
59
  Index("workflow_status_executor_id_index", "executor_id"),
58
60
  )
@@ -139,3 +141,24 @@ class SystemSchema:
139
141
  Column("workflow_fn_name", Text, primary_key=True, nullable=False),
140
142
  Column("last_run_time", BigInteger, nullable=False),
141
143
  )
144
+
145
+ job_queue = Table(
146
+ "job_queue",
147
+ metadata_obj,
148
+ Column(
149
+ "workflow_uuid",
150
+ Text,
151
+ ForeignKey(
152
+ "workflow_status.workflow_uuid", onupdate="CASCADE", ondelete="CASCADE"
153
+ ),
154
+ nullable=False,
155
+ primary_key=True,
156
+ ),
157
+ Column("queue_name", Text, nullable=False),
158
+ Column(
159
+ "created_at_epoch_ms",
160
+ BigInteger,
161
+ nullable=False,
162
+ server_default=text("(EXTRACT(epoch FROM now()) * 1000::numeric)::bigint"),
163
+ ),
164
+ )