@convex-dev/sharded-counter 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -7
- package/dist/commonjs/client/index.d.ts +96 -13
- package/dist/commonjs/client/index.d.ts.map +1 -1
- package/dist/commonjs/client/index.js +108 -5
- package/dist/commonjs/client/index.js.map +1 -1
- package/dist/commonjs/component/_generated/api.d.ts.map +1 -1
- package/dist/commonjs/component/_generated/api.js +0 -2
- package/dist/commonjs/component/_generated/api.js.map +1 -1
- package/dist/commonjs/component/_generated/server.d.ts.map +1 -1
- package/dist/commonjs/component/_generated/server.js +0 -2
- package/dist/commonjs/component/_generated/server.js.map +1 -1
- package/dist/commonjs/component/public.d.ts +14 -1
- package/dist/commonjs/component/public.d.ts.map +1 -1
- package/dist/commonjs/component/public.js +75 -2
- package/dist/commonjs/component/public.js.map +1 -1
- package/dist/esm/client/index.d.ts +96 -13
- package/dist/esm/client/index.d.ts.map +1 -1
- package/dist/esm/client/index.js +108 -5
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/component/_generated/api.d.ts.map +1 -1
- package/dist/esm/component/_generated/api.js +0 -2
- package/dist/esm/component/_generated/api.js.map +1 -1
- package/dist/esm/component/_generated/server.d.ts.map +1 -1
- package/dist/esm/component/_generated/server.js +0 -2
- package/dist/esm/component/_generated/server.js.map +1 -1
- package/dist/esm/component/public.d.ts +14 -1
- package/dist/esm/component/public.d.ts.map +1 -1
- package/dist/esm/component/public.js +75 -2
- package/dist/esm/component/public.js.map +1 -1
- package/package.json +2 -2
- package/src/client/index.ts +174 -17
- package/src/component/_generated/api.d.ts +15 -6
- package/src/component/_generated/api.js +0 -4
- package/src/component/_generated/dataModel.d.ts +0 -4
- package/src/component/_generated/server.d.ts +0 -4
- package/src/component/_generated/server.js +0 -4
- package/src/component/counter.test.ts +48 -6
- package/src/component/public.ts +86 -2
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"email": "support@convex.dev",
|
|
8
8
|
"url": "https://github.com/get-convex/sharded-counter/issues"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.1.
|
|
10
|
+
"version": "0.1.5",
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"keywords": [
|
|
13
13
|
"convex",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dev": "cd example; npm run dev",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"prepare": "npm run build",
|
|
25
|
-
"test": "vitest
|
|
25
|
+
"test": "vitest",
|
|
26
26
|
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
|
27
27
|
"test:coverage": "vitest run --coverage --coverage.reporter=text"
|
|
28
28
|
},
|
package/src/client/index.ts
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DocumentByName,
|
|
2
3
|
Expand,
|
|
3
4
|
FunctionReference,
|
|
4
5
|
GenericDataModel,
|
|
5
6
|
GenericMutationCtx,
|
|
6
7
|
GenericQueryCtx,
|
|
8
|
+
TableNamesInDataModel,
|
|
7
9
|
} from "convex/server";
|
|
8
10
|
import { GenericId } from "convex/values";
|
|
9
11
|
import { api } from "../component/_generated/api";
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A sharded counter is a map from string -> counter, where each counter can
|
|
15
|
+
* be incremented or decremented atomically.
|
|
16
|
+
*/
|
|
17
|
+
export class ShardedCounter<ShardsKey extends string> {
|
|
12
18
|
/**
|
|
13
19
|
* A sharded counter is a map from string -> counter, where each counter can
|
|
14
20
|
* be incremented or decremented.
|
|
15
|
-
*
|
|
21
|
+
*
|
|
16
22
|
* The counter is sharded into multiple documents to allow for higher
|
|
17
23
|
* throughput of updates. The default number of shards is 16.
|
|
18
|
-
*
|
|
24
|
+
*
|
|
19
25
|
* - More shards => higher throughput of updates.
|
|
20
26
|
* - Fewer shards => lower latency when querying the counter.
|
|
21
|
-
*
|
|
27
|
+
*
|
|
22
28
|
* @param options.shards The number of shards for each counter, for fixed
|
|
23
29
|
* keys.
|
|
24
30
|
* @param options.defaultShards The number of shards for each counter, for
|
|
@@ -26,38 +32,130 @@ export class ShardedCounter<Shards extends Record<string, number>> {
|
|
|
26
32
|
*/
|
|
27
33
|
constructor(
|
|
28
34
|
private component: UseApi<typeof api>,
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
options?: {
|
|
36
|
+
shards?: Partial<Record<ShardsKey, number>>;
|
|
37
|
+
defaultShards?: number;
|
|
38
|
+
},
|
|
39
|
+
) {
|
|
40
|
+
this.stickyShard = {};
|
|
41
|
+
const defaultShards = options?.defaultShards;
|
|
42
|
+
this.shardsForKey = (name: ShardsKey) => {
|
|
43
|
+
const explicitShards = options?.shards?.[name];
|
|
44
|
+
return explicitShards ?? defaultShards;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private shardsForKey: (name: ShardsKey) => number | undefined;
|
|
49
|
+
|
|
50
|
+
// Keep track of the shard for each key, so multiple mutations on the same key
|
|
51
|
+
// will use the same shard.
|
|
52
|
+
private stickyShard: Record<string, number>;
|
|
53
|
+
|
|
31
54
|
/**
|
|
32
55
|
* Increase the counter for key `name` by `count`.
|
|
33
56
|
* If `count` is negative, the counter will decrease.
|
|
34
|
-
*
|
|
57
|
+
*
|
|
35
58
|
* @param name The key to update the counter for.
|
|
36
59
|
* @param count The amount to increment the counter by. Defaults to 1.
|
|
37
60
|
*/
|
|
38
|
-
async add<Name extends
|
|
61
|
+
async add<Name extends ShardsKey>(
|
|
39
62
|
ctx: RunMutationCtx,
|
|
40
63
|
name: Name,
|
|
41
|
-
count: number = 1
|
|
64
|
+
count: number = 1,
|
|
42
65
|
) {
|
|
43
|
-
const
|
|
44
|
-
return ctx.runMutation(this.component.public.add, {
|
|
66
|
+
const shard = await ctx.runMutation(this.component.public.add, {
|
|
45
67
|
name,
|
|
46
68
|
count,
|
|
47
|
-
|
|
69
|
+
shard: this.stickyShard?.[name],
|
|
70
|
+
shards: this.shardsForKey(name),
|
|
48
71
|
});
|
|
72
|
+
this.stickyShard[name] = shard;
|
|
49
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decrease the counter for key `name` by `count`.
|
|
77
|
+
*/
|
|
78
|
+
async subtract<Name extends ShardsKey>(
|
|
79
|
+
ctx: RunMutationCtx,
|
|
80
|
+
name: Name,
|
|
81
|
+
count: number = 1,
|
|
82
|
+
) {
|
|
83
|
+
return this.add(ctx, name, -count);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Increment the counter for key `name` by 1.
|
|
88
|
+
*/
|
|
89
|
+
async inc<Name extends ShardsKey>(ctx: RunMutationCtx, name: Name) {
|
|
90
|
+
return this.add(ctx, name, 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decrement the counter for key `name` by 1.
|
|
95
|
+
*/
|
|
96
|
+
async dec<Name extends ShardsKey>(ctx: RunMutationCtx, name: Name) {
|
|
97
|
+
return this.add(ctx, name, -1);
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
/**
|
|
51
101
|
* Gets the counter for key `name`.
|
|
52
102
|
*
|
|
53
103
|
* NOTE: this reads from all shards. If used in a mutation, it will contend
|
|
54
104
|
* with all mutations that update the counter for this key.
|
|
55
105
|
*/
|
|
56
|
-
async count<Name extends
|
|
106
|
+
async count<Name extends ShardsKey>(ctx: RunQueryCtx, name: Name) {
|
|
107
|
+
return ctx.runQuery(this.component.public.count, { name });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Redistribute counts evenly across the counter's shards.
|
|
112
|
+
*
|
|
113
|
+
* If there were more shards for this counter at some point, those shards
|
|
114
|
+
* will be removed.
|
|
115
|
+
*
|
|
116
|
+
* If there were fewer shards for this counter, or if the random distribution
|
|
117
|
+
* of counts is uneven, the counts will be redistributed evenly.
|
|
118
|
+
*
|
|
119
|
+
* This operation reads and writes all shards, so it can cause contention if
|
|
120
|
+
* called too often.
|
|
121
|
+
*/
|
|
122
|
+
async rebalance<Name extends ShardsKey>(ctx: RunMutationCtx, name: Name) {
|
|
123
|
+
await ctx.runMutation(this.component.public.rebalance, {
|
|
124
|
+
name,
|
|
125
|
+
shards: this.shardsForKey(name),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clear the counter for key `name`.
|
|
131
|
+
*
|
|
132
|
+
* @param name The key to clear the counter for.
|
|
133
|
+
*/
|
|
134
|
+
async reset<Name extends ShardsKey>(ctx: RunMutationCtx, name: Name) {
|
|
135
|
+
await ctx.runMutation(this.component.public.reset, { name });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Estimate the count of a counter by only reading from a subset of shards,
|
|
140
|
+
* and extrapolating the total count.
|
|
141
|
+
*
|
|
142
|
+
* After a `rebalance`, or if there were a lot of data points to yield a
|
|
143
|
+
* random distribution across shards, this should be a good approximation of
|
|
144
|
+
* the total count. If there are few data points, which are not evenly
|
|
145
|
+
* distributed across shards, this will be a poor approximation.
|
|
146
|
+
*
|
|
147
|
+
* Use this to reduce contention when reading the counter.
|
|
148
|
+
*/
|
|
149
|
+
async estimateCount<Name extends ShardsKey>(
|
|
57
150
|
ctx: RunQueryCtx,
|
|
58
|
-
name: Name
|
|
151
|
+
name: Name,
|
|
152
|
+
readFromShards: number = 1,
|
|
59
153
|
) {
|
|
60
|
-
return ctx.runQuery(this.component.public.
|
|
154
|
+
return await ctx.runQuery(this.component.public.estimateCount, {
|
|
155
|
+
name,
|
|
156
|
+
shards: this.shardsForKey(name),
|
|
157
|
+
readFromShards,
|
|
158
|
+
});
|
|
61
159
|
}
|
|
62
160
|
/**
|
|
63
161
|
* Returns an object with methods to update and query the counter for key
|
|
@@ -74,7 +172,7 @@ export class ShardedCounter<Shards extends Record<string, number>> {
|
|
|
74
172
|
* });
|
|
75
173
|
* ```
|
|
76
174
|
*/
|
|
77
|
-
for<Name extends
|
|
175
|
+
for<Name extends ShardsKey>(name: Name) {
|
|
78
176
|
return {
|
|
79
177
|
/**
|
|
80
178
|
* Add `count` to the counter.
|
|
@@ -96,17 +194,76 @@ export class ShardedCounter<Shards extends Record<string, number>> {
|
|
|
96
194
|
dec: async (ctx: RunMutationCtx) => this.add(ctx, name, -1),
|
|
97
195
|
/**
|
|
98
196
|
* Get the current value of the counter.
|
|
99
|
-
*
|
|
197
|
+
*
|
|
100
198
|
* NOTE: this reads from all shards. If used in a mutation, it will
|
|
101
199
|
* contend with all mutations that update the counter for this key.
|
|
102
200
|
*/
|
|
103
201
|
count: async (ctx: RunQueryCtx) => this.count(ctx, name),
|
|
202
|
+
/**
|
|
203
|
+
* Reset the counter for this key.
|
|
204
|
+
*/
|
|
205
|
+
reset: async (ctx: RunMutationCtx) => this.reset(ctx, name),
|
|
206
|
+
/**
|
|
207
|
+
* Redistribute counts evenly across the counter's shards.
|
|
208
|
+
*
|
|
209
|
+
* This operation reads and writes all shards, so it can cause contention
|
|
210
|
+
* if called too often.
|
|
211
|
+
*/
|
|
212
|
+
rebalance: async (ctx: RunMutationCtx) => this.rebalance(ctx, name),
|
|
213
|
+
/**
|
|
214
|
+
* Estimate the counter by only reading from a subset of shards,
|
|
215
|
+
* and extrapolating the total count.
|
|
216
|
+
*
|
|
217
|
+
* Use this to reduce contention when reading the counter.
|
|
218
|
+
*/
|
|
219
|
+
estimateCount: async (ctx: RunQueryCtx, readFromShards: number = 1) =>
|
|
220
|
+
this.estimateCount(ctx, name, readFromShards),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
trigger<Ctx extends RunMutationCtx, Name extends ShardsKey>(
|
|
224
|
+
name: Name,
|
|
225
|
+
): Trigger<Ctx, GenericDataModel, TableNamesInDataModel<GenericDataModel>> {
|
|
226
|
+
return async (ctx, change) => {
|
|
227
|
+
if (change.operation === "insert") {
|
|
228
|
+
await this.inc(ctx, name);
|
|
229
|
+
} else if (change.operation === "delete") {
|
|
230
|
+
await this.dec(ctx, name);
|
|
231
|
+
}
|
|
104
232
|
};
|
|
105
233
|
}
|
|
106
234
|
}
|
|
107
235
|
|
|
108
236
|
/* Type utils follow */
|
|
109
237
|
|
|
238
|
+
export type Trigger<
|
|
239
|
+
Ctx,
|
|
240
|
+
DataModel extends GenericDataModel,
|
|
241
|
+
TableName extends TableNamesInDataModel<DataModel>,
|
|
242
|
+
> = (ctx: Ctx, change: Change<DataModel, TableName>) => Promise<void>;
|
|
243
|
+
|
|
244
|
+
export type Change<
|
|
245
|
+
DataModel extends GenericDataModel,
|
|
246
|
+
TableName extends TableNamesInDataModel<DataModel>,
|
|
247
|
+
> = {
|
|
248
|
+
id: GenericId<TableName>;
|
|
249
|
+
} & (
|
|
250
|
+
| {
|
|
251
|
+
operation: "insert";
|
|
252
|
+
oldDoc: null;
|
|
253
|
+
newDoc: DocumentByName<DataModel, TableName>;
|
|
254
|
+
}
|
|
255
|
+
| {
|
|
256
|
+
operation: "update";
|
|
257
|
+
oldDoc: DocumentByName<DataModel, TableName>;
|
|
258
|
+
newDoc: DocumentByName<DataModel, TableName>;
|
|
259
|
+
}
|
|
260
|
+
| {
|
|
261
|
+
operation: "delete";
|
|
262
|
+
oldDoc: DocumentByName<DataModel, TableName>;
|
|
263
|
+
newDoc: null;
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
|
|
110
267
|
type RunQueryCtx = {
|
|
111
268
|
runQuery: GenericQueryCtx<GenericDataModel>["runQuery"];
|
|
112
269
|
};
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* prettier-ignore-start */
|
|
2
|
-
|
|
3
1
|
/* eslint-disable */
|
|
4
2
|
/**
|
|
5
3
|
* Generated `api` utility.
|
|
@@ -33,10 +31,23 @@ export type Mounts = {
|
|
|
33
31
|
add: FunctionReference<
|
|
34
32
|
"mutation",
|
|
35
33
|
"public",
|
|
36
|
-
{ count: number; name: string; shards?: number },
|
|
37
|
-
|
|
34
|
+
{ count: number; name: string; shard?: number; shards?: number },
|
|
35
|
+
number
|
|
38
36
|
>;
|
|
39
37
|
count: FunctionReference<"query", "public", { name: string }, number>;
|
|
38
|
+
estimateCount: FunctionReference<
|
|
39
|
+
"query",
|
|
40
|
+
"public",
|
|
41
|
+
{ name: string; readFromShards?: number; shards?: number },
|
|
42
|
+
any
|
|
43
|
+
>;
|
|
44
|
+
rebalance: FunctionReference<
|
|
45
|
+
"mutation",
|
|
46
|
+
"public",
|
|
47
|
+
{ name: string; shards?: number },
|
|
48
|
+
any
|
|
49
|
+
>;
|
|
50
|
+
reset: FunctionReference<"mutation", "public", { name: string }, any>;
|
|
40
51
|
};
|
|
41
52
|
};
|
|
42
53
|
// For now fullApiWithMounts is only fullApi which provides
|
|
@@ -54,5 +65,3 @@ export declare const internal: FilterApi<
|
|
|
54
65
|
>;
|
|
55
66
|
|
|
56
67
|
export declare const components: {};
|
|
57
|
-
|
|
58
|
-
/* prettier-ignore-end */
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* prettier-ignore-start */
|
|
2
|
-
|
|
3
1
|
/* eslint-disable */
|
|
4
2
|
/**
|
|
5
3
|
* Generated `api` utility.
|
|
@@ -23,5 +21,3 @@ import { anyApi, componentsGeneric } from "convex/server";
|
|
|
23
21
|
export const api = anyApi;
|
|
24
22
|
export const internal = anyApi;
|
|
25
23
|
export const components = componentsGeneric();
|
|
26
|
-
|
|
27
|
-
/* prettier-ignore-end */
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* prettier-ignore-start */
|
|
2
|
-
|
|
3
1
|
/* eslint-disable */
|
|
4
2
|
/**
|
|
5
3
|
* Generated data model types.
|
|
@@ -60,5 +58,3 @@ export type Id<TableName extends TableNames | SystemTableNames> =
|
|
|
60
58
|
* `mutationGeneric` to make them type-safe.
|
|
61
59
|
*/
|
|
62
60
|
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
|
63
|
-
|
|
64
|
-
/* prettier-ignore-end */
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* prettier-ignore-start */
|
|
2
|
-
|
|
3
1
|
/* eslint-disable */
|
|
4
2
|
/**
|
|
5
3
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
|
@@ -149,5 +147,3 @@ export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
|
|
149
147
|
* for the guarantees Convex provides your functions.
|
|
150
148
|
*/
|
|
151
149
|
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
|
152
|
-
|
|
153
|
-
/* prettier-ignore-end */
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* prettier-ignore-start */
|
|
2
|
-
|
|
3
1
|
/* eslint-disable */
|
|
4
2
|
/**
|
|
5
3
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
|
@@ -90,5 +88,3 @@ export const internalAction = internalActionGeneric;
|
|
|
90
88
|
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
|
|
91
89
|
*/
|
|
92
90
|
export const httpAction = httpActionGeneric;
|
|
93
|
-
|
|
94
|
-
/* prettier-ignore-end */
|
|
@@ -22,14 +22,39 @@ describe("counter", () => {
|
|
|
22
22
|
expect(await t.query(api.public.count, { name: "beans" })).toEqual(10);
|
|
23
23
|
expect(await t.query(api.public.count, { name: "friends" })).toEqual(11);
|
|
24
24
|
});
|
|
25
|
+
test("respects shard argument", async () => {
|
|
26
|
+
const t = convexTest(schema, modules);
|
|
27
|
+
await t.mutation(api.public.add, { name: "beans", count: 10, shard: 1 });
|
|
28
|
+
await t.mutation(api.public.add, { name: "beans", count: 5, shard: 2 });
|
|
29
|
+
const values = await t.run(async (ctx) => {
|
|
30
|
+
const shard1 = await ctx.db
|
|
31
|
+
.query("counters")
|
|
32
|
+
.withIndex("name", (q) => q.eq("name", "beans").eq("shard", 1))
|
|
33
|
+
.unique();
|
|
34
|
+
const shard2 = await ctx.db
|
|
35
|
+
.query("counters")
|
|
36
|
+
.withIndex("name", (q) => q.eq("name", "beans").eq("shard", 2))
|
|
37
|
+
.unique();
|
|
38
|
+
return [shard1?.value, shard2?.value];
|
|
39
|
+
});
|
|
40
|
+
expect(values).toEqual([10, 5]);
|
|
41
|
+
});
|
|
42
|
+
test("reset", async () => {
|
|
43
|
+
const t = convexTest(schema, modules);
|
|
44
|
+
await t.mutation(api.public.add, { name: "beans", count: 10 });
|
|
45
|
+
await t.mutation(api.public.reset, { name: "beans" });
|
|
46
|
+
expect(await t.query(api.public.count, { name: "beans" })).toEqual(0);
|
|
47
|
+
});
|
|
25
48
|
});
|
|
26
49
|
|
|
27
50
|
fcTest.prop({
|
|
28
|
-
updates: fc.array(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
updates: fc.array(
|
|
52
|
+
fc.record({
|
|
53
|
+
v: fc.integer({ min: -10000, max: 10000 }).map((i) => i / 100),
|
|
54
|
+
key: fc.string(),
|
|
55
|
+
shards: fc.option(fc.integer({ min: 1, max: 100 })),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
33
58
|
})(
|
|
34
59
|
"updates to counter should match in-memory counter which ignores sharding",
|
|
35
60
|
async ({ updates }) => {
|
|
@@ -37,9 +62,26 @@ fcTest.prop({
|
|
|
37
62
|
const counter = new Map<string, number>();
|
|
38
63
|
for (const { v, key, shards } of updates) {
|
|
39
64
|
counter.set(key, (counter.get(key) ?? 0) + v);
|
|
40
|
-
await t.mutation(api.public.add, {
|
|
65
|
+
await t.mutation(api.public.add, {
|
|
66
|
+
name: key,
|
|
67
|
+
count: v,
|
|
68
|
+
shards: shards ?? undefined,
|
|
69
|
+
});
|
|
41
70
|
const count = await t.query(api.public.count, { name: key });
|
|
42
71
|
expect(count).toBeCloseTo(counter.get(key)!);
|
|
43
72
|
}
|
|
73
|
+
for (const [key, value] of counter.entries()) {
|
|
74
|
+
// Rebalancing keeps count the same and makes estimateCount accurate.
|
|
75
|
+
await t.mutation(api.public.rebalance, { name: key });
|
|
76
|
+
const count = await t.query(api.public.count, { name: key });
|
|
77
|
+
expect(count).toBeCloseTo(value);
|
|
78
|
+
for (let i = 1; i <= 16; i++) {
|
|
79
|
+
const estimate = await t.query(api.public.estimateCount, {
|
|
80
|
+
name: key,
|
|
81
|
+
readFromShards: i,
|
|
82
|
+
});
|
|
83
|
+
expect(estimate).toBeCloseTo(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
44
86
|
},
|
|
45
87
|
);
|
package/src/component/public.ts
CHANGED
|
@@ -7,11 +7,14 @@ export const add = mutation({
|
|
|
7
7
|
args: {
|
|
8
8
|
name: v.string(),
|
|
9
9
|
count: v.number(),
|
|
10
|
+
shard: v.optional(v.number()),
|
|
10
11
|
shards: v.optional(v.number()),
|
|
11
12
|
},
|
|
12
|
-
returns: v.
|
|
13
|
+
returns: v.number(),
|
|
13
14
|
handler: async (ctx, args) => {
|
|
14
|
-
const shard =
|
|
15
|
+
const shard =
|
|
16
|
+
args.shard ??
|
|
17
|
+
Math.floor(Math.random() * (args.shards ?? DEFAULT_SHARD_COUNT));
|
|
15
18
|
const counter = await ctx.db
|
|
16
19
|
.query("counters")
|
|
17
20
|
.withIndex("name", (q) => q.eq("name", args.name).eq("shard", shard))
|
|
@@ -27,6 +30,7 @@ export const add = mutation({
|
|
|
27
30
|
shard,
|
|
28
31
|
});
|
|
29
32
|
}
|
|
33
|
+
return shard;
|
|
30
34
|
},
|
|
31
35
|
});
|
|
32
36
|
|
|
@@ -41,3 +45,83 @@ export const count = query({
|
|
|
41
45
|
return counters.reduce((sum, counter) => sum + counter.value, 0);
|
|
42
46
|
},
|
|
43
47
|
});
|
|
48
|
+
|
|
49
|
+
export const rebalance = mutation({
|
|
50
|
+
args: { name: v.string(), shards: v.optional(v.number()) },
|
|
51
|
+
handler: async (ctx, args) => {
|
|
52
|
+
const counters = await ctx.db
|
|
53
|
+
.query("counters")
|
|
54
|
+
.withIndex("name", (q) => q.eq("name", args.name))
|
|
55
|
+
.collect();
|
|
56
|
+
const count = counters.reduce((sum, counter) => sum + counter.value, 0);
|
|
57
|
+
const shardCount = args.shards ?? DEFAULT_SHARD_COUNT;
|
|
58
|
+
const value = count / shardCount;
|
|
59
|
+
for (let i = 0; i < shardCount; i++) {
|
|
60
|
+
const shard = counters.find((c) => c.shard === i);
|
|
61
|
+
if (shard) {
|
|
62
|
+
await ctx.db.patch(shard._id, { value });
|
|
63
|
+
} else {
|
|
64
|
+
await ctx.db.insert("counters", {
|
|
65
|
+
name: args.name,
|
|
66
|
+
value,
|
|
67
|
+
shard: i,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const toDelete = counters.filter((c) => c.shard >= shardCount);
|
|
72
|
+
for (const counter of toDelete) {
|
|
73
|
+
await ctx.db.delete(counter._id);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const reset = mutation({
|
|
79
|
+
args: { name: v.string() },
|
|
80
|
+
handler: async (ctx, args) => {
|
|
81
|
+
await ctx.db
|
|
82
|
+
.query("counters")
|
|
83
|
+
.withIndex("name", (q) => q.eq("name", args.name))
|
|
84
|
+
.collect()
|
|
85
|
+
.then((counters) =>
|
|
86
|
+
Promise.all(counters.map((c) => ctx.db.delete(c._id))),
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const estimateCount = query({
|
|
92
|
+
args: {
|
|
93
|
+
name: v.string(),
|
|
94
|
+
readFromShards: v.optional(v.number()),
|
|
95
|
+
shards: v.optional(v.number()),
|
|
96
|
+
},
|
|
97
|
+
handler: async (ctx, args) => {
|
|
98
|
+
const shardCount = args.shards ?? DEFAULT_SHARD_COUNT;
|
|
99
|
+
const readFromShards = Math.min(
|
|
100
|
+
Math.max(1, args.readFromShards ?? 1),
|
|
101
|
+
shardCount,
|
|
102
|
+
);
|
|
103
|
+
const shards = shuffle(
|
|
104
|
+
Array.from({ length: shardCount }, (_, i) => i),
|
|
105
|
+
).slice(0, readFromShards);
|
|
106
|
+
let readCount = 0;
|
|
107
|
+
for (const shard of shards) {
|
|
108
|
+
const counter = await ctx.db
|
|
109
|
+
.query("counters")
|
|
110
|
+
.withIndex("name", (q) => q.eq("name", args.name).eq("shard", shard))
|
|
111
|
+
.unique();
|
|
112
|
+
if (counter) {
|
|
113
|
+
readCount += counter.value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return (readCount * shardCount) / readFromShards;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Fisher-Yates shuffle
|
|
121
|
+
function shuffle<T>(array: T[]): T[] {
|
|
122
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
123
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
124
|
+
[array[i], array[j]] = [array[j], array[i]];
|
|
125
|
+
}
|
|
126
|
+
return array;
|
|
127
|
+
}
|