dbos 0.22.0a5__tar.gz → 0.22.0a8__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 (90) hide show
  1. {dbos-0.22.0a5 → dbos-0.22.0a8}/PKG-INFO +1 -1
  2. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_admin_server.py +21 -0
  3. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_context.py +0 -1
  4. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_core.py +1 -1
  5. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_dbos.py +38 -5
  6. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_logger.py +2 -1
  7. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_recovery.py +6 -7
  8. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_sys_db.py +11 -4
  9. {dbos-0.22.0a5 → dbos-0.22.0a8}/pyproject.toml +1 -1
  10. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_admin_server.py +22 -1
  11. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_dbos.py +157 -11
  12. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_spans.py +4 -1
  13. {dbos-0.22.0a5 → dbos-0.22.0a8}/LICENSE +0 -0
  14. {dbos-0.22.0a5 → dbos-0.22.0a8}/README.md +0 -0
  15. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/__init__.py +0 -0
  16. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_app_db.py +0 -0
  17. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_classproperty.py +0 -0
  18. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_cloudutils/authentication.py +0 -0
  19. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_cloudutils/cloudutils.py +0 -0
  20. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_cloudutils/databases.py +0 -0
  21. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_croniter.py +0 -0
  22. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_db_wizard.py +0 -0
  23. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_dbos_config.py +0 -0
  24. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_error.py +0 -0
  25. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_fastapi.py +0 -0
  26. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_flask.py +0 -0
  27. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_kafka.py +0 -0
  28. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_kafka_message.py +0 -0
  29. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/env.py +0 -0
  30. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/script.py.mako +0 -0
  31. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  32. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  33. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  34. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  35. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  36. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  37. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  38. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_outcome.py +0 -0
  39. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_queue.py +0 -0
  40. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_registrations.py +0 -0
  41. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_request.py +0 -0
  42. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_roles.py +0 -0
  43. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_scheduler.py +0 -0
  44. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_schemas/__init__.py +0 -0
  45. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_schemas/application_database.py +0 -0
  46. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_schemas/system_database.py +0 -0
  47. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_serialization.py +0 -0
  48. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/README.md +0 -0
  49. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  50. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/__package/main.py +0 -0
  51. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  52. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  53. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  54. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  55. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  56. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  57. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  58. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_tracer.py +0 -0
  59. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/_workflow_commands.py +0 -0
  60. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/cli/_github_init.py +0 -0
  61. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/cli/_template_init.py +0 -0
  62. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/cli/cli.py +0 -0
  63. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/dbos-config.schema.json +0 -0
  64. {dbos-0.22.0a5 → dbos-0.22.0a8}/dbos/py.typed +0 -0
  65. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/__init__.py +0 -0
  66. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/atexit_no_ctor.py +0 -0
  67. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/atexit_no_launch.py +0 -0
  68. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/classdefs.py +0 -0
  69. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/conftest.py +0 -0
  70. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/more_classdefs.py +0 -0
  71. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/queuedworkflow.py +0 -0
  72. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_async.py +0 -0
  73. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_classdecorators.py +0 -0
  74. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_concurrency.py +0 -0
  75. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_config.py +0 -0
  76. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_croniter.py +0 -0
  77. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_failures.py +0 -0
  78. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_fastapi.py +0 -0
  79. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_fastapi_roles.py +0 -0
  80. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_flask.py +0 -0
  81. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_kafka.py +0 -0
  82. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_outcome.py +0 -0
  83. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_package.py +0 -0
  84. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_queue.py +0 -0
  85. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_scheduler.py +0 -0
  86. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_schema_migration.py +0 -0
  87. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_singleton.py +0 -0
  88. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_sqlalchemy.py +0 -0
  89. {dbos-0.22.0a5 → dbos-0.22.0a8}/tests/test_workflow_cmds.py +0 -0
  90. {dbos-0.22.0a5 → dbos-0.22.0a8}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.22.0a5
3
+ Version: 0.22.0a8
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
16
  _health_check_path = "/dbos-healthz"
17
17
  _workflow_recovery_path = "/dbos-workflow-recovery"
18
18
  _deactivate_path = "/deactivate"
19
+ _workflow_queues_metadata_path = "/dbos-workflow-queues-metadata"
19
20
  # /workflows/:workflow_id/cancel
20
21
  # /workflows/:workflow_id/resume
21
22
  # /workflows/:workflow_id/restart
@@ -64,6 +65,26 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
64
65
  self.send_response(200)
65
66
  self._end_headers()
66
67
  self.wfile.write("deactivated".encode("utf-8"))
68
+ elif self.path == _workflow_queues_metadata_path:
69
+ queue_metadata_array = []
70
+ from ._dbos import _get_or_create_dbos_registry
71
+
72
+ registry = _get_or_create_dbos_registry()
73
+ for queue in registry.queue_info_map.values():
74
+ queue_metadata = {
75
+ "name": queue.name,
76
+ "concurrency": queue.concurrency,
77
+ "workerConcurrency": queue.worker_concurrency,
78
+ "rateLimit": queue.limiter,
79
+ }
80
+ # Remove keys with None values
81
+ queue_metadata = {
82
+ k: v for k, v in queue_metadata.items() if v is not None
83
+ }
84
+ queue_metadata_array.append(queue_metadata)
85
+ self.send_response(200)
86
+ self._end_headers()
87
+ self.wfile.write(json.dumps(queue_metadata_array).encode("utf-8"))
67
88
  else:
68
89
  self.send_response(404)
69
90
  self._end_headers()
@@ -49,7 +49,6 @@ class TracedAttributes(TypedDict, total=False):
49
49
  class DBOSContext:
50
50
  def __init__(self) -> None:
51
51
  self.executor_id = os.environ.get("DBOS__VMID", "local")
52
- self.app_version = os.environ.get("DBOS__APPVERSION", "")
53
52
  self.app_id = os.environ.get("DBOS__APPID", "")
54
53
 
55
54
  self.logger = dbos_logger
@@ -163,7 +163,7 @@ def _init_workflow(
163
163
  "output": None,
164
164
  "error": None,
165
165
  "app_id": ctx.app_id,
166
- "app_version": ctx.app_version,
166
+ "app_version": dbos.app_version,
167
167
  "executor_id": ctx.executor_id,
168
168
  "request": (
169
169
  _serialization.serialize(ctx.request) if ctx.request is not None else None
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import atexit
5
+ import hashlib
6
+ import inspect
5
7
  import json
6
8
  import os
7
9
  import sys
@@ -186,6 +188,22 @@ class DBOSRegistry:
186
188
  else:
187
189
  self.instance_info_map[fn] = inst
188
190
 
191
+ def compute_app_version(self) -> str:
192
+ """
193
+ An application's version is computed from a hash of the source of its workflows.
194
+ This is guaranteed to be stable given identical source code because it uses an MD5 hash
195
+ and because it iterates through the workflows in sorted order.
196
+ This way, if the app's workflows are updated (which would break recovery), its version changes.
197
+ App version can be manually set through the DBOS__APPVERSION environment variable.
198
+ """
199
+ hasher = hashlib.md5()
200
+ sources = sorted(
201
+ [inspect.getsource(wf) for wf in self.workflow_info_map.values()]
202
+ )
203
+ for source in sources:
204
+ hasher.update(source.encode("utf-8"))
205
+ return hasher.hexdigest()
206
+
189
207
 
190
208
  class DBOS:
191
209
  """
@@ -283,6 +301,7 @@ class DBOS:
283
301
  self._executor_field: Optional[ThreadPoolExecutor] = None
284
302
  self._background_threads: List[threading.Thread] = []
285
303
  self._executor_id: str = os.environ.get("DBOS__VMID", "local")
304
+ self.app_version: str = os.environ.get("DBOS__APPVERSION", "")
286
305
 
287
306
  # If using FastAPI, set up middleware and lifecycle events
288
307
  if self.fastapi is not None:
@@ -351,6 +370,10 @@ class DBOS:
351
370
  dbos_logger.warning(f"DBOS was already launched")
352
371
  return
353
372
  self._launched = True
373
+ if self.app_version == "":
374
+ self.app_version = self._registry.compute_app_version()
375
+ dbos_logger.info(f"Application version: {self.app_version}")
376
+ dbos_tracer.app_version = self.app_version
354
377
  self._executor_field = ThreadPoolExecutor(max_workers=64)
355
378
  self._sys_db_field = SystemDatabase(self.config)
356
379
  self._app_db_field = ApplicationDatabase(self.config)
@@ -359,9 +382,19 @@ class DBOS:
359
382
  admin_port = 3001
360
383
  self._admin_server_field = AdminServer(dbos=self, port=admin_port)
361
384
 
362
- if not os.environ.get("DBOS__VMID"):
363
- workflow_ids = self._sys_db.get_pending_workflows("local")
364
- self._executor.submit(startup_recovery_thread, self, workflow_ids)
385
+ workflow_ids = self._sys_db.get_pending_workflows(
386
+ self._executor_id, self.app_version
387
+ )
388
+ if (len(workflow_ids)) > 0:
389
+ self.logger.info(
390
+ f"Recovering {len(workflow_ids)} workflows from application version {self.app_version}"
391
+ )
392
+ else:
393
+ self.logger.info(
394
+ f"No workflows to recover from application version {self.app_version}"
395
+ )
396
+
397
+ self._executor.submit(startup_recovery_thread, self, workflow_ids)
365
398
 
366
399
  # Listen to notifications
367
400
  notification_listener_thread = threading.Thread(
@@ -398,13 +431,13 @@ class DBOS:
398
431
  self._background_threads.append(poller_thread)
399
432
  self._registry.pollers = []
400
433
 
401
- dbos_logger.info("DBOS launched")
434
+ dbos_logger.info("DBOS launched!")
402
435
 
403
436
  # Flush handlers and add OTLP to all loggers if enabled
404
437
  # to enable their export in DBOS Cloud
405
438
  for handler in dbos_logger.handlers:
406
439
  handler.flush()
407
- add_otlp_to_all_loggers()
440
+ add_otlp_to_all_loggers(self.app_version)
408
441
  except Exception:
409
442
  dbos_logger.error(f"DBOS failed to launch: {traceback.format_exc()}")
410
443
  raise
@@ -86,8 +86,9 @@ def config_logger(config: "ConfigFile") -> None:
86
86
  dbos_logger.addFilter(_otlp_transformer)
87
87
 
88
88
 
89
- def add_otlp_to_all_loggers() -> None:
89
+ def add_otlp_to_all_loggers(app_version: str) -> None:
90
90
  if _otlp_handler is not None and _otlp_transformer is not None:
91
+ _otlp_transformer.app_version = app_version
91
92
  root = logging.root
92
93
 
93
94
  root.addHandler(_otlp_handler)
@@ -43,12 +43,10 @@ def recover_pending_workflows(
43
43
  ) -> List["WorkflowHandle[Any]"]:
44
44
  workflow_handles: List["WorkflowHandle[Any]"] = []
45
45
  for executor_id in executor_ids:
46
- if executor_id == "local" and os.environ.get("DBOS__VMID"):
47
- dbos.logger.debug(
48
- f"Skip local recovery because it's running in a VM: {os.environ.get('DBOS__VMID')}"
49
- )
50
46
  dbos.logger.debug(f"Recovering pending workflows for executor: {executor_id}")
51
- pending_workflows = dbos._sys_db.get_pending_workflows(executor_id)
47
+ pending_workflows = dbos._sys_db.get_pending_workflows(
48
+ executor_id, dbos.app_version
49
+ )
52
50
  for pending_workflow in pending_workflows:
53
51
  if (
54
52
  pending_workflow.queue_name
@@ -65,6 +63,7 @@ def recover_pending_workflows(
65
63
  workflow_handles.append(
66
64
  execute_workflow_by_id(dbos, pending_workflow.workflow_uuid)
67
65
  )
68
-
69
- dbos.logger.info("Recovered pending workflows")
66
+ dbos.logger.info(
67
+ f"Recovering {len(pending_workflows)} workflows from version {dbos.app_version}"
68
+ )
70
69
  return workflow_handles
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ import logging
2
3
  import os
3
4
  import re
4
5
  import threading
@@ -13,6 +14,7 @@ from typing import (
13
14
  Optional,
14
15
  Sequence,
15
16
  Set,
17
+ Tuple,
16
18
  TypedDict,
17
19
  cast,
18
20
  )
@@ -22,8 +24,8 @@ import sqlalchemy as sa
22
24
  import sqlalchemy.dialects.postgresql as pg
23
25
  from alembic import command
24
26
  from alembic.config import Config
25
- from sqlalchemy import or_
26
27
  from sqlalchemy.exc import DBAPIError
28
+ from sqlalchemy.sql import func
27
29
 
28
30
  from . import _serialization
29
31
  from ._dbos_config import ConfigFile
@@ -191,7 +193,7 @@ class SystemDatabase:
191
193
  database="postgres",
192
194
  # fills the "application_name" column in pg_stat_activity
193
195
  query={
194
- "application_name": f"dbos_transact_{os.environ.get('DBOS__VMID', 'local')}_{os.environ.get('DBOS__APPVERSION', '')}"
196
+ "application_name": f"dbos_transact_{os.environ.get('DBOS__VMID', 'local')}"
195
197
  },
196
198
  )
197
199
  engine = sa.create_engine(postgres_db_url)
@@ -213,7 +215,7 @@ class SystemDatabase:
213
215
  database=sysdb_name,
214
216
  # fills the "application_name" column in pg_stat_activity
215
217
  query={
216
- "application_name": f"dbos_transact_{os.environ.get('DBOS__VMID', 'local')}_{os.environ.get('DBOS__APPVERSION', '')}"
218
+ "application_name": f"dbos_transact_{os.environ.get('DBOS__VMID', 'local')}"
217
219
  },
218
220
  )
219
221
 
@@ -228,6 +230,7 @@ class SystemDatabase:
228
230
  )
229
231
  alembic_cfg = Config()
230
232
  alembic_cfg.set_main_option("script_location", migration_dir)
233
+ logging.getLogger("alembic").setLevel(logging.WARNING)
231
234
  # Alembic requires the % in URL-escaped parameters to itself be escaped to %%.
232
235
  escaped_conn_string = re.sub(
233
236
  r"%(?=[0-9A-Fa-f]{2})",
@@ -307,6 +310,7 @@ class SystemDatabase:
307
310
  recovery_attempts=(
308
311
  SystemSchema.workflow_status.c.recovery_attempts + 1
309
312
  ),
313
+ updated_at=func.extract("epoch", func.now()) * 1000,
310
314
  ),
311
315
  )
312
316
  )
@@ -404,6 +408,7 @@ class SystemDatabase:
404
408
  status=status["status"],
405
409
  output=status["output"],
406
410
  error=status["error"],
411
+ updated_at=func.extract("epoch", func.now()) * 1000,
407
412
  ),
408
413
  )
409
414
  )
@@ -767,7 +772,7 @@ class SystemDatabase:
767
772
  return GetWorkflowsOutput(workflow_uuids)
768
773
 
769
774
  def get_pending_workflows(
770
- self, executor_id: str
775
+ self, executor_id: str, app_version: str
771
776
  ) -> list[GetPendingWorkflowsOutput]:
772
777
  with self.engine.begin() as c:
773
778
  rows = c.execute(
@@ -778,8 +783,10 @@ class SystemDatabase:
778
783
  SystemSchema.workflow_status.c.status
779
784
  == WorkflowStatusString.PENDING.value,
780
785
  SystemSchema.workflow_status.c.executor_id == executor_id,
786
+ SystemSchema.workflow_status.c.application_version == app_version,
781
787
  )
782
788
  ).fetchall()
789
+
783
790
  return [
784
791
  GetPendingWorkflowsOutput(
785
792
  workflow_uuid=row.workflow_uuid,
@@ -27,7 +27,7 @@ dependencies = [
27
27
  ]
28
28
  requires-python = ">=3.9"
29
29
  readme = "README.md"
30
- version = "0.22.0a5"
30
+ version = "0.22.0a8"
31
31
 
32
32
  [project.license]
33
33
  text = "MIT"
@@ -5,7 +5,7 @@ import uuid
5
5
  import requests
6
6
 
7
7
  # Public API
8
- from dbos import DBOS, ConfigFile, SetWorkflowID, _workflow_commands
8
+ from dbos import DBOS, ConfigFile, Queue, SetWorkflowID, _workflow_commands
9
9
 
10
10
 
11
11
  def test_admin_endpoints(dbos: DBOS) -> None:
@@ -23,6 +23,27 @@ def test_admin_endpoints(dbos: DBOS) -> None:
23
23
  assert response.status_code == 200
24
24
  assert response.json() == []
25
25
 
26
+ # Test GET /dbos-workflow-queues-metadata
27
+ Queue("q1")
28
+ Queue("q2", concurrency=1)
29
+ Queue("q3", concurrency=1, worker_concurrency=1)
30
+ Queue("q4", concurrency=1, worker_concurrency=1, limiter={"limit": 0, "period": 0})
31
+ response = requests.get(
32
+ "http://localhost:3001/dbos-workflow-queues-metadata", timeout=5
33
+ )
34
+ assert response.status_code == 200
35
+ assert response.json() == [
36
+ {"name": "q1"},
37
+ {"name": "q2", "concurrency": 1},
38
+ {"name": "q3", "concurrency": 1, "workerConcurrency": 1},
39
+ {
40
+ "name": "q4",
41
+ "concurrency": 1,
42
+ "workerConcurrency": 1,
43
+ "rateLimit": {"limit": 0, "period": 0},
44
+ },
45
+ ]
46
+
26
47
  # Test GET not found
27
48
  response = requests.get("http://localhost:3001/stuff", timeout=5)
28
49
  assert response.status_code == 404
@@ -1,5 +1,8 @@
1
+ # mypy: disable-error-code="no-redef"
2
+
1
3
  import datetime
2
4
  import logging
5
+ import os
3
6
  import threading
4
7
  import time
5
8
  import uuid
@@ -71,14 +74,22 @@ def test_simple_workflow_attempts_counter(dbos: DBOS) -> None:
71
74
 
72
75
  wfuuid = str(uuid.uuid4())
73
76
  with dbos._sys_db.engine.connect() as c:
74
- stmt = sa.select(SystemSchema.workflow_status.c.recovery_attempts).where(
75
- SystemSchema.workflow_status.c.workflow_uuid == wfuuid
76
- )
77
+ stmt = sa.select(
78
+ SystemSchema.workflow_status.c.recovery_attempts,
79
+ SystemSchema.workflow_status.c.created_at,
80
+ SystemSchema.workflow_status.c.updated_at,
81
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == wfuuid)
77
82
  for i in range(10):
78
83
  with SetWorkflowID(wfuuid):
79
84
  noop()
80
- result = c.execute(stmt).scalar()
81
- assert result == i + 1
85
+ result = c.execute(stmt).fetchone()
86
+ assert result is not None
87
+ recovery_attempts, created_at, updated_at = result
88
+ assert recovery_attempts == i + 1
89
+ if i == 0:
90
+ assert created_at == updated_at
91
+ else:
92
+ assert updated_at > created_at
82
93
 
83
94
 
84
95
  def test_child_workflow(dbos: DBOS) -> None:
@@ -546,10 +557,13 @@ def test_recovery_temp_workflow(dbos: DBOS) -> None:
546
557
  assert txn_counter == 1
547
558
 
548
559
 
549
- def test_recovery_thread(config: ConfigFile, dbos: DBOS) -> None:
560
+ def test_recovery_thread(config: ConfigFile) -> None:
550
561
  wf_counter: int = 0
551
562
  test_var = "dbos"
552
563
 
564
+ DBOS.destroy(destroy_registry=True)
565
+ dbos = DBOS(config=config)
566
+
553
567
  @DBOS.workflow()
554
568
  def test_workflow(var: str) -> str:
555
569
  nonlocal wf_counter
@@ -557,6 +571,8 @@ def test_recovery_thread(config: ConfigFile, dbos: DBOS) -> None:
557
571
  wf_counter += 1
558
572
  return var
559
573
 
574
+ DBOS.launch()
575
+
560
576
  wfuuid = str(uuid.uuid4())
561
577
  with SetWorkflowID(wfuuid):
562
578
  assert test_workflow(test_var) == test_var
@@ -584,19 +600,19 @@ def test_recovery_thread(config: ConfigFile, dbos: DBOS) -> None:
584
600
  }
585
601
  )
586
602
 
587
- dbos._destroy() # Unusual pattern - reusing the memory
588
- dbos.__init__(config=config) # type: ignore
603
+ DBOS.destroy(destroy_registry=True)
604
+ DBOS(config=config)
589
605
 
590
- @DBOS.workflow() # type: ignore
606
+ @DBOS.workflow()
591
607
  def test_workflow(var: str) -> str:
592
608
  nonlocal wf_counter
593
609
  if var == test_var:
594
610
  wf_counter += 1
595
611
  return var
596
612
 
597
- DBOS.launch() # Usually the framework does this but we destroyed it above
613
+ DBOS.launch()
598
614
 
599
- # Upon re-initialization, the background thread should recover the workflow safely.
615
+ # Upon re-launch, the background thread should recover the workflow safely.
600
616
  max_retries = 10
601
617
  success = False
602
618
  for i in range(max_retries):
@@ -1190,3 +1206,133 @@ def test_destroy_semantics(dbos: DBOS, config: ConfigFile) -> None:
1190
1206
  DBOS.launch()
1191
1207
 
1192
1208
  assert test_workflow(var) == var
1209
+
1210
+
1211
+ def test_app_version(config: ConfigFile) -> None:
1212
+ def is_hex(s: str) -> bool:
1213
+ return all(c in "0123456789abcdefABCDEF" for c in s)
1214
+
1215
+ DBOS.destroy(destroy_registry=True)
1216
+ dbos = DBOS(config=config)
1217
+
1218
+ @DBOS.workflow()
1219
+ def workflow_one(x: int) -> int:
1220
+ return x
1221
+
1222
+ @DBOS.workflow()
1223
+ def workflow_two(y: int) -> int:
1224
+ return y
1225
+
1226
+ DBOS.launch()
1227
+
1228
+ # Verify that app version is correctly set to a hex string
1229
+ app_version = dbos.app_version
1230
+ assert len(app_version) > 0
1231
+ assert is_hex(app_version)
1232
+
1233
+ DBOS.destroy(destroy_registry=True)
1234
+ dbos = DBOS(config=config)
1235
+
1236
+ @DBOS.workflow()
1237
+ def workflow_one(x: int) -> int:
1238
+ return x
1239
+
1240
+ @DBOS.workflow()
1241
+ def workflow_two(y: int) -> int:
1242
+ return y
1243
+
1244
+ DBOS.launch()
1245
+
1246
+ # Verify stability--the same workflow source produces the same app version.
1247
+ assert dbos.app_version == app_version
1248
+
1249
+ DBOS.destroy(destroy_registry=True)
1250
+ dbos = DBOS(config=config)
1251
+
1252
+ @DBOS.workflow()
1253
+ def workflow_one(x: int) -> int:
1254
+ return x
1255
+
1256
+ # Verify that changing the workflow source changes the workflow version
1257
+ DBOS.launch()
1258
+ assert dbos.app_version != app_version
1259
+
1260
+ # Verify that version can be overriden with an environment variable
1261
+ app_version = "12345"
1262
+ os.environ["DBOS__APPVERSION"] = app_version
1263
+
1264
+ DBOS.destroy(destroy_registry=True)
1265
+ dbos = DBOS(config=config)
1266
+
1267
+ @DBOS.workflow()
1268
+ def workflow_one(x: int) -> int:
1269
+ return x
1270
+
1271
+ DBOS.launch()
1272
+ assert dbos.app_version == app_version
1273
+
1274
+ del os.environ["DBOS__APPVERSION"]
1275
+
1276
+
1277
+ def test_recovery_appversion(config: ConfigFile) -> None:
1278
+ input = 5
1279
+
1280
+ DBOS.destroy(destroy_registry=True)
1281
+ dbos = DBOS(config=config)
1282
+
1283
+ @DBOS.workflow()
1284
+ def test_workflow(x: int) -> int:
1285
+ return x
1286
+
1287
+ DBOS.launch()
1288
+
1289
+ wfuuid = str(uuid.uuid4())
1290
+ with SetWorkflowID(wfuuid):
1291
+ assert test_workflow(input) == input
1292
+
1293
+ # Change the workflow status to pending
1294
+ dbos._sys_db.wait_for_buffer_flush()
1295
+ with dbos._sys_db.engine.begin() as c:
1296
+ c.execute(
1297
+ sa.update(SystemSchema.workflow_status)
1298
+ .values({"status": "PENDING", "name": test_workflow.__qualname__})
1299
+ .where(SystemSchema.workflow_status.c.workflow_uuid == wfuuid)
1300
+ )
1301
+
1302
+ # Reconstruct an identical environment to simulate a restart
1303
+ DBOS.destroy(destroy_registry=True)
1304
+ dbos = DBOS(config=config)
1305
+
1306
+ @DBOS.workflow()
1307
+ def test_workflow(x: int) -> int:
1308
+ return x
1309
+
1310
+ DBOS.launch()
1311
+
1312
+ # The workflow should successfully recover
1313
+ workflow_handles = DBOS.recover_pending_workflows()
1314
+ assert len(workflow_handles) == 1
1315
+ assert workflow_handles[0].get_result() == input
1316
+
1317
+ # Change the workflow status to pending
1318
+ dbos._sys_db.wait_for_buffer_flush()
1319
+ with dbos._sys_db.engine.begin() as c:
1320
+ c.execute(
1321
+ sa.update(SystemSchema.workflow_status)
1322
+ .values({"status": "PENDING", "name": test_workflow.__qualname__})
1323
+ .where(SystemSchema.workflow_status.c.workflow_uuid == wfuuid)
1324
+ )
1325
+
1326
+ # Now reconstruct a "modified application" with a different application version
1327
+ DBOS.destroy(destroy_registry=True)
1328
+ dbos = DBOS(config=config)
1329
+
1330
+ @DBOS.workflow()
1331
+ def test_workflow(x: int) -> int:
1332
+ return x + 1
1333
+
1334
+ DBOS.launch()
1335
+
1336
+ # The workflow should not recover
1337
+ workflow_handles = DBOS.recover_pending_workflows()
1338
+ assert len(workflow_handles) == 0
@@ -36,6 +36,7 @@ def test_spans(dbos: DBOS) -> None:
36
36
 
37
37
  for span in spans:
38
38
  assert span.attributes is not None
39
+ assert span.attributes["applicationVersion"] == dbos.app_version
39
40
  assert span.context is not None
40
41
 
41
42
  assert spans[0].name == test_step.__name__
@@ -73,6 +74,7 @@ async def test_spans_async(dbos: DBOS) -> None:
73
74
 
74
75
  for span in spans:
75
76
  assert span.attributes is not None
77
+ assert span.attributes["applicationVersion"] == dbos.app_version
76
78
  assert span.context is not None
77
79
 
78
80
  assert spans[0].name == test_step.__name__
@@ -85,7 +87,7 @@ async def test_spans_async(dbos: DBOS) -> None:
85
87
 
86
88
 
87
89
  def test_temp_wf_fastapi(dbos_fastapi: Tuple[DBOS, FastAPI]) -> None:
88
- _, app = dbos_fastapi
90
+ dbos, app = dbos_fastapi
89
91
 
90
92
  @app.get("/step")
91
93
  @DBOS.step()
@@ -109,6 +111,7 @@ def test_temp_wf_fastapi(dbos_fastapi: Tuple[DBOS, FastAPI]) -> None:
109
111
 
110
112
  for span in spans:
111
113
  assert span.attributes is not None
114
+ assert span.attributes["applicationVersion"] == dbos.app_version
112
115
  assert span.context is not None
113
116
 
114
117
  assert spans[0].name == test_step_endpoint.__name__
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