dbos 0.6.2__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/__init__.py +2 -0
- dbos/application_database.py +6 -11
- dbos/context.py +3 -2
- dbos/core.py +74 -54
- dbos/dbos.py +57 -69
- dbos/dbos_config.py +1 -1
- dbos/error.py +0 -11
- dbos/fastapi.py +46 -2
- dbos/kafka.py +27 -12
- dbos/migrations/versions/eab0cc1d9a14_job_queue.py +55 -0
- dbos/queue.py +36 -0
- dbos/recovery.py +1 -1
- dbos/scheduler/scheduler.py +7 -9
- dbos/schemas/system_database.py +23 -0
- dbos/system_database.py +116 -83
- {dbos-0.6.2.dist-info → dbos-0.7.0.dist-info}/METADATA +2 -2
- {dbos-0.6.2.dist-info → dbos-0.7.0.dist-info}/RECORD +20 -18
- {dbos-0.6.2.dist-info → dbos-0.7.0.dist-info}/WHEEL +1 -1
- {dbos-0.6.2.dist-info → dbos-0.7.0.dist-info}/entry_points.txt +2 -0
- {dbos-0.6.2.dist-info → dbos-0.7.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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:
|
dbos/scheduler/scheduler.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
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
|
|
@@ -23,19 +25,15 @@ def scheduler_loop(
|
|
|
23
25
|
if stop_event.wait(timeout=sleepTime.total_seconds()):
|
|
24
26
|
return
|
|
25
27
|
with SetWorkflowID(f"sched-{func.__qualname__}-{nextExecTime.isoformat()}"):
|
|
26
|
-
|
|
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
|
dbos/schemas/system_database.py
CHANGED
|
@@ -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
|
+
)
|