Skip to content

[rfc]: Dynamic Upstream Routing#32

Open
pseudomuto wants to merge 1 commit into
mainfrom
rfc-upstream-routing
Open

[rfc]: Dynamic Upstream Routing#32
pseudomuto wants to merge 1 commit into
mainfrom
rfc-upstream-routing

Conversation

@pseudomuto

@pseudomuto pseudomuto commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Document the proposal for upstream routing and configuration schema to support multiple upstream clusters based on namespace.

https://github.com/temporalio/temporal-proxy/blob/rfc-upstream-routing/rfc/routing.md

Document the proposal for upstream routing and configuration schema to support multiple upstream clusters based on namespace.
@pseudomuto pseudomuto requested a review from a team as a code owner June 15, 2026 19:33
Comment thread rfc/routing.md

The proxy currently supports a single static upstream defined in config. However, we want a single proxy (tenant) to serve many namespaces, each potentially terminating at a different address/upstream.

This can be for a number of reasons, but notably, during migrations, some teams will want to move over namespace-by-namespace rather than cluster-by-cluster. They’d point all namespaces to the original upstream (typically the OSS frontend) and switch to another one as needed based on some routing rules.

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.

Practically speaking, I would likely set the proxy to route a fixed/known set of namespaces to OSS and everything else to their upstream. Then, as you work your way down the list, you end up with a single "forward all namespaces" rule, rather than a bunch of namespace-specific rules.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think something important about proxy config - especially when migrations are involved.

The final state configuration shouldn't have residual artefacts that a migration occurred.

Comment thread rfc/routing.md

The proxy currently supports a single static upstream defined in config. However, we want a single proxy (tenant) to serve many namespaces, each potentially terminating at a different address/upstream.

This can be for a number of reasons, but notably, during migrations, some teams will want to move over namespace-by-namespace rather than cluster-by-cluster. They’d point all namespaces to the original upstream (typically the OSS frontend) and switch to another one as needed based on some routing rules.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice. Yes this will keep granularity in sync with how migrations via s2s proxy currently work: a cluster is connected, but namespaces are replicated per-namespace.

This will allow us to maintain this granularity, and moves the worker pointing logic into a centralised place.

Ie:

  1. connect clusters (via s2s)
  2. set namespace to passive on destination cluster (via s2s)
  3. force replicate (via s2s)
  4. set destination to active (via s2s)
  5. point workers to destination cluster (now active) (via worker proxy)
  6. remove source namespace as a replication target (via s2s)

I wonder if we can add management apis on the worker proxy (this proxy) to facilitate pointing workers from source cluster to destination cluster.

This would enable us to fully script the e2e namespace migration process from source to destination cluster. (current gap is requiring workload owner (source owner) to update worker config and restart worker pods. Could we do this with less downtime, and via API.

Comment thread rfc/routing.md

The proxy currently supports a single static upstream defined in config. However, we want a single proxy (tenant) to serve many namespaces, each potentially terminating at a different address/upstream.

This can be for a number of reasons, but notably, during migrations, some teams will want to move over namespace-by-namespace rather than cluster-by-cluster. They’d point all namespaces to the original upstream (typically the OSS frontend) and switch to another one as needed based on some routing rules.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think something important about proxy config - especially when migrations are involved.

The final state configuration shouldn't have residual artefacts that a migration occurred.

Comment thread rfc/routing.md

#### Namespace Rules

Since namespace translation is defined per upstream and we don’t yet know which upstream we’re using, the local namespace (before translation) is used to perform the match. When a namespace is not defined on the rule, no namespace-based matching will be performed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How will inbound routing work for glob-based rules (where inbound is a one to many relationship)

My understanding: rules are evaluated in order at evaluation-time (not pre compiled as a bi-directional map would be).

So in the case of:

  rules:
    - match:
        namespace: "prod-*"
      upstream: prod

    - match:
        namespace: "*-admin"
      upstream: admin

A namespace prod-admin would map to prod-namespace as each rule evaluates in order.

On outbound: prod-admin is evaluated to match prod-* and prod is returned.

On inbound: prod is evaluated to match rule prod-*.

So we know the rule - how is the one-to-many translation identified? This might be later in the file, or I might have missed it.

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.

This is a good point and a potential source of confusion if not documented well. These rules are for routing to upstreams and are evaluated in the order they are defined in the config. There's no namespace translation here. That would be defined per upstream (not covered in this RFC).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

understood! The proxy is only unidirectionally routing - so there is no inbound translation required. Thanks.

@liam-lowe liam-lowe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

not sure if you wanted approval on this, or if it was just for gathering feedback.

If gathering feedback - i'm sure you want more than one perspective - but approving to mark my alignment.

@pseudomuto pseudomuto added the RFC label Jun 29, 2026
Comment thread rfc/routing.md
Comment on lines +112 to +116
- match:
namespace: "prod-*"
headers:
x-tier: "gold"
upstream: cloud-namespace

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is namespace required?

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.

Hmm...I had been operating under the assumption that it was, but I don't think it needs to be since the rules are evaluated in order. Just curious, or did you have a use case in mind which would be headers only? Or something else?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Namespace naming conventions are (were?) loosely enforced, so I think we'd probably want to be able to operate off of headers.

Looking way forward: I want to be able to route Nexus operations dynamically to either TCloud or a local/on-prem cluster. We would be selecting this based on request-boundary headers (e.g. "all Nexus Operations for ServiceA in NamespaceB originating from ApplicationC are routed to on-prem cluster"). I would envision a similar behavior possible for normal namespace traffic as well.

I suspect these more fancy traffic routing capabilities may also just be something we write our own interceptors for.

pseudomuto added a commit that referenced this pull request Jun 30, 2026
Routing rules (RFC #32) match namespaces and metadata values using
simple globs: exact, prefix (foo*), suffix (*foo), and contains (*foo*).
This adds a small, dependency-free pkg/match that compiles a pattern
once and matches against it.

The implementation deliberately supports only those four forms and
rejects an interior "*" (e.g. "a*b") at compile time, so a malformed
pattern fails fast at config-load rather than silently matching the
wrong thing. A general globber (e.g. path.Match, regexp) was avoided on
purpose: those assign meaning to "?", "[]", "\", and treat "/" as a
separator, which would change behavior for operator-authored patterns
and arbitrary metadata values.
pseudomuto added a commit that referenced this pull request Jun 30, 2026
The proxy held a single Config.Upstream, which blocks the dynamic routing
work (RFC #32): routing rules need to refer to upstreams by name, and a
deployment serves more than one. This replaces it with Config.Upstreams, a
named list, as the foundation for the routing block, engine, and connection
registry that will follow.

Upstream targets may be templated (e.g. "{{ .RemoteNamespace }}.acme:7233")
once rendering lands, so Upstream.Validate skips the literal IsHostPort check
when the value contains a template action; a static hostPort is still
validated. The inbound ListenConfig.Validate stays strict, since a listener
address is never templated.

Config.PrimaryUpstream returns the first entry as an explicit,
documented bridge rather than scatter an Upstreams[0] assumption across
the fx modules; it will be removed when routing selects the upstream per
request.
pseudomuto added a commit that referenced this pull request Jun 30, 2026
Upstream definitions (RFC #32) template their hostPort and serverName with
request-derived values so one upstream can serve many namespaces, e.g.
"{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233". This adds internal/template,
a thin wrapper over text/template that compiles a template once and renders it
against a per-request context.

Templates are bound to the context they render against. ParseRouting renders
against a RoutingContext (local namespace and metadata), used while routing
rules pick an upstream; ParseUpstream adds RemoteNamespace, which is only known
after an upstream is selected since translation is per-upstream. Binding the
type means a routing template that references RemoteNamespace fails at parse
time rather than silently rendering empty.

Parsing validates by rendering against a probe context, so an unknown field
(a typo) is caught at config-load. Absent metadata keys render empty rather
than erroring, since the available keys are only known at request time. Must
wraps a Parse call for package-level vars and tests.
pseudomuto added a commit that referenced this pull request Jul 13, 2026
Routing rules (RFC #32) match namespaces and metadata values using
simple globs: exact, prefix (foo*), suffix (*foo), and contains (*foo*).
This adds a small, dependency-free pkg/match that compiles a pattern
once and matches against it.

The implementation deliberately supports only those four forms and
rejects an interior "*" (e.g. "a*b") at compile time, so a malformed
pattern fails fast at config-load rather than silently matching the
wrong thing. A general globber (e.g. path.Match, regexp) was avoided on
purpose: those assign meaning to "?", "[]", "\", and treat "/" as a
separator, which would change behavior for operator-authored patterns
and arbitrary metadata values.
pseudomuto added a commit that referenced this pull request Jul 13, 2026
Upstream definitions (RFC #32) template their hostPort and serverName with
request-derived values so one upstream can serve many namespaces, e.g.
"{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233". This adds internal/template,
a thin wrapper over text/template that compiles a template once and renders it
against a per-request context.

Templates are bound to the context they render against. ParseRouting renders
against a RoutingContext (local namespace and metadata), used while routing
rules pick an upstream; ParseUpstream adds RemoteNamespace, which is only known
after an upstream is selected since translation is per-upstream. Binding the
type means a routing template that references RemoteNamespace fails at parse
time rather than silently rendering empty.

Parsing validates by rendering against a probe context, so an unknown field
(a typo) is caught at config-load. Absent metadata keys render empty rather
than erroring, since the available keys are only known at request time. Must
wraps a Parse call for package-level vars and tests.
pseudomuto added a commit that referenced this pull request Jul 13, 2026
Upstream definitions (RFC #32) template their hostPort and serverName with
request-derived values so one upstream can serve many namespaces, e.g.
"{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233". This adds internal/template,
a thin wrapper over text/template that compiles a template once and renders it
against a per-request context.

Templates are bound to the context they render against. ParseRouting renders
against a RoutingContext (local namespace and metadata), used while routing
rules pick an upstream; ParseUpstream adds RemoteNamespace, which is only known
after an upstream is selected since translation is per-upstream. Binding the
type means a routing template that references RemoteNamespace fails at parse
time rather than silently rendering empty.

Parsing validates by rendering against a probe context, so an unknown field
(a typo) is caught at config-load. Absent metadata keys render empty rather
than erroring, since the available keys are only known at request time. Must
wraps a Parse call for package-level vars and tests.
pseudomuto added a commit that referenced this pull request Jul 13, 2026
The proxy held a single Config.Upstream, which blocks the dynamic routing
work (RFC #32): routing rules need to refer to upstreams by name, and a
deployment serves more than one. This replaces it with Config.Upstreams, a
named list, as the foundation for the routing block, engine, and connection
registry that will follow.

Upstream targets may be templated (e.g. "{{ .RemoteNamespace }}.acme:7233")
once rendering lands, so Upstream.Validate skips the literal IsHostPort check
when the value contains a template action; a static hostPort is still
validated. The inbound ListenConfig.Validate stays strict, since a listener
address is never templated.

Config.PrimaryUpstream returns the first entry as an explicit,
documented bridge rather than scatter an Upstreams[0] assumption across
the fx modules; it will be removed when routing selects the upstream per
request.
pseudomuto added a commit that referenced this pull request Jul 13, 2026
The proxy held a single Config.Upstream, which blocks the dynamic routing
work (RFC #32): routing rules need to refer to upstreams by name, and a
deployment serves more than one. This replaces it with Config.Upstreams, a
named list, as the foundation for the routing block, engine, and connection
registry that will follow.

Upstream targets may be templated (e.g. "{{ .RemoteNamespace }}.acme:7233")
once rendering lands, so Upstream.Validate skips the literal IsHostPort check
when the value contains a template action; a static hostPort is still
validated. The inbound ListenConfig.Validate stays strict, since a listener
address is never templated.

Config.PrimaryUpstream returns the first entry as an explicit,
documented bridge rather than scatter an Upstreams[0] assumption across
the fx modules; it will be removed when routing selects the upstream per
request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants