@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.
Files changed (38) hide show
  1. package/README.md +136 -7
  2. package/dist/commonjs/client/index.d.ts +96 -13
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +108 -5
  5. package/dist/commonjs/client/index.js.map +1 -1
  6. package/dist/commonjs/component/_generated/api.d.ts.map +1 -1
  7. package/dist/commonjs/component/_generated/api.js +0 -2
  8. package/dist/commonjs/component/_generated/api.js.map +1 -1
  9. package/dist/commonjs/component/_generated/server.d.ts.map +1 -1
  10. package/dist/commonjs/component/_generated/server.js +0 -2
  11. package/dist/commonjs/component/_generated/server.js.map +1 -1
  12. package/dist/commonjs/component/public.d.ts +14 -1
  13. package/dist/commonjs/component/public.d.ts.map +1 -1
  14. package/dist/commonjs/component/public.js +75 -2
  15. package/dist/commonjs/component/public.js.map +1 -1
  16. package/dist/esm/client/index.d.ts +96 -13
  17. package/dist/esm/client/index.d.ts.map +1 -1
  18. package/dist/esm/client/index.js +108 -5
  19. package/dist/esm/client/index.js.map +1 -1
  20. package/dist/esm/component/_generated/api.d.ts.map +1 -1
  21. package/dist/esm/component/_generated/api.js +0 -2
  22. package/dist/esm/component/_generated/api.js.map +1 -1
  23. package/dist/esm/component/_generated/server.d.ts.map +1 -1
  24. package/dist/esm/component/_generated/server.js +0 -2
  25. package/dist/esm/component/_generated/server.js.map +1 -1
  26. package/dist/esm/component/public.d.ts +14 -1
  27. package/dist/esm/component/public.d.ts.map +1 -1
  28. package/dist/esm/component/public.js +75 -2
  29. package/dist/esm/component/public.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/client/index.ts +174 -17
  32. package/src/component/_generated/api.d.ts +15 -6
  33. package/src/component/_generated/api.js +0 -4
  34. package/src/component/_generated/dataModel.d.ts +0 -4
  35. package/src/component/_generated/server.d.ts +0 -4
  36. package/src/component/_generated/server.js +0 -4
  37. package/src/component/counter.test.ts +48 -6
  38. 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.3",
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 run",
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
  },
@@ -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
- export class ShardedCounter<Shards extends Record<string, number>> {
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
- private options?: { shards?: Shards; defaultShards?: number }
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 string = keyof Shards & string>(
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 shards = this.options?.shards?.[name] ?? this.options?.defaultShards;
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
- shards,
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 string = keyof Shards & string>(
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.count, { name });
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 string = keyof Shards & string>(name: Name) {
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
- null
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(fc.record({
29
- v: fc.integer({ min: 0, max: 10000 }).map((i) => i / 100),
30
- key: fc.string(),
31
- shards: fc.option(fc.integer({ min: 1, max: 100 })),
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, { name: key, count: v, shards: shards ?? undefined });
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
  );
@@ -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.null(),
13
+ returns: v.number(),
13
14
  handler: async (ctx, args) => {
14
- const shard = Math.floor(Math.random() * (args.shards ?? DEFAULT_SHARD_COUNT));
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
+ }