5.0 Upgrade Guide¶
In this document we describe important changes to be aware of when upgrading from orchestrator-core v4.x to v5.0.
If you are not yet using v4.x, please consult the 4.0 upgrade guide first.
If you are using scheduled tasks and/or workflow authorization callbacks, please consult the 4.7 and 4.8 upgrades first as well.
About 5.0¶
In this release, the following breaking changes are introduced:
orchestrator-coreis now a namespaced package, which changes all imports.- GraphQL
scheduledTasksquery no longer returns tasks created by the decorator - Secret settings for sensitive URIs
- Dependency
pydantic-formslower bound increased from 1.4.0 to 2.4.0 - DB column
engine_settings.running_processesremoved, service functions renamed - The
descriptionparameter in workflow decorators is now deprecated strawberry-graphqlupgrade includes breaking changes- LLM-powered search is now the default
orchestrator.core.security.authorizeandauthorize_websocketnow raise onFalse- Workflow and Step callbacks of type
Authorizernow expect anAuthContext, not anOIDCUserModel - Migration from
psycopg2topsycopg3 - New
OrchestratorCore.register_table()API
1. Namespace packaging¶
What changed¶
The whole orchestrator Python module has moved to a namespace package structure, which means that every submodule under
orchestrator.* has been moved to orchestrator.core.*. This will allow for the development of other, optional
modules that can be used together with the Orchestrator core. This change is motivated more in
this section of the documentation.
Linting and packaging tools¶
As a part of this change, we have also replaced flit and black with uv_build and ruff, respectively.
Ruff was already part of our development toolchain, but there were some stray uses of black lingering around for
linting purposes. This is now completely replaced with ruff. You may want to update your own CI pipelines to reflect
this, but this should not be a breaking change for using orchestrator-core in your own projects.
We no longer use flit for building the Python wheels that are distributed on PyPI, but use uv_build instead going
forward. This is a change that only impacts orchestrator-core contributors that may have to run a uv sync just to
be up-to-date again.
What you need to do¶
All import statements that mention the Orchestrator core should be updated to reflect this change. There is a migration
script available to help with this, which will try to do this automatically. It can be run as follows:
uv run -m orchestrator.core.devtools.scripts.migrate_50 <dir> where <dir> points to your orchestrator implementation, unit tests or any other directory with python files that import something from orchestrator.
In short, every reference to orchestrator-core like import statements from orchestrator.X import Y should be
changed to from orchestrator.core.X import Y.
| Old | New | Examples of where you’ll find this |
|---|---|---|
import orchestrator.db |
import orchestrator.core.db |
Anywhere |
from orchestrator.services.subscriptions import WF_USABLE_MAP |
from orchestrator.core.services.subscriptions import WF_USABLE_MAP |
Anywhere |
@patch("orchestrator.api.api_v1.endpoints") |
@patch("orchestrator.core.api.api_v1.endpoints") |
Unit tests |
strawberry.lazy("orchestrator.graphql.schemas.subscription") |
strawberry.lazy("orchestrator.core.graphql.schemas.subscription") |
GraphQL schema overrides |
include=["orchestrator.services.tasks"] |
include=["orchestrator.core.services.tasks"] |
Celery client definition |
2. GraphQL scheduledTasks query no longer returns tasks created by the decorator¶
The GraphQL scheduledTasks query no longer returns tasks created by the decorator, but only tasks that are scheduled
via the API.
To see all scheduled tasks, you can run the show-schedule CLI command which will still show tasks created by the decorator.
3. Secret settings for sensitive URIs¶
Replace usages of
orchestrator.core.utils.expose_settings.SecretStrwithpydantic.SecretStr(previously we also called this typeOrchSecretStr)
Three AppSettings fields that contain sensitive connection strings have been changed from plain types to Pydantic
Secret subtypes.
These URIs can contain credentials (e.g. database passwords, Redis auth tokens) that should not be leaked.
By wrapping them in Secret types, Pydantic automatically masks their values when the settings object is logged, printed,
serialized, or appears in tracebacks — replacing the actual value with '**********'.
This provides better protection against accidental credential exposure in application logs and error reports.
The affected fields and their new types are:
| Field | Old Type | New Type |
|---|---|---|
DATABASE_URI |
PostgresDsn |
SecretPostgresDsn (Secret[PostgresDsn]) |
CACHE_URI |
RedisDsn |
SecretRedisDsn (Secret[RedisDsn]) |
WEBSOCKET_BROADCASTER_URL |
str |
SecretStr |
What changed¶
Previously, you could use these settings values directly:
from orchestrator.core.settings import app_settings
# Old usage — no longer works
db_url = str(app_settings.DATABASE_URI)
redis_url = str(app_settings.CACHE_URI)
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL
Now, you must call .get_secret_value() to retrieve the underlying value:
from orchestrator.core.settings import app_settings
# New usage
db_url = str(app_settings.DATABASE_URI.get_secret_value())
redis_url = str(app_settings.CACHE_URI.get_secret_value())
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()
What you need to do¶
Search your codebase for any direct references to app_settings.DATABASE_URI, app_settings.CACHE_URI, or
app_settings.WEBSOCKET_BROADCASTER_URL (or via a settings parameter of type AppSettings). At each usage site,
append .get_secret_value() to extract the actual value.
For example:
# Before
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI))
cache = create_redis_client(app_settings.CACHE_URI)
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL).scheme
# After
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI.get_secret_value()))
cache = create_redis_client(app_settings.CACHE_URI.get_secret_value())
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()).scheme
If you assign to these fields in tests or configuration (e.g. overriding DATABASE_URI with a test database URL), wrap
the value in the appropriate Secret type:
from orchestrator.core.settings import SecretPostgresDsn, SecretRedisDsn, app_settings
from pydantic import SecretStr
# Before
app_settings.DATABASE_URI = db_uri
# After
app_settings.DATABASE_URI = SecretPostgresDsn(db_uri)
Warning
Previously custom settings whose names contained the string secret or password would automatically be masked.
This is no longer the case, and you should now use the type SecretStr for these values instead.
4. Dependency pydantic-forms lower bound increased from 1.4.0 to 2.4.0¶
The lower bound for dependency pydantic-forms has increased from 1.4.0 to 2.4.0.
This includes one breaking change from version
2.0, Replace ReadOnlyField with read_only_field and read_only_list.
What changed¶
Previously, the type ReadOnlyField was used for both scalar fields and list fields.
class Form(FormPage):
int_field: ReadOnlyField(1)
bool_field: ReadOnlyField("True", default_type=bool)
list_field: ReadOnlyField(["a", "b"], default_type=list[str])
As you can see, for bool_field and list_field it was necessary to explicitly specify the type.
Now, you must use a different type for either field, and you no longer have to specify the default_type:
class Form(FormPage):
int_field: read_only_field(1)
bool_field: read_only_field(True)
list_field: read_only_list(["a", "b"])
What you need to do¶
If you have pydantic-forms pinned to any version, you will need to change this pin to 2.4.0.
If your pinned version was below 2.0, search your codebase for usages of ReadOnlyField() and replace them with
read_only_field() or read_only_list() as described above.
The new types still come from the same module, pydantic_forms.validators.
5. DB column engine_settings.running_processes removed, service functions renamed¶
The running_processes column has been removed from the engine_settings database table.
Previously, this counter was maintained manually on every process start and finish.
This was replaced in v4.8 by WorkerStatusMonitor which continuously determines the running_processes count by
inspecting either the threadpool or the celery statistics.
What changed¶
Several internal service functions have been renamed for clarity:
| Old name | New name |
|---|---|
get_engine_settings() |
get_engine_settings_table() |
get_engine_settings_for_update() |
get_engine_settings_table_for_update() |
generate_engine_status_response() (in api/api_v1/endpoints/settings) |
generate_engine_settings_schema() (in services/settings) |
What you need to do¶
If you use the running_processes column in external queries, this column will need to be removed as there is no direct
replacement.
You can instead either retrieve this value through the /settings/status REST endpoint, or through the settings
GraphQL query.
If you use the old service functions anywhere, replace them with the new ones as described below.
Before
from orchestrator.services.settings import get_engine_settings, get_engine_settings_for_update
from orchestrator.api.api_v1.endpoints.settings import generate_engine_status_response
engine_settings = get_engine_settings()
engine_settings_for_update = get_engine_settings_for_update()
schema = generate_engine_status_response(engine_settings)
After
from orchestrator.core.services.settings import get_engine_settings_table, get_engine_settings_table_for_update, generate_engine_settings_schema
engine_settings = get_engine_settings_table()
engine_settings_for_update = get_engine_settings_table_for_update()
schema = generate_engine_settings_schema(engine_settings)
6. The description parameter in workflow decorators is now deprecated¶
The description parameter in @create_workflow, @modify_workflow, @terminate_workflow, @validate_workflow,
@reconcile_workflow, @task, and @workflow is now deprecated.
Workflow descriptions are now managed in the database and can be updated via the UI or API.
What changed¶
Previously, workflow descriptions were typically defined both in code and in the database. This could lead to mismatches when the description was updated through the UI. Descriptions are no longer validated against the database by the product validation task.
Passing a description to a workflow decorator still works in v5.0, but now emits a deprecation warning.
What you need to do¶
Remove the description parameter from your workflow decorators and manage workflow descriptions through the database
instead.
For example:
# Before
@create_workflow("Create SN8 IP BGP", initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
...
# After
@create_workflow(initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
...
If you want to backfill descriptions for multiple existing workflows during the upgrade, you can do so in a one-off database migration. For example, replace the workflow names and descriptions below with your own values:
WITH workflow_descriptions(workflow_name, workflow_description) AS (VALUES ('your_create_workflow',
'Create your product'),
('your_modify_workflow',
'Modify your product'),
('your_terminate_workflow',
'Terminate your product'))
UPDATE workflows AS w
SET description = wd.workflow_description
FROM workflow_descriptions AS wd
WHERE w.name = wd.workflow_name;
7. strawberry-graphql upgrade includes breaking changes¶
What changed¶
FEDERATION_ENABLEDrenamed toFEDERATION_VERSION, defaulting to2.9to maintain compatibility withexample-orchestrator. (strawberry-graphql 0.285.0 breaking change)SERVE_GRAPHQL_UIchanged fromboolto a string (leaving it empty turns it off):graphiql,apollo-sandboxorpathfinder.- Custom scalars no longer accept
NewType, usename=and map to the target type instead. (strawberry scalar docs)
What you need to do¶
Remove FEDERATION_ENABLED and set FEDERATION_VERSION to the federation version you need.
Change SERVE_GRAPHQL_UI to the UI you want to use or set it to empty SERVE_GRAPHQL_UI=""
If you have custom scalars, replace the NewType wrapper with name=:
Before:
import strawberry
from typing import NewType
from orchestrator.graphql import SCALAR_OVERRIDES
VlanRangesType = strawberry.scalar(
NewType("VlanRangesType", str),
description="Represent the Orchestrator VlanRanges data type",
serialize=lambda v: v.to_list_of_tuples(),
parse_value=lambda v: v,
)
After:
import strawberry
from orchestrator.core.graphql import SCALAR_OVERRIDES
VlanRangesType = strawberry.scalar(
name="VlanRangesType",
description="Represent the Orchestrator VlanRanges data type",
serialize=lambda v: v.to_list_of_tuples(),
parse_value=lambda v: v,
)
The
NewTypewrapper andfrom typing import NewTypeare no longer needed and can be removed.
8. LLM-powered search is now the default¶
What changed¶
The LLM-powered search (AI search) is now a mandatory part of orchestrator-core. The following changes were made:
SEARCH_ENABLEDsetting removed – search is always enabled. Remove this environment variable from your configuration.searchpip extra removed –litellmis now a core dependency. Changepip install orchestrator-core[search]to justpip install orchestrator-core.- Search tables managed by alembic – the
ai_search_index,agent_runs,search_queries, andgraph_snapshotstables are now created via an alembic migration instead of at application startup. - TSV search deprecated – the
queryparameter on the subscriptions REST and GraphQL endpoints now emits a deprecation warning. Use the/api/searchendpoint instead. TSV search will be removed in a future 5.x release.
What you need to do¶
1. Prerequisites: install required PostgreSQL extensions
The AI search migration requires several PostgreSQL extensions. If your database user has sufficient privileges, the migration will create them automatically. Otherwise, install them manually before running the migration:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS vector;
The vector extension requires pgvector to be installed on your PostgreSQL
server. The easiest way to get a PostgreSQL instance with all required extensions is to use the pgvector/pgvector
Docker image, which the example-orchestrator uses in
its docker-compose.yml:
postgres:
image: pgvector/pgvector:pg17
This image is based on the official PostgreSQL image and includes pgvector pre-installed. The other extensions (
uuid-ossp, ltree, unaccent, pg_trgm) are part of the standard PostgreSQL contrib package and are available in
all standard PostgreSQL distributions.
If your database user cannot create extensions but the extensions already exist, no action is needed. If you need to
force extension creation (e.g., after a PostgreSQL upgrade), set LLM_FORCE_EXTENSION_MIGRATION=true.
2. Run the database migration
python main.py db upgrade heads
This creates the AI search tables. For existing installations that already have these tables (from the runtime
migration), the migration uses CREATE TABLE IF NOT EXISTS and is safe to run.
3. Configure your embedding provider (optional)
Search works without any embedding configuration, but semantic search (vector similarity) will be disabled. Text-based search will still function normally.
To enable semantic search, set the following environment variables:
EMBEDDING_API_ENABLED=True
EMBEDDING_API_KEY=sk-... # Your embedding provider's API key
EMBEDDING_MODEL=openai/text-embedding-3-small # Default; see LiteLLM docs for alternatives
EMBEDDING_DIMENSION=1536 # Default; adjust based on your model
EMBEDDING_API_BASE is optional. When omitted, the default API base URL of the provider (as configured by LiteLLM) is
used. Set it to point to a custom or self-hosted endpoint:
EMBEDDING_API_BASE=https://your-custom-endpoint.example.com/v1 # Optional
4. Initialize the search index
After the migration, populate the search index by running the CLI indexing commands:
python main.py index subscriptions
python main.py index products
python main.py index processes
python main.py index workflows
5. (Optional) Resize embeddings
If you want to use a different embedding dimension than the default (1536), update EMBEDDING_DIMENSION and run the
resize command:
python main.py embedding resize
Warning
Resizing embeddings will delete all existing embeddings from ai_search_index and search_queries tables. You will
need to re-index after resizing.
6. Remove deprecated settings
Remove these environment variables from your configuration:
SEARCH_ENABLED– no longer usedOPENAI_API_KEY– renamed toEMBEDDING_API_KEYOPENAI_BASE_URL– renamed toEMBEDDING_API_BASE- Any
pip installcommands that useorchestrator-core[search]– change toorchestrator-core
9. orchestrator.core.security.authorize and authorize_websocket now raise on False¶
What changed¶
These callbacks determine if a user is authorized to make a request or connect a websocket.
OrchestratorCore.register_authorization allows users to register their own Authorization instance.
This could be the oauth2_lib default OPA-based instance, but with auto_error=False,
or a custom Authorization instance entirely.
Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True,
and did not raise when False was returned from auth_manager.authorize.
Now it does raise on False.
What you need to do¶
If you write your own instance of an Authorization that returns False instead of raising a 403 Forbidden,
this may cause your authorizations to start working (i.e. 403ing on failure) where they previously didn’t.
You should confirm your authorizations work as expected.
10. Workflow and Step callbacks of type Authorizer now expect an AuthContext, not an OIDCUserModel¶
What changed¶
These callbacks (of type Authorizer) determine if a user is authorized to start, resume, or retry a workflow.
Previously, these callbacks should have received an argument of oauth2_lib.fastapi.OIDCUserModel.
Now, they should receive an AuthContext.
OrchestratorCore.register_authorization allows users to register their own Authorization instance.
This could be the oauth2_lib default OPA-based instance, but with auto_error=False,
or a custom Authorization instance entirely.
Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True,
and did not raise when False was returned from auth_manager.authorize.
Now it does raise on False.
It looks like this:
class AuthContext(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
user: AuthUserModel | None = None
action: Literal["start_workflow", "resume_workflow", "retry_workflow"]
workflow: AuthWorkflow | None = None
# Should be None for "start_workflow", otherwise set
step: AuthStep | None = None
What you need to do¶
You are affected by this change if you use any of the following:
@workflow(..., authorize_callback=foo, retry_auth_callback=bar)@step(..., resume_auth_callback=foo, retry_auth_callback=bar)
There should now be much more information available for making authorization decisions within a callback, but here is how to quickly port your existing logic. If you currently have:
def auth_callback(user: OIDCUserModel | None) -> bool:
return do_something(user)
You would update this function to instead do:
from orchestrator.core.utils.auth import AuthContext
def auth_callback(context: AuthContext | None) -> bool:
return do_something(context.user)
To leverage the new changes, see the fields on this model and its sub-Prototypes.
11. Migration from psycopg2 to psycopg3¶
The database driver has been changed from psycopg2-binary to psycopg[binary] (psycopg 3). This requires changes to
your database connection URI and any code that imports psycopg2 directly.
What changed¶
- The dependency
psycopg2-binaryhas been replaced bypsycopg[binary]>=3.3.3 - The SQLAlchemy dialect in
DATABASE_URImust change frompostgresql://topostgresql+psycopg://. A deprecation warning is emitted if the old dialect is detected at startup. - psycopg3 uses “autobegin” — any query automatically starts an implicit transaction.
orchestrator-corehas been updated to handle this correctly within its workflow engine and task execution.
What you need to do¶
Update your DATABASE_URI in all environments (.env, CI config, deploy scripts, Helm values):
- DATABASE_URI=postgresql://user:pass@host/dbname
+ DATABASE_URI=postgresql+psycopg://user:pass@host/dbname
This also applies to any test database URIs (e.g. DATABASE_URI_TEST).
Update your dependency pin, if you have one:
- psycopg2-binary==2.9.11
+ psycopg[binary]>=3.3.3
Replace direct psycopg2 usage, if any. The psycopg3 API has some differences:
# Before
import psycopg2
conn = psycopg2.connect(
database="mydb",
user="myuser",
password="mypass",
host="localhost",
)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("SELECT 1")
# After
import psycopg
conn = psycopg.connect(
dbname="mydb",
user="myuser",
password="mypass",
host="localhost",
autocommit=True,
)
cursor = conn.cursor()
cursor.execute("SELECT 1")
Note that conn.autocommit is no longer a settable attribute — pass autocommit=True to the constructor instead, or
use conn.set_autocommit(True).
Be aware of autobegin: psycopg3 automatically starts a transaction on the first query. If you execute queries
outside of a transactional() context manager (e.g. in custom workflow steps or Celery signal handlers), those
transactions may not be committed or rolled back, leaving connections in an “idle in transaction” state. Wrap such
queries in transactional():
from orchestrator.core.db import db
from orchestrator.core.db.database import transactional
from structlog import get_logger
logger = get_logger(__name__)
with transactional(db, logger):
result = db.session.execute(...)
12. New OrchestratorCore.register_table() API¶
What changed¶
A new static method OrchestratorCore.register_table() has been added. It allows you to register custom
column_property attributes from a subclass onto the mapper of any SQLAlchemy base table. This makes custom computed
columns available in queries and GraphQL without modifying the core table definition.
The method works with any SQLAlchemy mapped class — SubscriptionTable is the most common use case, but it can be
applied to any base table you want to extend.
What you need to do¶
If you have a custom subclass with extra column_property definitions, register it during application startup:
from orchestrator.core.app import OrchestratorCore
from orchestrator.core.db import SubscriptionTable
from myapp.db.models import MySubscriptionTable
app = OrchestratorCore(...)
# Register custom column properties onto the base table mapper
app.register_table(SubscriptionTable, MySubscriptionTable)
For example, if your subclass defines a computed column (assuming a user-defined CustomerTable with customer_id and
fullname columns — see the GraphQL customization guide
for a full example):
from sqlalchemy.orm import column_property, Mapped
from sqlalchemy import select
from orchestrator.core.db import SubscriptionTable
from myapp.db.models import CustomerTable # your own model
class MySubscriptionTable(SubscriptionTable):
customer_name: Mapped[str] = column_property(
select(CustomerTable.fullname)
.where(CustomerTable.customer_id == SubscriptionTable.customer_id)
.correlate_except(CustomerTable)
.scalar_subquery(),
)
After calling register_table(), customer_name becomes available on the base SubscriptionTable mapper — queryable
via SQLAlchemy and exposed through GraphQL.
The same pattern applies to any other base table. Pass the base class and custom class as arguments:
OrchestratorCore.register_table(SomeOtherBaseTable, MyCustomSomeOtherTable)
If you are using Celery workers, register the table in your worker startup as well:
OrchestratorCore.register_table(SubscriptionTable, MySubscriptionTable)