Skip to content

feat(pl-middle-layer): use server-side LoadSubtree for tree refreshes when available#1583

Open
PoslavskySV wants to merge 1 commit into
mainfrom
feature/load-subtree-rpc
Open

feat(pl-middle-layer): use server-side LoadSubtree for tree refreshes when available#1583
PoslavskySV wants to merge 1 commit into
mainfrom
feature/load-subtree-rpc

Conversation

@PoslavskySV

@PoslavskySV PoslavskySV commented Apr 23, 2026

Copy link
Copy Markdown
Member

Problem

Opening a project and applying block mutations (reorder/delete) walk the resource graph client-side: one gRPC round-trip per graph layer. On LAN this is invisible; on high-latency/lossy connections it compounds into multi-second hangs and network timeout errors. The bottleneck is architectural — not bandwidth, not payload size — so a browser app loading a heavy page over the same connection is unaffected.

See the paired pl PR for the full problem framing.

Change

When the backend advertises the loadSubtree:v1 capability (via MaintenanceAPI.Ping.Response.capabilities), tree synchronization uses the new server-side ResourceAPI.LoadSubtree RPC instead of the client-driven BFS. The walk happens locally on the backend against the open read transaction and streams results back in a single RPC regardless of graph depth.

Fallback is transparent — when the capability is absent, the loader uses the existing client-driven BFS with no behavior change.

Packages touched

@milaboratories/pl-client

  • PlClient.hasServerCapability(capability: string): boolean — reads the new capabilities field from Ping.
  • PlTransaction.loadSubtree(opts: LoadSubtreeOptions): Promise<LoadedSubtreeNode[]> — wire wrapper over the new RPC.
  • PruningRule / PruningSpec / LoadSubtreeOptions / LoadedSubtreeNode — declarative wire types exported publicly so pl-tree and middle-layer can describe pruning without touching proto-generated identifiers.
  • TxStat gains loadSubtreeRequests, loadSubtreeNodes, loadSubtreeFields, loadSubtreeBytes.
  • Proto regen is minimal — only our additions in the generated api.ts. Unrelated proto messages are unchanged.
  • REST Ping wrapper in ll_client.ts defaults capabilities to [] so the REST path tolerates older openapi schemas.

@milaboratories/pl-tree

  • New loadTreeStateServerSide(tx, request, stats) peer to existing loadTreeState. Same signature, same return type.
  • TreeLoadingRequest gains root and optional pruningSpec fields.
  • SynchronizedTreeOps gains an optional pruningSpec.
  • SynchronizedTreeState.refresh() chooses the loader: if a spec is supplied and the client reports the capability, it calls loadTreeStateServerSide; otherwise it falls back to the classic loader.
  • LoadSubtreeCapability = "loadSubtree:v1" constant exported for symmetry with the backend token.

@milaboratories/pl-middle-layer

  • projectTreePruningSpec (in project.ts) — declarative equivalent of projectTreePruning, covering StreamWorkdir/*, BlockPackCustom.template, UserProject.__serviceTemplate*, Blob.
  • ProjectsListTreePruningSpec (in project_list.ts) — declarative equivalent of ProjectsListTreePruningFunction.
  • Both SynchronizedTreeState.init call sites now pass both pruning (function, for fallback) and pruningSpec (declarative, for server-side path).
  • The "resource with excessive field count" warning stays in projectTreePruning; it fires only on the fallback path. Parity-logging on the server-side path is out of scope for this PR.

Why both PruningFunction and PruningSpec?

The two are maintained in lock-step: the function is the authoritative filter on the classic loader, the spec is the authoritative filter on the server-side path. Unit tests in the paired pl PR cover the exact translation, so regressing one and not the other is detectable.

Keeping both avoids a breaking change for any external consumer still wiring a PruningFunction directly, and lets new/old backends coexist without the middle layer having to branch.

Compatibility

Zero-impact on old backends: the switch inside refresh() keys on the explicit capability, so a backend that doesn't advertise it keeps using the client-driven BFS just like today. No wasted round-trip, no UNIMPLEMENTED probes.

Also forward-compatible: if a future feature needs similar gating, hasServerCapability is reusable with a different token.

Test plan

  • pnpm types:check green in pl-client, pl-tree.
  • pnpm build green in pl-client, pl-tree, pl-middle-layer, and the desktop app (via pnpm overrides).
  • pnpm fmt applied to all three packages.
  • Manual test vs a local backend built from the paired pl PR:
    • curl /v1/ping shows capabilities: ["loadSubtree:v1"].
    • Opening a project with the new desktop + new backend uses the server-side path (one RTT per refresh in tree stats).
    • Opening a project with the new desktop + old backend (capability removed from backend) falls back to the client-driven BFS with identical UI behavior.
    • Block delete and reorder complete faster under simulated latency (tc qdisc or equivalent).

Changeset

.changeset/load-subtree-rpc.md — minor bumps for pl-client, pl-tree, pl-middle-layer.

Depends on

milaboratory/pl#1776 — the backend change adding ResourceAPI.LoadSubtree and the capabilities Ping field. This PR is safe to merge and ship against older pl releases (fallback kicks in); the fast path only activates once a pl with the new RPC is deployed.

Greptile Summary

This PR introduces server-side subtree loading (ResourceAPI.LoadSubtree) as a capability-gated fast path for tree synchronization, collapsing a multi-round-trip client-driven BFS into a single RPC. The fallback to the existing client-driven path is transparent and the dual pruning/pruningSpec strategy is well-designed for old/new backend coexistence. Three P2 items worth confirming before or after merging: the server's handling of empty resourceSignature bytes in knownFinals, the server's prefix-matching behaviour for an empty typePattern (catch-all in ProjectsListTreePruningSpec), and the missing prunedFields/finalResourcesSkipped stat increments on the server-side code path.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 clarifications that do not block correctness or fallback behavior.

All three comments are P2: empty signatures are a potential optimization gap (not a functional bug), the catch-all prefix is a protocol question rather than a current failure, and missing stat counters are a monitoring gap. The fallback to the client-driven BFS is fully preserved, so the worst-case scenario on a new backend is a slightly less efficient tree walk.

lib/node/pl-client/src/core/transaction.ts (empty signatures in knownFinals) and lib/node/pl-middle-layer/src/middle_layer/project_list.ts (empty-string prefix catch-all) deserve a cross-check against the paired backend PR.

Important Files Changed

Filename Overview
lib/node/pl-client/src/core/transaction.ts Adds loadSubtree method wiring the new RPC; knownFinals always sent with empty signatures which may silently bypass the skip optimization
lib/node/pl-client/src/core/load_subtree.ts New file: clean TypeScript-idiomatic wire types and encoder for PruningRule/PruningSpec, correctly mapping string unions to proto enums
lib/node/pl-client/src/core/client.ts Adds hasServerCapability helper that safely reads capabilities from ping response; handles null/undefined server info correctly
lib/node/pl-client/src/core/ll_client.ts REST ping path now defaults missing capabilities to [] so older openapi schemas don't break the capability check
lib/node/pl-tree/src/sync.ts Adds loadTreeStateServerSide and LoadSubtreeCapability; prunedFields and finalResourcesSkipped stats are not populated on the server-side path
lib/node/pl-tree/src/synchronized_tree.ts Capability-gated dispatch between server-side and client-driven BFS paths is clean and correct; fallback is transparent
lib/node/pl-middle-layer/src/middle_layer/project.ts Adds projectTreePruningSpec in lock-step with projectTreePruning; all four pruning rules match their function equivalents
lib/node/pl-middle-layer/src/middle_layer/project_list.ts Adds ProjectsListTreePruningSpec; catch-all rule uses empty-string prefix which needs server confirmation that "" matches all type names
lib/node/pl-client/src/core/stat.ts Four new loadSubtree stat fields added consistently across type definition, initializer, and addStat aggregator

Comments Outside Diff (1)

  1. lib/node/pl-tree/src/sync.ts, line 1281-1315 (link)

    P2 stats.prunedFields and stats.finalResourcesSkipped silently stay at 0

    loadTreeStateServerSide never updates stats.prunedFields or stats.finalResourcesSkipped. When the server-side path is active, any log or dashboard that watches these counters will always read 0, making it impossible to tell how much pruning or known-final skipping actually happened on the server. The classic loadTreeState increments both. This doesn't affect correctness, but monitoring parity between the two paths is already called out in the PR as a future concern for the "excessive field count" warning — the stat gap compounds that blind spot.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/node/pl-tree/src/sync.ts
    Line: 1281-1315
    
    Comment:
    **`stats.prunedFields` and `stats.finalResourcesSkipped` silently stay at 0**
    
    `loadTreeStateServerSide` never updates `stats.prunedFields` or `stats.finalResourcesSkipped`. When the server-side path is active, any log or dashboard that watches these counters will always read 0, making it impossible to tell how much pruning or known-final skipping actually happened on the server. The classic `loadTreeState` increments both. This doesn't affect correctness, but monitoring parity between the two paths is already called out in the PR as a future concern for the "excessive field count" warning — the stat gap compounds that blind spot.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/node/pl-client/src/core/transaction.ts
Line: 847-852

Comment:
**Empty `resourceSignature` in `knownFinals` may silently defeat the optimization**

All `knownFinals` entries are sent with `resourceSignature: new Uint8Array(0)`. The proto doc says "Each entry must carry a signature obtained from a prior response." If the backend validates the signature and rejects empty bytes (treating the entry as a cache-miss rather than a known-final), the server will re-fetch and re-descend through every supposedly-skipped resource on every refresh — reducing the server-side path to a full tree walk rather than the incremental walk that justifies the feature. The same empty signature is sent for the root resource itself. Worth cross-checking against the paired backend PR to confirm the server accepts `Uint8Array(0)` as "no signature / skip check".

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/node/pl-tree/src/sync.ts
Line: 1281-1315

Comment:
**`stats.prunedFields` and `stats.finalResourcesSkipped` silently stay at 0**

`loadTreeStateServerSide` never updates `stats.prunedFields` or `stats.finalResourcesSkipped`. When the server-side path is active, any log or dashboard that watches these counters will always read 0, making it impossible to tell how much pruning or known-final skipping actually happened on the server. The classic `loadTreeState` increments both. This doesn't affect correctness, but monitoring parity between the two paths is already called out in the PR as a future concern for the "excessive field count" warning — the stat gap compounds that blind spot.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/node/pl-middle-layer/src/middle_layer/project_list.ts
Line: 36

Comment:
**Catch-all rule relies on empty-string prefix behavior being well-defined**

`{ typeMatch: "prefix", typePattern: "", action: "dropAll" }` is intended as a catch-all that drops every non-Projects resource. An empty prefix matches every string, so this works in standard string semantics — but the backend's prefix-matching logic should explicitly handle `""` as "match all". If the server treats an empty `type_pattern` as "no match" or as a no-op, the catch-all silently stops firing and the walk continues unbound into all subtrees. Worth a one-line comment (or a companion integration test) confirming the server treats `""` as the "match any type" wildcard.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(pl-middle-layer): use server-side L..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

… when available

Replaces the client-driven BFS tree loader with a single RPC to the
new ResourceAPI.LoadSubtree when the backend advertises the
"loadSubtree:v1" capability, falling back transparently to the
existing loader otherwise.

The old loader made one round-trip per graph layer, dominating
project-open and post-mutation refresh time on high-latency
connections. The new path collapses the walk to one RTT regardless
of depth.

Changes:
  - pl-client: PlClient.hasServerCapability helper reading
    MaintenanceAPI.Ping.Response.capabilities; PlTransaction.loadSubtree
    method; declarative PruningRule/PruningSpec/LoadSubtreeOptions/
    LoadedSubtreeNode types; tx stat fields for the new call. Minimal
    proto regen - only our additions in the generated api.ts (no drift
    on unrelated messages).
  - pl-tree: loadTreeStateServerSide as a peer to loadTreeState;
    pruningSpec field on TreeLoadingRequest and SynchronizedTreeOps;
    SynchronizedTreeState.refresh picks the server-side path when a
    spec is supplied and the capability is present.
  - pl-middle-layer: projectTreePruningSpec and
    ProjectsListTreePruningSpec added alongside the existing
    PruningFunctions, kept in lock-step so both old and new backends
    see the same pruning behavior.

Wire protocol unchanged on old backends. No desktop-app changes
required; the new loader is enabled automatically when it connects
to a backend advertising the capability.

Depends on the pl repo change adding ResourceAPI.LoadSubtree.
@changeset-bot

changeset-bot Bot commented Apr 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91ad225

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@milaboratories/pl-client Minor
@milaboratories/pl-tree Minor
@milaboratories/pl-middle-layer Minor
@milaboratories/pl-model-backend Patch
@milaboratories/pl-errors Patch
@milaboratories/pl-drivers Patch
@platforma-sdk/pl-cli Patch
@platforma-sdk/test Patch
@milaboratories/pl-mcp-server Major
@platforma-sdk/tengo-builder Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces server-side tree synchronization via the ResourceAPI.LoadSubtree RPC, which optimizes project opening and refreshing by collapsing multi-round-trip client-driven BFS walks into a single request when the backend supports the loadSubtree:v1 capability. The implementation includes new protobuf definitions, client-side capability detection, declarative pruning rules, and updated transaction statistics. Review feedback suggests that knownFinals signatures should be preserved rather than hardcoded to empty to avoid redundant data transfers, and that the loadSubtreeBytes statistic should include field value lengths for more accurate data tracking.

Comment on lines +847 to +852
const knownFinals = opts.knownFinals
? [...opts.knownFinals].map((rid) => ({
resourceId: toResourceId(rid),
resourceSignature: new Uint8Array(0),
}))
: [];

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.

medium

The knownFinals signatures are currently hardcoded to empty Uint8Array. According to the protobuf definition and comments for LoadSubtree.Request, each entry in known_finals must carry a signature obtained from a prior response to allow the server to verify and skip it. By sending empty signatures, the server may be forced to re-send data for resources the client already holds, which would significantly reduce the performance benefits of this optimization on high-latency connections. If opts.knownFinals contains ResourceData objects, their signatures should be preserved and sent to the server.

Comment on lines +880 to +886
for (const n of result) {
this._stat.loadSubtreeBytes += n.resource.data?.length ?? 0;
this._stat.loadSubtreeFields += n.resource.fields.length;
for (const kv of n.kv) {
this._stat.loadSubtreeBytes += kv.key.length + kv.value.length;
}
}

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.

medium

The loadSubtreeBytes statistic is currently undercounting the actual data transferred because it does not include the size of the field values. In a typical resource tree, the bulk of the data resides within the fields. To maintain consistency with other byte-tracking statistics in TxStat (like kvGetBytes), the lengths of all field values should be added to the total.

      for (const n of result) {
        this._stat.loadSubtreeBytes += n.resource.data?.length ?? 0;
        this._stat.loadSubtreeFields += n.resource.fields.length;
        for (const f of n.resource.fields) {
          this._stat.loadSubtreeBytes += f.value.length;
        }
        for (const kv of n.kv) {
          this._stat.loadSubtreeBytes += kv.key.length + kv.value.length;
        }
      }

Comment on lines +847 to +852
const knownFinals = opts.knownFinals
? [...opts.knownFinals].map((rid) => ({
resourceId: toResourceId(rid),
resourceSignature: new Uint8Array(0),
}))
: [];

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.

P2 Empty resourceSignature in knownFinals may silently defeat the optimization

All knownFinals entries are sent with resourceSignature: new Uint8Array(0). The proto doc says "Each entry must carry a signature obtained from a prior response." If the backend validates the signature and rejects empty bytes (treating the entry as a cache-miss rather than a known-final), the server will re-fetch and re-descend through every supposedly-skipped resource on every refresh — reducing the server-side path to a full tree walk rather than the incremental walk that justifies the feature. The same empty signature is sent for the root resource itself. Worth cross-checking against the paired backend PR to confirm the server accepts Uint8Array(0) as "no signature / skip check".

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/node/pl-client/src/core/transaction.ts
Line: 847-852

Comment:
**Empty `resourceSignature` in `knownFinals` may silently defeat the optimization**

All `knownFinals` entries are sent with `resourceSignature: new Uint8Array(0)`. The proto doc says "Each entry must carry a signature obtained from a prior response." If the backend validates the signature and rejects empty bytes (treating the entry as a cache-miss rather than a known-final), the server will re-fetch and re-descend through every supposedly-skipped resource on every refresh — reducing the server-side path to a full tree walk rather than the incremental walk that justifies the feature. The same empty signature is sent for the root resource itself. Worth cross-checking against the paired backend PR to confirm the server accepts `Uint8Array(0)` as "no signature / skip check".

How can I resolve this? If you propose a fix, please make it concise.

action: "includeAll",
},
// Catch-all: drop fields on any other resource and stop descending.
{ typeMatch: "prefix", typePattern: "", action: "dropAll" },

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.

P2 Catch-all rule relies on empty-string prefix behavior being well-defined

{ typeMatch: "prefix", typePattern: "", action: "dropAll" } is intended as a catch-all that drops every non-Projects resource. An empty prefix matches every string, so this works in standard string semantics — but the backend's prefix-matching logic should explicitly handle "" as "match all". If the server treats an empty type_pattern as "no match" or as a no-op, the catch-all silently stops firing and the walk continues unbound into all subtrees. Worth a one-line comment (or a companion integration test) confirming the server treats "" as the "match any type" wildcard.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/node/pl-middle-layer/src/middle_layer/project_list.ts
Line: 36

Comment:
**Catch-all rule relies on empty-string prefix behavior being well-defined**

`{ typeMatch: "prefix", typePattern: "", action: "dropAll" }` is intended as a catch-all that drops every non-Projects resource. An empty prefix matches every string, so this works in standard string semantics — but the backend's prefix-matching logic should explicitly handle `""` as "match all". If the server treats an empty `type_pattern` as "no match" or as a no-op, the catch-all silently stops firing and the walk continues unbound into all subtrees. Worth a one-line comment (or a companion integration test) confirming the server treats `""` as the "match any type" wildcard.

How can I resolve this? If you propose a fix, please make it concise.

@PoslavskySV PoslavskySV requested a review from dbolotin April 23, 2026 01:58
@codecov

codecov Bot commented Apr 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 13.15789% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.96%. Comparing base (f3afc64) to head (91ad225).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
lib/node/pl-client/src/core/transaction.ts 0.00% 17 Missing ⚠️
lib/node/pl-client/src/core/load_subtree.ts 0.00% 8 Missing ⚠️
lib/node/pl-client/src/core/client.ts 0.00% 3 Missing ⚠️
lib/node/pl-client/src/core/ll_client.ts 0.00% 3 Missing ⚠️
lib/node/pl-tree/src/synchronized_tree.ts 60.00% 0 Missing and 2 partials ⚠️

❌ Your patch check has failed because the patch coverage (13.15%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1583      +/-   ##
==========================================
- Coverage   55.98%   53.96%   -2.03%     
==========================================
  Files         198      256      +58     
  Lines       10781    14659    +3878     
  Branches     2306     3045     +739     
==========================================
+ Hits         6036     7911    +1875     
- Misses       3986     5735    +1749     
- Partials      759     1013     +254     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant