Skip to content

Support router as replica with pipelines#3721

Open
Bihan wants to merge 13 commits intodstackai:masterfrom
Bihan:support_router_replica_with_pipelines
Open

Support router as replica with pipelines#3721
Bihan wants to merge 13 commits intodstackai:masterfrom
Bihan:support_router_replica_with_pipelines

Conversation

@Bihan
Copy link
Copy Markdown
Collaborator

@Bihan Bihan commented Mar 31, 2026

Refer design document for this PR is here.

@Bihan Bihan force-pushed the support_router_replica_with_pipelines branch from 2fe5e14 to bafd2d9 Compare April 1, 2026 07:22
@Bihan Bihan requested review from jvstme and r4victor April 7, 2026 10:33


class ServiceRouterWorkerSyncFetcher(Fetcher[ServiceRouterWorkerSyncPipelineItem]):
@sentry_utils.instrument_named_task("pipeline_tasks.ServiceRouterWorkerSyncFetcher.fetch")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recently added @sentry_utils.instrument_pipeline_task – use it to avoid hardcoding pipeline_tasks prefix.

Comment on lines +201 to +205
run_model = sync_row.run
if run_model is None:
await session.delete(sync_row)
await session.commit()
return
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can run_model be None here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought what if the run row can be hard-deleted, so sync_row.run becomes None. If this is not possible we can delete this block.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you defined run_id as non-optional with ondelete="CASCADE" - how can it be possible?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. Maybe I delete this block.

Comment on lines +220 to +227
.options(
selectinload(RunModel.project),
selectinload(RunModel.jobs).selectinload(JobModel.project),
selectinload(RunModel.jobs)
.selectinload(JobModel.instance)
.selectinload(InstanceModel.project),
)
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is potentially a very inefficient select – a run can have thousands of job submissions. Select only the jobs that the processing needs, i.e. only the router replica job. Also every selectinload will be a separate query here – not sure if it's justified. joinedload may be a better suited for a one-to-one rel. Also, try to avoid loading all models's columns and use load_only to select only the necessary.

Copy link
Copy Markdown
Collaborator Author

@Bihan Bihan Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check if below proposed query addresses the concerns

  1. Avoid loading thousands of job submissions: no longer load RunModel.jobs unconditionally. The selectinload(RunModel.jobs.and_(...)) restricts the loaded jobs to only RUNNING + registered replicas, which are the only ones sync_router_workers_for_run_model() can use (router job selection and worker list building both ignore non‑running / unregistered jobs).

  2. selectinload is intentional: RunModel.jobs is a one‑to‑many collection; using joinedload would duplicate the RunModel row per job.

  3. joinedload for one‑to‑one/many‑to‑one: RunModel.project, JobModel.project, JobModel.instance, InstanceModel.project are loaded with joinedload because these are scalar relationships from from run,job and instance.

  4. Use load_only: This limits columns required by sync_router_workers_for_run_model(run_for_sync) and _get_service_replica_client(job_model)

res = await session.execute(
    select(RunModel)
    .where(RunModel.id == item.run_id)
    .options(
        load_only(RunModel.id, RunModel.run_spec),
        selectinload(
            RunModel.jobs.and_(
                JobModel.status == JobStatus.RUNNING,
                JobModel.registered == true(),
            )
        )
        .load_only(
            JobModel.id,
            JobModel.status,
            JobModel.registered,
            JobModel.job_spec_data,
            JobModel.job_provisioning_data,
            JobModel.job_runtime_data,
        )
        .options(
            joinedload(JobModel.project).load_only(ProjectModel.id, ProjectModel.ssh_private_key),
            joinedload(JobModel.instance)
            .load_only(InstanceModel.id, InstanceModel.remote_connection_info)
            .joinedload(InstanceModel.project)
            .load_only(ProjectModel.id, ProjectModel.ssh_private_key),
        ),
    )
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, at least at a glance

Comment on lines +105 to +112
router_jobs = [
j
for j in run_model.jobs
if job_belongs_to_group(j, group_name) and j.status == JobStatus.RUNNING
]
if not router_jobs or not is_replica_registered(router_jobs):
return None
return router_jobs[0]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be multiple router jobs? If so, how does that work?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first iteration, I suggest restricting the router replica group to count: 1 via configuration validation. The current sync logic effectively assumes a single active router job. We can extend this later to support multiple router replicas for HA.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's worth a comment!

Comment on lines +98 to +107
def run_spec_has_router_replica_group(run_spec: RunSpec) -> bool:
if run_spec.configuration.type != "service":
return False
cfg = run_spec.configuration
if not isinstance(cfg, ServiceConfiguration):
return False
return any(g.router is not None for g in cfg.replica_groups)


async def ensure_service_router_worker_sync_row(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why put these router-speicfic functions in top of runs services.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept it there because they are used by run lifecycle. Should I shift them to src/dstack/_internal/server/services/router_worker_sync.py?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean at least they should not be at the top of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it.

],
)
global_replica_num += 1
await ensure_service_router_worker_sync_row(session, run_model, run_spec)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in-place update supports replicas. What happens if a user adds a router replica in in-place update if ensure_service_router_worker_sync_row() gets called only on submit_run()?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing out. I need to call ensure_service_router_worker_sync_row after this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if a user adds a router replica in in-place update

@Bihan, is this use case expected to work at all? I think it won't work with the current implementation, because adding a router replica means that only this replica should receive requests, which means that other existing replicas should be unregistered from the gateway, which doesn't seem to be implemented.

Similarly, due to the need to register or unregister existing replicas, I assume that the following use cases won't work as expected:

  • Removing a router replica group.
  • Adding the router property to an existing replica group.
  • Removing the router property from an existing replica group.

If supporting these use cases requires additional effort, I can suggest to forbid them for now (see _check_can_update_configuration). And, in that case, only call ensure_service_router_worker_sync_row here and simplify its implementation

Comment on lines +112 to +120
if not run_spec_has_router_replica_group(run_spec):
return
res = await session.execute(
select(ServiceRouterWorkerSyncModel.id).where(
ServiceRouterWorkerSyncModel.run_id == run_model.id
)
)
if res.scalar_one_or_none() is not None:
return
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can it be that ServiceRouterWorkerSyncModel already exists for a run if ensure_service_router_worker_sync_row is called only on run submit?

return
run_model = sync_row.run
if run_model is None:
await session.delete(sync_row)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally use soft deletes in dstack server easier debugging and historical data. Assuming there will be very few ServiceRouterWorkerSyncModel rows (one per service replica router), I'd also soft-delete it for consistency.

)


class ServiceRouterWorkerSyncModel(PipelineModelMixin, BaseModel):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put it somewhere in the end of the file so that "core" models come first.

@@ -0,0 +1,49 @@
"""SSH-tunneled async HTTP client to a job's service port (same path as probes)."""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put this file in jobs services?

@@ -0,0 +1,345 @@
"""Reconcile SGLang router /workers with dstack's registered worker replicas (async, SSH-tunneled)."""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put this file in runs services

Copy link
Copy Markdown
Collaborator

@r4victor r4victor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a quick review of the pipeline code. Haven't looked into the worker sync logic.

@Bihan Bihan force-pushed the support_router_replica_with_pipelines branch from e155d17 to 7b268cb Compare April 9, 2026 10:36
Comment on lines +39 to +45
async def _stream_response_body_bytes(resp: Response, max_bytes: int) -> bytes:
buf = bytearray()
async for chunk in resp.aiter_bytes():
buf.extend(chunk)
if len(buf) > max_bytes:
raise _ResponseTooLargeError()
return bytes(buf)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) We have the join_byte_stream_checked function that appears to do the same thing

@Bihan Bihan force-pushed the support_router_replica_with_pipelines branch from 3bc04df to 8fe01e5 Compare April 13, 2026 07:33
@Bihan Bihan changed the title [Draft PR] Support router as replica with pipelines Support router as replica with pipelines Apr 14, 2026
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) In my view, blog posts should generally remain unchanged, as they are timestamped and serve a historical purpose. As a reader, I wouldn't expect their content to change significantly over time.

I would keep the blog post as is, but add a note at the top indicating that gateway routers are deprecated, along with a reference to the relevant replica-group routers docs or examples.

Comment on lines +101 to +102
!!! note "Deprecation"
Configuring the SGLang router in a gateway will be deprecated in a future release.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) I'd put this at the top of the Router section, so that users don't have to read all of it before they find out that it's irrelevant.

Or even remove the section.

Also:

will be deprecated in a future release

More like is deprecated and will be disallowed in a future release?


<!-- TODO: Gateway creation using fleets is coming to simplify this. -->
!!! note "Gateway-based routing (deprecated)"
If you create a gateway with the [`sglang` router](https://dstack.ai/docs/concepts/gateways/#sglang), you can also run SGLang with PD disaggregation. This method will be deprecated in the future in favor of running the router as a replica.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit)

will be deprecated in the future

More like is deprecated and will be disallowed in the future?

#### SSH fleet

For example, if you run services on the `kubernetes` backend, make sure to also create the gateway in the same backend:
Create an [SSH fleet](https://dstack.ai/docs/concepts/fleets/#apply-a-configuration) that includes one CPU host for the router and one or more GPU hosts for the workers. Make sure the CPU and GPU hosts are in the same network.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) Does it have to be an SSH fleet specifically? I thought elastic (nodes: 0..) cloud and kubernetes fleets could work too — just don't specify any resource constraints in the fleet, and dstack will automatically provision the correct instances (both CPU and GPU, in the same fleet) based on the resources specified in replicas in the run configuration.

Only some backends won't work — like Nebius, which requires all instances in the cluster to be homogeneous.

The are more references to SSH fleets in the docs updated in this PR

fleets: [pd-disagg]

# Custom probe is required for PD disaggregation
# Custom probe is required for PD disaggregation.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) By the way, is it still required? I thought sync_router_workers_for_run_model can gracefully handle the router or workers not being ready, and perform the registration eventually, once they become ready

Comment on lines +1056 to +1058
def validate_replica_group_router_mutex(cls, values):
"""
When a replica group sets `router:`, service-level `router` must be omitted.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +44 to +49
op.create_index(
op.f("ix_service_router_worker_sync_pipeline_fetch_q"),
"service_router_worker_sync",
[sa.literal_column("last_processed_at ASC")],
unique=False,
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this missing the sqlite_where and postgresql_where that are present in models.py?

Not sure why they weren't added automatically. I'd suggest to try and re-generate the migration (using Alembic, in case that's not what you were using previously)

if service.router is not None and service.router.type == RouterType.SGLANG:
path_for_match = path if path.startswith("/") else f"/{path}"
if not _is_whitelisted_path(path_for_match, _SGLANG_WHITELISTED_PATHS):
raise ProxyError("Path is not allowed for this service", status.HTTP_404_NOT_FOUND)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) 403 Forbidden for consistency with the gateway and better semantics?

Comment on lines +77 to +81
_SGLANG_WHITELISTED_PATHS = (
"/generate",
"/v1/",
"/chat/completions",
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) Duplicates the list from the gateway. Consider importing the constant from some common place, like proxy/lib/const.py

Comment on lines +222 to +231
await session.execute(
update(ServiceRouterWorkerSyncModel)
.where(
ServiceRouterWorkerSyncModel.id == item.id,
ServiceRouterWorkerSyncModel.lock_token == item.lock_token,
)
.values(**early_cleanup_update_map)
)
await session.commit()
return
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) Missing the log_lock_token_changed_after_processing call in case the update fails.

To avoid such discrepancies, consider refactoring, so that there is only one place that performs the update (currently there are three in the same method)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants