Skip to content

Multitenant pipeline design#3389

Draft
jmacd wants to merge 2 commits into
open-telemetry:mainfrom
jmacd:jmacd/multitenant
Draft

Multitenant pipeline design#3389
jmacd wants to merge 2 commits into
open-telemetry:mainfrom
jmacd:jmacd/multitenant

Conversation

@jmacd

@jmacd jmacd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Change Summary

Adds the outline of a design for request-level tenant descriptors and a common mechanism
for defining conditional rate and resource limiter configurations by tenant.

What issue does this PR close?

Fixes #470

  • Added a .chloggen/*.yaml entry
  • This PR is a chore (indicated in title)
  • This is a documentation-only PR.

@github-actions github-actions Bot added documentation Improvements or additions to documentation lang:rust Pull requests that update Rust code labels Jun 30, 2026
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (474998a) to head (5cc6e7e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3389      +/-   ##
==========================================
- Coverage   86.23%   86.22%   -0.01%     
==========================================
  Files         739      739              
  Lines      292355   292355              
==========================================
- Hits       252116   252095      -21     
- Misses      39715    39736      +21     
  Partials      524      524              
Components Coverage Δ
otap-dataflow 87.28% <ø> (-0.01%) ⬇️
query_engine 89.58% <ø> (ø)
otel-arrow-go 52.45% <ø> (ø)
quiver 92.19% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jmacd jmacd marked this pull request as ready for review June 30, 2026 22:55
@jmacd jmacd requested a review from a team as a code owner June 30, 2026 22:55
Copilot AI review requested due to automatic review settings June 30, 2026 22:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds new documentation describing a proposed multitenant “tenant descriptor” model for request context propagation and conditional per-tenant limiter configuration, along with an accompanying flow diagram.

Changes:

  • Introduces an agent-multitenancy-design.md design document detailing tenant descriptor resolution, context propagation, and limiter condition evaluation.
  • Adds an SVG diagram illustrating request/descriptor/context flow and how downstream limiters reuse precomputed lookup keys.

Reviewed changes

Copilot reviewed 1 out of 2 changed files in this pull request and generated 4 comments.

File Description
rust/otap-dataflow/docs/agent-multitenancy-design.md New design doc describing tenant descriptor modeling, context propagation, and limiter configuration patterns.
rust/otap-dataflow/docs/agent-multitenancy-diagram.svg New diagram visualizing the request-to-context flow and O(1) limiter probing approach.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

stored.

Tenant descriptors are multi-dimensional to enable tenant assignment
in logically independent ways with user control ove rcardinality. For
Comment on lines +223 to +224
- Reject the configuration: callers asserts single-reource context or else
invalid configuration.
Comment on lines +405 to +410
the context: whichever component consumes the context becomes
responsible releasing it. The detailed Rust ownership mechanics are an
out of scope here (see
[otel-arrow#3316](https://github.com/open-telemetry/otel-arrow/pull/3316),
however the main requirement is that owners are responsible for
releasing resource reservations and sometimes the reservation is
resource holds do not automatically cross shared boundaries (i.e.,
they are `!Send`). Sub-components within the engine (e.g., channels)
assume responsibility for resources, and special nodes (e.g., topic
exporter and receiver) will require an explicit mechanisms to transfer
@jmacd

jmacd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

See github.com//issues/3375

stored.

Tenant descriptors are multi-dimensional to enable tenant assignment
in logically independent ways with user control ove rcardinality. For

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
in logically independent ways with user control ove rcardinality. For
in logically independent ways with user control over cardinality. For

Comment on lines +218 to +227
These actions do not apply in contexts where multiple resources are
present. Under this proposal, the options for handling multi-resource
requests with these resource descriptor actions are:

- Nack the request.
- Reject the configuration: callers asserts single-reource context or else
invalid configuration.
- Do not resolve the action, the descriptor will be unresolved; receivers
and limiters that require this descriptor will reject, otherwise these
requests will not match conditions and take the default limit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we could use the concept of partitioning described here, if it were placed upstream of the thing that applies these actions, to mitigate the issue of mixed resource batches.

This issue #3273 describes the ability to partition based on the result of some OPL expression. In this case the expression could simply be something like resource.attributes["service.name"].

Note: I imagine the partition-by-expression implementation will need to partition on multiple expressions so we can use the output for multiple actions (this is not called out in #3273, but seems like the right thing to do).

Comment on lines +262 to +278
# first-match wins
routes:
# customer_id=bigfish
- entries:
- key: customer_id
value: bigfish
output: bigfish_pipeline
# for premium-tier customers
- entries:
- key: tier
value: premium
output: premium_pipeline
# for any customer
- entries:
- key: customer_id
output: shared_pipeline
default_output: fallback

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Although #3310 only calls out headers, I really intended this to be catchall issue where we added the ability to access / manipulate batch metadata in OPL, and I believe this tenant description is something that could be included. If we did that, I imagine that this kind of tenant based routing could also be expressed in OPL and done through transform processor

signals |
if (tenant_descriptor["customer_id"] == "bigfish" ) { 
  route to "bigfish_pipeline" 
} |
if (tenant_descriptor["tier"] == "premium" ) {
  route to "premium_pipeline"
} |
if (tenant_descriptor["customer_id"] != null) {
  route_to "shared_pipeline"
} |
route_to "fallback"

@albertlockett albertlockett left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Really cool @jmacd !

Like we touched on in SIG on Tuesday, I think there's some overlap between what this PR is trying to acheive and some work we have planned in the query-engine/OPL.

Specifcally, I think this capability to partition by an OPL expression (which could be extracted to a consumable function for easy invocation outside the full query-engine context), could be used to split batches with metadata that could be used to drive some tenant description actions:
#3273

I also think it'd be great to add the capability to work with these tenant descriptors into OPL/query-engine (and batch metadata generally), which I'm tracking in:
#3310

value: production
```

These actions do not apply in contexts where multiple resources are

@lalitb lalitb Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I may be missing something, but this feels like a core gap. Header-derived descriptors classify the whole request/batch as one tenant, while resource_key is single-resource only. For mixed-tenant batches, could we define the expected behavior here? The options below seem to sit at different levels (config rejection, runtime Nack, or unresolved/default), so it would help to specify which applies when.

a queue. The semaphore acts as a gate, limiting the amount of weight
admitted into a logical-admitted state.

The main resource limiter interface **acquire** returns a reservation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this needs a clearer rule. This section says a resource hold can live in Context and follow the request, but later says holds are !Send. If the request then crosses a topic or moves to another core, that seems hard to make safe. Can we define whether holds stay out of Context, or get released and re-acquired at those boundaries?


### Rate limiters

The rate limiter interface **limit** returns a reservation object. If

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems inconsistent with the earlier rate-limit definition. If rate tokens are consumed immediately and not returned, then an earlier grant cannot really be “cancelled” when a later limiter fails. I think this needs a clear reserve/commit/cancel rule for correctness, not just as an efficiency follow-up. For example, all limiters could first reserve, then commit only after every limiter grants.

and semaphore limiters support thread-local use only, so they are
defined with pipeline-scope. The dataflow engine extension mechanism
supports requesting shared extensions at wider scopes generally, so
limiter scope can be thread-local (pipeline), CPU-local (pipeline

@lalitb lalitb Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This scope model (pipeline / group / engine) seems to overlap with the existing resources policy framework in config/src/policy.rs, while the current memory_limiter is restricted to top-level policy only. Can we clarify whether these new per-scope limiters are meant to extend that framework or replace it?

to avoid leaking tenant details outside their scope. The dataflow
engine is responsible for enforcing this discipline automatically.

Pipeline operators are advised not to use unauthenticated request

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we make the trust model a bit more explicit here? If descriptors can come from headers, peer address, or resource attributes, it would help to define which sources are trusted and how they relate to authentication.

Each of these uses the following dimensions:

- Condition name
- Key:values in condition entries of matching bucket

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we clarify the intended metric-label policy for tenant values? Customer IDs, workspace IDs, and subscription IDs may be sensitive or high-cardinality even when bucket count is bounded, so it would help to say whether these values are emitted, hashed, or disabled by default.

@lalitb

lalitb commented Jul 2, 2026

Copy link
Copy Markdown
Member

Thanks for putting this together @jmacd. I like the direction and the model is helpful. I left a few questions where I’m trying to understand the intended integration points better. Once those are clear, I think the design will be easier for implementers to follow consistently.

stored.

Tenant descriptors are multi-dimensional to enable tenant assignment
in logically independent ways with user control ove rcardinality. For

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
in logically independent ways with user control ove rcardinality. For
in logically independent ways with user control over cardinality. For

Tenant descriptors originate in a series of actions, configured at the
top level, group-level, and pipeline-level of the dataflow engine
configuration, since these definitions are shared across pipeline
groups. Descriptor values refer to one or more tenant associations,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand why we say that "these definitions are shared across pipeline groups" when, at the beginning of the sentence, we say that a tenant descriptor can be defined at the pipeline level. In that case, one would expect the descriptor not to be shared at the group level.

Comment on lines +115 to +117
lookup key for evaluating limiter conditions. Tenant descriptor values
must be erased when they go out of scope to avoid leaking sensitive
data across unintended boundaries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I like this concept of boundaries.

@jmacd jmacd marked this pull request as draft July 7, 2026 04:16

@lquerel lquerel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, Josh, for this PR. For now, I'm only doing a partial review, as explained in my last comment. I'll continue tomorrow.

- `remote_address`, `masked_remote_address`: origin network address, optionally CIDR-masked.
- `generic_key`: a static, hard-coded key-value.
- `request_header`: copy a request header value into a descriptor key.
- `request_header_match`: descriptor key value must match a condition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
- `request_header_match`: descriptor key value must match a condition.
- `request_header_match`: request header value must match a condition.

Not 100% sure but that seems more coherent for me.


![A flow diagram showing how tenant descriptor values are computed and flow through the pipeline](./agent-multitenancy-diagram.svg)

### Tenant descriptor actions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The term "action" seems very generic here. Why not use the term "extractor" instead, or "definition" that better characterizes what is being done?
Alternatives: "Tenant description extractor", "Tenant descriptor definition", "Tenant descriptor spec", ...

the keys:

```yaml
actions:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not a big fan of the term actions

Comment on lines +126 to +127
- `request_header`: copy a request header value into a descriptor key.
- `request_header_match`: descriptor key value must match a condition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In other parts of the project, we use the term "transport header" instead of "request header", which seems more appropriate to me.

Multiple tenant descriptors per request are supported, enabling
multiple independent limiters with lower dimensions. For example, a
request may be accompanied by both an end-user tenant identifier and
an acting-on-behalf-of tenant identifier. These are logically

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the term "acting-on-behalf-of” should be clarified somewhere.

Comment on lines +441 to +448
```yaml
token_bucket:
allow: 100_000_000 # maximum per interval
burst: 20_000_000 # maximum individual weight
interval: 60s # interval duration
waiters: 100 # max number waiting
mode: fifo # prioritize fairness
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If I want to apply rate limiters and/or resource limiters per tenant, how should I proceed?

1. Use a group- or engine-shared limiter instance.
2. Route the data by tenant descriptor to a single pipeline, then use a thread-local limiter instance.

Both are reasonable options.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is option 1 really reasonable?

I think option 2 is more appropriate for our engine and our constraints. We could imagine a concept of a tenant cell combined with the concept of topics to implement efficient multi-tenancy in our engine. I'll try to define this concept of a tenant cell more clearly later on.

Comment on lines +506 to +521
type: extension:token_bucket_limiter
config:
unit: network_bytes/second
optional_descriptors: [enduser_tenant_modern, enduser_tenant_legacy]
conditions:
# This gives each workspace with customer_id=bigfish more allowance.
- name: bigfish
entries:
- key: workspace_id
- key: customer_id
value: bigfish
token_bucket:
allow: 50_000 # 50KB/s allowance per pipeline PER CORE
burst: 100_000 # 100KB maximum size PER CORE
cardinality:
max_count: 1000 # Up to 1000 workspaces (single customer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a policy :-)

Comment on lines +543 to +546
capabilities:
# Multiple rate limiters are listed with numeric sorting key "/N".
rate_limit/0: obo_rate
rate_limit/1: customer_rate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How does that work when the number of tenant is high/dynamic?

requests will be cancelled. Specific implementation details are
out-of-scope, see open questions.

### Limiter fairness

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This document/topic is a big piece of work, so I'll stop my review here for now. I'm sure some of my questions/comments will be answered later, but I hope some of them will remain valid even after a full review.

In any case, this document represents a major specification and design effort. I think we should not hesitate to spend time on it to make sure we're aligned on the approach.

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

Labels

documentation Improvements or additions to documentation lang:rust Pull requests that update Rust code size/XL

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants