Skip to content

docs(rfc): propose service-scoped extensions, splitting SubmitQueue storage per service - #740

Open
roychying wants to merge 1 commit into
mainfrom
chenghan.ying/service-scoped-extensions-rfc
Open

roychying wants to merge 1 commit into
mainfrom
chenghan.ying/service-scoped-extensions-rfc

Conversation

@roychying

@roychying roychying commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Why?

Both SubmitQueue services depend on one thirteen-store Storage aggregate, so a gateway controller can resolve BatchStore and nothing objects. The split is already the documented design — status-list-api.md says the gateway owns the request log and three read models, and the orchestrator's stores are pipeline working state — but nothing expresses it.

Separating the services onto their own databases already works today; what does not is provisioning. The schema is one filegroup, so each database gets all thirteen tables, including the nine or four that service never reads.

What?

An RFC for the layout rule: when only one service of a multi-service domain resolves an extension, its Factory, aggregate, implementations, mocks and schema move to {domain}/{service}/extension/{ext}/, while the behavioural
contracts stay shared.

The split is on reachability, not declaration — what a service may resolve is decided by its aggregate's accessors, so that is the part worth scoping. Moving the contracts too would add no enforcement and cost every domain-level caller a dependency on a service package.

Scope is storage only. changeprovider, validator, conflict, buildrunner and speculation are orchestrator-only and would qualify, but none has a schema and none is the reason for this change; they wait for a later RFC. queueconfig stays shared — gateway-only today, expected in both.

Test Plan

Issue

Comment thread doc/rfc/service-scoped-extensions.md Outdated

**Promote the contract package to `platform/`.** Rejected under Decision 5.

## Worth considering afterwards: relocating `submitqueue/core/`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Could take the same approach for 'core/'. not necessary now, but might worth considering once this lands.

@roychying
roychying force-pushed the chenghan.ying/service-scoped-extensions-rfc branch from fcd6c7f to ead85cc Compare September 22, 2026 22:48
@roychying
roychying added this pull request to stack #742 September 22, 2026 23:11

@behinddwalls behinddwalls left a comment

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.

Agent-written.

4/9 split is right. Split Factory/Storage by service; keep store interfaces at submitqueue/extension/storage/, or the core/ inversion is in this RFC.

Comment thread doc/rfc/service-scoped-extensions.md Outdated
@@ -0,0 +1,89 @@
# Service-Scoped Extensions

Moving an extension used by exactly one service of a multi-service domain out of `{domain}/extension/` and into `{domain}/{service}/extension/`, so the service and the thing it depends on map one to one. SubmitQueue's storage extension goes first; the layout rule is meant to apply to every extension after it.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

Decision 1 already covers more than storage: changeprovider, validator, conflict, buildrunner, and speculation are orchestrator-only today. Say whether they stay at {domain}/extension/ until a later RFC, or only extensions with a schema split move. Implementation also needs the new path in AGENTS.md.

queueconfig is gateway-only now but expected in both services — leave it at submitqueue/extension/queueconfig/.

Comment thread doc/rfc/service-scoped-extensions.md Outdated

> The gateway owns the append-only request log and three new logical read models. The orchestrator's request and change stores are pipeline working state with different retention semantics, so neither API reads them.

Nothing enforces it. A gateway controller can resolve `BatchStore` and nothing objects. Deployments feel it too: the MySQL schema is one filegroup, so a deployment that gives each service its own database provisions all thirteen tables in both, including the nine or four the service never reads.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

Separate DBs already work: orchestrator publishes logs on the queue; gateway persists them. The four gateway tables are not a shared store.

Comment thread doc/rfc/service-scoped-extensions.md Outdated

1. An extension used by exactly one service of a multi-service domain lives at `{domain}/{service}/extension/{ext}/`, with its implementations, mocks and schema underneath it, exactly as a domain-level extension does today.
2. An extension genuinely shared between a domain's services stays at `{domain}/extension/{ext}/`. A single-service domain is unaffected: its domain root is its service root, so nothing moves.
3. SubmitQueue's storage extension splits into `submitqueue/gateway/extension/storage/` and `submitqueue/orchestrator/extension/storage/`. Each declares its own `Factory` and its own `Storage` aggregate covering only its stores.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

Split Factory/Storage by service. Keep store interfaces in submitqueue/extension/storage/. Moving *_store.go forces core/ to import a service package in this refactor, not later.

Comment thread doc/rfc/service-scoped-extensions.md Outdated
1. An extension used by exactly one service of a multi-service domain lives at `{domain}/{service}/extension/{ext}/`, with its implementations, mocks and schema underneath it, exactly as a domain-level extension does today.
2. An extension genuinely shared between a domain's services stays at `{domain}/extension/{ext}/`. A single-service domain is unaffected: its domain root is its service root, so nothing moves.
3. SubmitQueue's storage extension splits into `submitqueue/gateway/extension/storage/` and `submitqueue/orchestrator/extension/storage/`. Each declares its own `Factory` and its own `Storage` aggregate covering only its stores.
4. `submitqueue/extension/storage/` survives as a contract-only package: the error vocabulary and `Config`, which both halves and `submitqueue/core/` depend on. It keeps its import path, so nothing that uses only those symbols changes.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

ErrNotFound/Config callers stay. Materializer and TerminateRequest take Factory/Storage and will change. The storage README belongs with the interfaces.

Comment thread doc/rfc/service-scoped-extensions.md Outdated
3. SubmitQueue's storage extension splits into `submitqueue/gateway/extension/storage/` and `submitqueue/orchestrator/extension/storage/`. Each declares its own `Factory` and its own `Storage` aggregate covering only its stores.
4. `submitqueue/extension/storage/` survives as a contract-only package: the error vocabulary and `Config`, which both halves and `submitqueue/core/` depend on. It keeps its import path, so nothing that uses only those symbols changes.
5. The contract package is not promoted to `platform/`. It is not cross-domain — `stovepipe/extension/storage/storage.go` already declares its own verbatim copy of the same symbols, so a per-domain error vocabulary is the existing pattern rather than something this RFC introduces.
6. Each half owns its own MySQL schema directory, so the tables a service creates are the tables it reads.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

Keep a union schema for colocated DBs (e2e, local compose), as #737 did. Split schemas are the opt-in for separate DBs.

Comment thread doc/rfc/service-scoped-extensions.md Outdated

## Worth considering afterwards: relocating `submitqueue/core/`

Not part of this refactor, and nothing here depends on it. Raising it because the split makes an existing oddity visible, and the cheapest moment to act on it is while the surrounding code is already being touched.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

If interfaces move with Decision 3, core/ has no domain Storage to import. That is in scope, not a follow-on.

Comment thread doc/rfc/service-scoped-extensions.md Outdated

So the suggestion is to move `core/batch` and `core/changeset` under the orchestrator, and split `core/request` by file. It would be mechanical: the files already divide along the line, and rebinding them has already proven where each one belongs.

A smaller observation in passing. `core/request/request.go` holds `CurrentState` and `GetCurrentStateFromRequestLog`, and neither has a caller outside that file's own tests. It is an exported symbol in a domain-internal package, so it is not API anyone outside the repository depends on. If that is genuinely dead, deleting it removes the third file from the `core/request` question and leaves two going opposite directions. Worth confirming rather than assuming.

@behinddwalls behinddwalls Sep 22, 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.

Agent-written.

Confirmed unused. Drop from this RFC; delete in its own PR.

@behinddwalls

behinddwalls commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Agent-written.

Layout

platform/extension/counter/              # shared; mysql impl; each service has its own table
submitqueue/
├── extension/
│   ├── storage/                         # errors, Config, store interfaces
│   └── queueconfig/                     # shared — stays here
├── gateway/extension/storage/
│   ├── storage.go                       # Factory/Storage (4)
│   └── mysql/schema/                    # 4 tables
└── orchestrator/extension/storage/
    ├── storage.go                       # Factory/Storage (9)
    └── mysql/schema/                    # 9 tables

No store is shared today. RequestLogStore is gateway-owned (orchestrator publishes logs; gateway persists).

A later shared store: interface in submitqueue/extension/storage/, accessor on both aggregates, explicit table ownership. Do not reassemble the 13-way Storage.

queueconfig stays at submitqueue/extension/queueconfig/. It will be used by both services; do not move it under gateway.

@behinddwalls behinddwalls left a comment

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.

Agent-written.

Requesting changes: split Factory/Storage by service, but keep store interfaces in submitqueue/extension/storage/. Moving *_store.go inverts core/ and is in scope for this RFC. queueconfig stays domain-level (shared). Preserve a colocated schema union.

@roychying

roychying commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

@behinddwalls Stack now uses your layout. contracts and their mocks stay at submitqueue/extension/storage/, only the aggregate, implementation, mocks and schema are service-scoped. Also added the union schema target and dropped the gateway schema from the orchestrator suite.

But still one more concern. Splitting contract from implementation costs some cohesion. a table's interface now sits in a different tree from its MySQL code and its .sql, and ownership reads from the two aggregates rather than the directory. If the store sets stay disjoint forever, keeping them together is probably the nicer end state?

Went with yours anyway because it avoids the inversion now. moving the contracts to service level only becomes clean after the other extensions refactor lands and core/changeset can follow them. plus it's compatible with a store both services need later if we will have such one.

so imo if the store sets stay disjoint indefinitely and the extensions move is definitely happening, cohesion wins and it's worth revisiting before this is entrenched. otherwise I'm happy here.

@roychying
roychying force-pushed the chenghan.ying/service-scoped-extensions-rfc branch from c5ec2dc to 73eb649 Compare September 23, 2026 19:10
@roychying
roychying force-pushed the chenghan.ying/service-scoped-extensions-rfc branch from 73eb649 to 5051dde Compare September 23, 2026 19:22

This branch has not been deployed

No deployments
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