Skip to content

Commit 5886ee6

Browse files
os-zhuanghotlong
andauthored
perf(security,protocol): stop asking the same question twice within one request (#10824)
* perf(security,protocol): stop asking the same question twice per request Refs #10757 * test(security,protocol): pin the dedupe and its invalidation; changeset Refs #10757 --------- Co-authored-by: Jack Zhuang <zhuangjianguo@gmail.com>
1 parent 86e6ebc commit 5886ee6

5 files changed

Lines changed: 643 additions & 7 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
Stop issuing two DB queries for questions already answered earlier in the same
7+
request (#10757). One authenticated `GET /data/:object?$top=1` measured **24 DB
8+
queries before, 23 after****22** when the caller opts out of the count.
9+
Measured with `X-OS-Debug-Timing: json` on `pnpm dev:crm`, whose `Server-Timing`
10+
carries `db;dur=…;desc="N queries"`.
11+
12+
**`$count=false` now skips the COUNT query** (`@objectstack/metadata-protocol`).
13+
The parameter has been declared (`ODataQuerySchema.$count`), aliased on the wire
14+
(`$count``count`), reserved out of the implicit-field-filter bucket,
15+
arity-checked and boolean-coerced for a long time — and then deleted unread, so
16+
every paginated list ran `engine.count()` whether or not the caller wanted a
17+
total. It is honoured now:
18+
19+
```
20+
GET /data/task?$top=25 → { records, total, hasMore } (unchanged)
21+
GET /data/task?$top=25&$count=true → { records, total, hasMore } (unchanged)
22+
GET /data/task?$top=25&$count=false → { records, hasMore } (no COUNT query)
23+
```
24+
25+
Read the shape of that carefully before adopting it:
26+
27+
- **Only an explicit `false` opts out.** An ABSENT `$count` still counts and
28+
still reports `total`. OData reads absent as "omit the count", and taking that
29+
reading here would silently strip `total` from every existing caller — none of
30+
them send the parameter, all of them read the number. The asymmetry is
31+
deliberate and pinned by tests.
32+
- **`total` is OMITTED, never estimated.** `FindDataResponse.total` is declared
33+
optional ("if requested"), so absent is the declared shape for "not
34+
requested". A caller that opted out and then reads `total` gets `undefined`,
35+
not a plausible-looking guess — guard the read (`total ?? undefined`) or do
36+
not send `$count=false`.
37+
- **`hasMore` is still answered**, from the page alone: a full page means there
38+
may be more. Same page-local rule the `$search` path already uses.
39+
40+
**A find and its COUNT resolve permission sets once, not twice**
41+
(`@objectstack/plugin-security`). `findData` answers a paginated list with two
42+
engine operations, and the security middleware runs on both; each pass re-read
43+
`sys_permission_set` for the same context with identical bindings. The
44+
resolution is now memoized per execution context — a `WeakMap` keyed on the
45+
context object, which is built once per request and collected with it, so
46+
nothing outlives the caller it was resolved for — and **retired by any write**:
47+
a process-wide epoch is bumped on every `insert`/`update`/`delete` the engine
48+
middleware sees, ahead of the `isSystem` bypass so a seeder, a package publish
49+
or an auto-org-admin grant invalidates too. A context whose grants are rewritten
50+
in place re-resolves as well (the memo key covers `positions`, `permissions`,
51+
`principalKind` and the presence of `userId`). No authorization answer is reused
52+
across a write, across a context, or across a request.
53+
54+
Not a fix for the whole cost: the remaining ~22 queries per authenticated
55+
request are session resolution, grant resolution, localization and metadata
56+
reads that repeat on every request. Removing those needs cross-request caching
57+
with an invalidation design, which is deliberately not in this change.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #10757 — `$count=false` skips the COUNT query.
5+
*
6+
* ## What was wrong
7+
*
8+
* `$count` has been a fully-plumbed parameter for a long time: declared in the
9+
* spec (`ODataQuerySchema.$count`, `packages/spec/src/api/odata.zod.ts`),
10+
* aliased on the wire (`$count` → `count`, `WIRE_DOLLAR_ALIASES`), reserved out
11+
* of the implicit-field-filter bucket (`RESERVED_LIST_QUERY_PARAMS`),
12+
* arity-checked (`protocol.query-param-arity.test.ts`) and boolean-coerced —
13+
* and then DELETED unread by the protocol-key strip in `findData`. So every
14+
* paginated list issued `engine.count()` whether or not the caller wanted a
15+
* `total`, which on a remote database is a whole round trip per request. The
16+
* measured trace on a real stack put it at query 24 of 24 for one
17+
* `GET /data/:object?$top=1`.
18+
*
19+
* ## The two directions this suite pins, and why both are needed
20+
*
21+
* 1. **The OPT-OUT works** — an explicit `false` (either spelling) means no
22+
* `engine.count()` call and no `total` key. `expect(count).not.toHaveBeenCalled()`
23+
* is the load-bearing assertion; asserting only the absent `total` would
24+
* stay green if a future edit ran the query and merely dropped the number,
25+
* which is the whole cost with none of the saving.
26+
*
27+
* 2. **Nothing else changed** — absent `$count`, and explicit `$count=true`,
28+
* both still count and still report `total`. This is the direction that
29+
* makes the opt-out safe to ship: OData reads an ABSENT `$count` as "omit
30+
* the count", and taking that reading here would silently strip `total`
31+
* from every existing caller (none of them send the parameter, all of them
32+
* read the number). The asymmetry is deliberate, so it is pinned rather
33+
* than left to be "tidied up" later.
34+
*
35+
* `total` is OMITTED rather than estimated — `FindDataResponseSchema` declares
36+
* it optional ("if requested"), and a page-local guess handed back to a caller
37+
* who declined the real number is how an estimate ends up rendered as a record
38+
* count. `hasMore` is still answered from the page alone.
39+
*/
40+
41+
import { describe, it, expect, vi } from 'vitest';
42+
import { ObjectStackProtocolImplementation } from './protocol.js';
43+
44+
const SCHEMA = {
45+
name: 'invoice',
46+
nameField: 'name',
47+
fields: {
48+
name: { name: 'name', type: 'text' },
49+
status: { name: 'status', type: 'text' },
50+
},
51+
};
52+
53+
function makeProtocol(pageSize: number) {
54+
const find = vi.fn(async () => Array.from({ length: pageSize }, (_, i) => ({ id: `r${i}` })));
55+
const count = vi.fn(async () => 3125);
56+
const engine = {
57+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
58+
find,
59+
count,
60+
aggregate: vi.fn(async () => [] as unknown[]),
61+
};
62+
return { p: new ObjectStackProtocolImplementation(engine as any), find, count };
63+
}
64+
65+
describe('[#10757] findData honours $count=false', () => {
66+
describe('opt-out — the COUNT query is not issued', () => {
67+
// Both wire spellings reach the same normalized `count` slot; a fix that
68+
// read only one of them would leave the other paying for the query.
69+
for (const spelling of ['$count', 'count'] as const) {
70+
it(`?${spelling}=false skips engine.count() and omits total`, async () => {
71+
const { p, count } = makeProtocol(1);
72+
73+
const result = await p.findData({
74+
object: 'invoice',
75+
query: { $top: 1, [spelling]: 'false' },
76+
} as never);
77+
78+
expect(count).not.toHaveBeenCalled();
79+
expect('total' in (result as object)).toBe(false);
80+
});
81+
}
82+
83+
it('accepts the already-boolean form a POST body carries', async () => {
84+
const { p, count } = makeProtocol(1);
85+
86+
const result = await p.findData({
87+
object: 'invoice',
88+
query: { $top: 1, count: false },
89+
} as never);
90+
91+
expect(count).not.toHaveBeenCalled();
92+
expect('total' in (result as object)).toBe(false);
93+
});
94+
95+
it('still answers hasMore from the page: a FULL page means there may be more', async () => {
96+
const { p } = makeProtocol(10);
97+
98+
const result = await p.findData({
99+
object: 'invoice',
100+
query: { $top: 10, $count: 'false' },
101+
} as never);
102+
103+
expect(result.hasMore).toBe(true);
104+
});
105+
106+
it('…and a SHORT page means there are not', async () => {
107+
const { p } = makeProtocol(3);
108+
109+
const result = await p.findData({
110+
object: 'invoice',
111+
query: { $top: 10, $count: 'false' },
112+
} as never);
113+
114+
expect(result.hasMore).toBe(false);
115+
});
116+
117+
it('leaves `count` off the engine option bag (it is a protocol-layer flag)', async () => {
118+
const { p, find } = makeProtocol(1);
119+
120+
await p.findData({ object: 'invoice', query: { $top: 1, $count: 'false' } } as never);
121+
122+
const bag = (find.mock.calls[0] as unknown[])[1] as Record<string, unknown>;
123+
expect('count' in bag).toBe(false);
124+
expect('$count' in bag).toBe(false);
125+
});
126+
});
127+
128+
describe('unchanged for every caller that does not opt out', () => {
129+
it('an ABSENT $count still counts and still reports total', async () => {
130+
const { p, count } = makeProtocol(1);
131+
132+
const result = await p.findData({ object: 'invoice', query: { $top: 1 } } as never);
133+
134+
expect(count).toHaveBeenCalledTimes(1);
135+
expect(result.total).toBe(3125);
136+
expect(result.hasMore).toBe(true);
137+
});
138+
139+
it('an explicit $count=true still counts and still reports total', async () => {
140+
const { p, count } = makeProtocol(1);
141+
142+
const result = await p.findData({
143+
object: 'invoice',
144+
query: { $top: 1, $count: 'true' },
145+
} as never);
146+
147+
expect(count).toHaveBeenCalledTimes(1);
148+
expect(result.total).toBe(3125);
149+
});
150+
151+
it('$count=false without a limit is a no-op — the full set is already the total', async () => {
152+
// No `limit` ⇒ the whole result set came back, so `records.length` IS
153+
// the total and `engine.count()` was never called even before #10757.
154+
// Pinned so the opt-out cannot accidentally start suppressing a total
155+
// that costs nothing.
156+
const { p, count } = makeProtocol(4);
157+
158+
const result = await p.findData({
159+
object: 'invoice',
160+
query: { $count: 'false' },
161+
} as never);
162+
163+
expect(count).not.toHaveBeenCalled();
164+
expect(result.total).toBe(4);
165+
expect(result.hasMore).toBe(false);
166+
});
167+
});
168+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8881,7 +8881,8 @@ export class ObjectStackProtocolImplementation implements
88818881
// ride the spread into the engine AST and OVERRIDE the resolved
88828882
// object, splitting `ast.object` from the table actually queried —
88838883
// a mismatch is refused, never resolved by picking a winner.
8884-
// - `count`: a response-shape flag this method consumed above.
8884+
// - `count`: a response-shape flag this method consumed above (and,
8885+
// since #10757, one it actually HONOURS — see `countOptOut` below).
88858886
// - QueryAST tombstones (`cursor`/`joins`/`windowFunctions`/
88868887
// `distinct`): reserved at the wire gate so they are not read as
88878888
// field filters; on the wire they stay ignored-with-tombstone-docs
@@ -8899,6 +8900,22 @@ export class ObjectStackProtocolImplementation implements
88998900
err.code = 'QUERY_OBJECT_MISMATCH';
89008901
throw err;
89018902
}
8903+
// [#10757] `$count=false` — read the flag BEFORE the strip below deletes
8904+
// it, because the strip is what kept it from ever being honoured: the
8905+
// parameter has been declared (`ODataQuerySchema.$count`,
8906+
// `packages/spec/src/api/odata.zod.ts`), aliased (`$count` → `count`,
8907+
// {@link WIRE_DOLLAR_ALIASES}), reserved from the implicit-field-filter
8908+
// bucket ({@link RESERVED_LIST_QUERY_PARAMS}), arity-checked and boolean-
8909+
// coerced — and then deleted unread, so every list request paid for the
8910+
// COUNT query below whether or not the caller wanted a `total`.
8911+
//
8912+
// Only an EXPLICIT `false` opts out. An absent `$count` keeps today's
8913+
// behaviour (count runs, `total` is reported) rather than taking OData's
8914+
// "absent means omit" reading: every existing caller sends nothing and
8915+
// reads `total`, so the OData default would silently break all of them.
8916+
// The parameter is therefore an opt-OUT here, and that asymmetry is
8917+
// deliberate — see the changeset for the wording that ships to consumers.
8918+
const countOptOut = options.count === false;
89028919
for (const k of ['object', 'count', 'joins', 'windowFunctions', 'cursor', 'distinct', 'having']) {
89038920
delete options[k];
89048921
}
@@ -8915,26 +8932,55 @@ export class ObjectStackProtocolImplementation implements
89158932
// reporting a wrong total.
89168933
const pageLimit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : undefined;
89178934
const pageOffset = typeof options.offset === 'number' && options.offset > 0 ? options.offset : 0;
8918-
let total = records.length;
8935+
let total: number | undefined = records.length;
89198936
let hasMore = false;
89208937
if (pageLimit !== undefined) {
89218938
// `distinct` used to suppress the count here too — #4286 finding 2:
89228939
// the flag's ONLY observable effect platform-wide, on a capability
89238940
// that never deduplicated a row. Removed with `query.distinct`
89248941
// (tombstoned in spec 17); `total`/`hasMore` are truthful again.
89258942
const countable = options.search == null;
8926-
if (countable) {
8943+
if (countOptOut) {
8944+
// [#10757] The caller said it does not need `total`, so the
8945+
// COUNT query is not issued at all — that is the whole point of
8946+
// the parameter, and on a remote database it is a full round
8947+
// trip saved per list request.
8948+
//
8949+
// `total` is OMITTED rather than estimated. `FindDataResponse`
8950+
// declares it optional ("Total number of records matching the
8951+
// filter (IF REQUESTED)",
8952+
// `packages/spec/src/api/protocol.zod.ts`), so absent is the
8953+
// declared shape for "not requested" — and it is the only
8954+
// honest one: the `search` branch below reports an estimate
8955+
// because it has no better number to give, while here a real
8956+
// number was available and the caller declined it. Handing back
8957+
// a plausible-looking guess under those circumstances is how a
8958+
// page-local estimate ends up rendered as a record count.
8959+
//
8960+
// `hasMore` is still answered, from the page alone: a FULL page
8961+
// means there may be more. Same page-local rule the search
8962+
// branch uses, and it never over-reports the data — it can only
8963+
// say "maybe more" on an exactly-full last page.
8964+
hasMore = records.length === pageLimit;
8965+
total = undefined;
8966+
} else if (countable) {
8967+
// [#10757] `counted` is a separate, always-assigned local so
8968+
// `hasMore` below compares against a `number`: `total` became
8969+
// optional when `$count=false` gained the right to omit it, and
8970+
// TypeScript cannot narrow it back across the try/catch.
8971+
let counted: number;
89278972
try {
8928-
total = await this.engine.count(request.object, {
8973+
counted = await this.engine.count(request.object, {
89298974
where: options.where,
89308975
context: options.context,
89318976
} as any);
89328977
} catch {
89338978
// engine.count() has its own find().length fallback; if it still
89348979
// throws, degrade to a page-local total rather than failing the list.
8935-
total = pageOffset + records.length;
8980+
counted = pageOffset + records.length;
89368981
}
8937-
hasMore = pageOffset + records.length < total;
8982+
total = counted;
8983+
hasMore = pageOffset + records.length < counted;
89388984
} else {
89398985
hasMore = records.length === pageLimit;
89408986
total = pageOffset + records.length + (hasMore ? 1 : 0);
@@ -8943,7 +8989,11 @@ export class ObjectStackProtocolImplementation implements
89438989
return {
89448990
object: request.object,
89458991
records,
8946-
total,
8992+
// [#10757] Omitted, not `undefined`-valued: a JSON body carrying
8993+
// `"total": null` (or a key some serializers keep) reads as "the
8994+
// total is nothing", which is a different claim from "no total was
8995+
// requested". The key is simply absent.
8996+
...(total === undefined ? {} : { total }),
89478997
hasMore,
89488998
};
89498999
}

0 commit comments

Comments
 (0)