@causa/runtime-google 1.0.0 → 1.1.0

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.
@@ -5,11 +5,10 @@ import { getConfigurationKeyForTopic } from './configuration.js';
5
5
  import { PubSubTopicNotConfiguredError } from './errors.js';
6
6
  /**
7
7
  * The default options to use when publishing messages.
8
- * Batching is disabled as the most common use case is to publish a single message at a time, for which latency is more
9
- * important than throughput.
8
+ * Batching is minimal, as latency should be prioritized over throughput.
10
9
  */
11
10
  const DEFAULT_PUBLISH_OPTIONS = {
12
- batching: { maxMessages: 1 },
11
+ batching: { maxMilliseconds: 5 },
13
12
  };
14
13
  /**
15
14
  * An implementation of the {@link EventPublisher} using Google Pub/Sub as the broker.
@@ -117,26 +116,12 @@ export class PubSubPublisher {
117
116
  if (attributes && 'eventId' in attributes) {
118
117
  messageInfo.eventId = attributes.eventId;
119
118
  }
120
- try {
121
- const messageId = await pubSubTopic.publishMessage({
122
- data,
123
- attributes,
124
- orderingKey: key,
125
- });
126
- this.logger.info({ publishedMessage: { ...messageInfo, messageId } }, 'Published message to Pub/Sub.');
127
- }
128
- catch (error) {
129
- this.logger.error({
130
- failedMessage: {
131
- ...messageInfo,
132
- data: data.toString('base64'),
133
- attributes,
134
- key,
135
- },
136
- error: error.stack,
137
- }, 'Failed to publish message to Pub/Sub.');
138
- throw error;
139
- }
119
+ const messageId = await pubSubTopic.publishMessage({
120
+ data,
121
+ attributes,
122
+ orderingKey: key,
123
+ });
124
+ this.logger.info({ publishedMessage: { ...messageInfo, messageId } }, 'Published message to Pub/Sub.');
140
125
  }
141
126
  async flush() {
142
127
  await Promise.all(Object.values(this.topicCache).map((topic) => topic.flush()));
@@ -1,6 +1,6 @@
1
1
  import { Database, Snapshot, Transaction } from '@google-cloud/spanner';
2
2
  import { type Type as ParamType } from '@google-cloud/spanner/build/src/codec.js';
3
- import type { TimestampBounds } from '@google-cloud/spanner/build/src/transaction.js';
3
+ import type { ExecuteSqlRequest, TimestampBounds } from '@google-cloud/spanner/build/src/transaction.js';
4
4
  import { type Type } from '@nestjs/common';
5
5
  import { SpannerTableCache } from './table-cache.js';
6
6
  import type { SpannerReadOnlyTransactionOption, SpannerReadWriteTransactionOption } from './types.js';
@@ -53,7 +53,7 @@ export type SqlStatement = {
53
53
  /**
54
54
  * Options for {@link SpannerEntityManager.query}.
55
55
  */
56
- export type QueryOptions<T> = SpannerReadOnlyTransactionOption & {
56
+ export type QueryOptions<T> = SpannerReadOnlyTransactionOption & Pick<ExecuteSqlRequest, 'requestOptions'> & {
57
57
  /**
58
58
  * The type of entity to return in the list of results.
59
59
  */
@@ -62,7 +62,7 @@ export type QueryOptions<T> = SpannerReadOnlyTransactionOption & {
62
62
  /**
63
63
  * Options when reading entities.
64
64
  */
65
- type FindOptions = SpannerReadOnlyTransactionOption & {
65
+ type FindOptions = SpannerReadOnlyTransactionOption & Pick<ExecuteSqlRequest, 'requestOptions'> & {
66
66
  /**
67
67
  * The index to use to look up the entity.
68
68
  */
@@ -141,6 +141,7 @@ let SpannerEntityManager = class SpannerEntityManager {
141
141
  json: true,
142
142
  jsonOptions: { wrapNumbers: true },
143
143
  index: options.index,
144
+ requestOptions: options.requestOptions,
144
145
  });
145
146
  const row = rows[0];
146
147
  if (row && options.index && !options.columns) {
@@ -210,7 +211,7 @@ let SpannerEntityManager = class SpannerEntityManager {
210
211
  if (options.transaction) {
211
212
  return await runFn(options.transaction);
212
213
  }
213
- return await this.database.runTransactionAsync(async (transaction) => {
214
+ return await this.database.runTransactionAsync({ requestOptions: { transactionTag: options.tag } }, async (transaction) => {
214
215
  try {
215
216
  const result = await runFn(transaction);
216
217
  if (transaction.ended) {
@@ -275,10 +276,11 @@ let SpannerEntityManager = class SpannerEntityManager {
275
276
  ? optionsOrStatement
276
277
  : {};
277
278
  const sqlStatement = statement ?? optionsOrStatement;
278
- const { entityType } = options;
279
+ const { entityType, requestOptions } = options;
279
280
  return await this.snapshot({ transaction: options.transaction }, async (transaction) => {
280
281
  const [rows] = await transaction.run({
281
282
  ...sqlStatement,
283
+ requestOptions,
282
284
  json: true,
283
285
  jsonOptions: { wrapNumbers: entityType != null },
284
286
  });
@@ -1,4 +1,6 @@
1
+ import { protos } from '@google-cloud/spanner';
1
2
  import type { SpannerReadOnlyTransaction, SpannerReadWriteTransaction } from './entity-manager.js';
3
+ export declare const SpannerRequestPriority: typeof protos.google.spanner.v1.RequestOptions.Priority;
2
4
  /**
3
5
  * Option for a function that accepts a Spanner read-only transaction.
4
6
  */
@@ -15,5 +17,15 @@ export type SpannerReadWriteTransactionOption = {
15
17
  /**
16
18
  * The transaction to use.
17
19
  */
18
- readonly transaction?: SpannerReadWriteTransaction;
20
+ readonly transaction: SpannerReadWriteTransaction;
21
+ } | {
22
+ /**
23
+ * The transaction to use.
24
+ */
25
+ readonly transaction?: undefined;
26
+ /**
27
+ * A tag to assign to the transaction.
28
+ * This can only be provided when creating a new transaction.
29
+ */
30
+ readonly tag?: string;
19
31
  };
@@ -1 +1,2 @@
1
- export {};
1
+ import { protos } from '@google-cloud/spanner';
2
+ export const SpannerRequestPriority = protos.google.spanner.v1.RequestOptions.Priority;
@@ -1,6 +1,6 @@
1
1
  import { TransactionRunner, type ReadWriteTransactionOptions, type TransactionFn } from '@causa/runtime';
2
2
  import { Logger } from '@causa/runtime/nestjs';
3
- import { Firestore } from '@google-cloud/firestore';
3
+ import { Firestore } from 'firebase-admin/firestore';
4
4
  import { PubSubPublisher } from '../../pubsub/index.js';
5
5
  import { FirestoreReadOnlyStateTransaction } from './readonly-state-transaction.js';
6
6
  import { FirestorePubSubTransaction } from './transaction.js';
@@ -10,8 +10,8 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  var FirestorePubSubTransactionRunner_1;
11
11
  import { OutboxEventTransaction, TransactionRunner, } from '@causa/runtime';
12
12
  import { Logger } from '@causa/runtime/nestjs';
13
- import { Firestore } from '@google-cloud/firestore';
14
13
  import { Injectable } from '@nestjs/common';
14
+ import { Firestore } from 'firebase-admin/firestore';
15
15
  import { wrapFirestoreOperation } from '../../firestore/index.js';
16
16
  import { PubSubPublisher } from '../../pubsub/index.js';
17
17
  import { FirestoreReadOnlyStateTransaction } from './readonly-state-transaction.js';
@@ -32,9 +32,14 @@ function parseSenderOptions(defaultOptions, customOptions, config) {
32
32
  const index = config.get('SPANNER_OUTBOX_INDEX');
33
33
  const shardingColumn = config.get('SPANNER_OUTBOX_SHARDING_COLUMN');
34
34
  const shardingCount = validateIntOrUndefined('SPANNER_OUTBOX_SHARDING_COUNT');
35
+ const shardingDisableRoundRobin = !!config.get('SPANNER_OUTBOX_SHARDING_DISABLE_ROUND_ROBIN');
35
36
  const leaseDuration = validateIntOrUndefined('SPANNER_OUTBOX_LEASE_DURATION');
36
37
  const sharding = shardingColumn && shardingCount
37
- ? { column: shardingColumn, count: shardingCount }
38
+ ? {
39
+ column: shardingColumn,
40
+ count: shardingCount,
41
+ roundRobin: !shardingDisableRoundRobin,
42
+ }
38
43
  : undefined;
39
44
  const envOptionsWithUndefined = {
40
45
  batchSize,
@@ -1,10 +1,19 @@
1
- import { OutboxTransactionRunner, type OutboxEvent, type OutboxEventTransaction, type ReadWriteTransactionOptions, type TransactionFn } from '@causa/runtime';
1
+ import { OutboxTransactionRunner, type OutboxEvent, type OutboxEventTransaction, type ReadOnlyTransactionOption, type ReadWriteTransactionOptions, type TransactionFn, type TransactionOption } from '@causa/runtime';
2
2
  import { Logger } from '@causa/runtime/nestjs';
3
3
  import type { Type } from '@nestjs/common';
4
4
  import { SpannerEntityManager } from '../../spanner/index.js';
5
5
  import { SpannerReadOnlyStateTransaction } from './readonly-transaction.js';
6
6
  import { SpannerOutboxSender } from './sender.js';
7
7
  import { SpannerOutboxTransaction } from './transaction.js';
8
+ /**
9
+ * Options for a Spanner outbox transaction.
10
+ */
11
+ export type SpannerOutboxReadWriteTransactionOptions = ReadWriteTransactionOptions & {
12
+ /**
13
+ * The Spanner tag to assign to the transaction.
14
+ */
15
+ tag?: string;
16
+ };
8
17
  /**
9
18
  * An {@link OutboxTransactionRunner} that uses a {@link SpannerOutboxTransaction} to run transactions.
10
19
  * Events are stored in a Spanner table before being published.
@@ -13,5 +22,10 @@ export declare class SpannerOutboxTransactionRunner extends OutboxTransactionRun
13
22
  readonly entityManager: SpannerEntityManager;
14
23
  constructor(entityManager: SpannerEntityManager, outboxEventType: Type<OutboxEvent>, sender: SpannerOutboxSender, logger: Logger);
15
24
  protected runReadOnly<RT>(runFn: TransactionFn<SpannerReadOnlyStateTransaction, RT>): Promise<RT>;
16
- protected runStateTransaction<RT>(eventTransactionFactory: () => OutboxEventTransaction, options: ReadWriteTransactionOptions, runFn: TransactionFn<SpannerOutboxTransaction, RT>): Promise<RT>;
25
+ protected runStateTransaction<RT>(eventTransactionFactory: () => OutboxEventTransaction, options: SpannerOutboxReadWriteTransactionOptions, runFn: TransactionFn<SpannerOutboxTransaction, RT>): Promise<RT>;
26
+ run<RT>(runFn: TransactionFn<SpannerOutboxTransaction, RT>): Promise<RT>;
27
+ run<RT>(options: TransactionOption<SpannerOutboxTransaction> | SpannerOutboxReadWriteTransactionOptions, runFn: TransactionFn<SpannerOutboxTransaction, RT>): Promise<RT>;
28
+ run<RT>(options: ReadOnlyTransactionOption<SpannerReadOnlyStateTransaction> & {
29
+ readOnly: true;
30
+ }, runFn: TransactionFn<SpannerReadOnlyStateTransaction, RT>): Promise<RT>;
17
31
  }
@@ -23,7 +23,7 @@ export class SpannerOutboxTransactionRunner extends OutboxTransactionRunner {
23
23
  });
24
24
  }
25
25
  async runStateTransaction(eventTransactionFactory, options, runFn) {
26
- return await this.entityManager.transaction(async (dbTransaction) => {
26
+ return await this.entityManager.transaction({ tag: options.tag }, async (dbTransaction) => {
27
27
  const stateTransaction = new SpannerStateTransaction(this.entityManager, dbTransaction);
28
28
  const eventTransaction = eventTransactionFactory();
29
29
  const transaction = new SpannerOutboxTransaction(stateTransaction, eventTransaction, options.publishOptions);
@@ -36,4 +36,7 @@ export class SpannerOutboxTransactionRunner extends OutboxTransactionRunner {
36
36
  }
37
37
  });
38
38
  }
39
+ async run(optionsOrRunFn, runFn) {
40
+ return super.run(optionsOrRunFn, runFn);
41
+ }
39
42
  }
@@ -3,9 +3,9 @@ import { Logger } from '@causa/runtime/nestjs';
3
3
  import type { Type } from '@nestjs/common';
4
4
  import { SpannerEntityManager } from '../../spanner/index.js';
5
5
  /**
6
- * Sharding options for the {@link SpannerOutboxSender}.
6
+ * The sharding configuration for the {@link SpannerOutboxSender}.
7
7
  */
8
- export type SpannerOutboxSenderShardingOptions = {
8
+ type SpannerOutboxSenderSharding = {
9
9
  /**
10
10
  * The name of the column used for sharding.
11
11
  */
@@ -14,7 +14,16 @@ export type SpannerOutboxSenderShardingOptions = {
14
14
  * The number of shards.
15
15
  */
16
16
  readonly count: number;
17
+ /**
18
+ * Whether events are fetched one shard at a time in a round-robin fashion, or all shards at once.
19
+ * Defaults to `true`.
20
+ */
21
+ readonly roundRobin: boolean;
17
22
  };
23
+ /**
24
+ * Sharding options for the {@link SpannerOutboxSender}.
25
+ */
26
+ export type SpannerOutboxSenderShardingOptions = Pick<SpannerOutboxSenderSharding, 'column' | 'count'> & Partial<SpannerOutboxSenderSharding>;
18
27
  /**
19
28
  * Options for the {@link SpannerOutboxSender}.
20
29
  */
@@ -49,7 +58,17 @@ export declare class SpannerOutboxSender extends OutboxEventSender {
49
58
  * Sharding options.
50
59
  * If `null`, queries to fetch events will not use sharding.
51
60
  */
52
- readonly sharding: SpannerOutboxSenderShardingOptions | undefined;
61
+ readonly sharding: SpannerOutboxSenderSharding | undefined;
62
+ /**
63
+ * If sharding round-robin is enabled, the permutation of shard indices determining the order in which shards are
64
+ * queried by {@link SpannerOutboxSender.fetchEvents}.
65
+ */
66
+ private readonly shardsPermutation;
67
+ /**
68
+ * If sharding round-robin is enabled, the index of the next shard to query in
69
+ * {@link SpannerOutboxSender.fetchEvents}.
70
+ */
71
+ private shardsPermutationIndex;
53
72
  /**
54
73
  * The name of the column used for the {@link OutboxEvent.id} property.
55
74
  */
@@ -99,3 +118,4 @@ export declare class SpannerOutboxSender extends OutboxEventSender {
99
118
  protected fetchEvents(): Promise<OutboxEvent[]>;
100
119
  protected updateOutbox(result: OutboxEventPublishResult): Promise<void>;
101
120
  }
121
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { OutboxEventSender, } from '@causa/runtime';
2
2
  import { Logger } from '@causa/runtime/nestjs';
3
- import { SpannerEntityManager } from '../../spanner/index.js';
3
+ import { SpannerEntityManager, SpannerRequestPriority, } from '../../spanner/index.js';
4
4
  /**
5
5
  * The default name for the {@link OutboxEvent.id} column.
6
6
  */
@@ -20,6 +20,16 @@ export class SpannerOutboxSender extends OutboxEventSender {
20
20
  * If `null`, queries to fetch events will not use sharding.
21
21
  */
22
22
  sharding;
23
+ /**
24
+ * If sharding round-robin is enabled, the permutation of shard indices determining the order in which shards are
25
+ * queried by {@link SpannerOutboxSender.fetchEvents}.
26
+ */
27
+ shardsPermutation;
28
+ /**
29
+ * If sharding round-robin is enabled, the index of the next shard to query in
30
+ * {@link SpannerOutboxSender.fetchEvents}.
31
+ */
32
+ shardsPermutationIndex;
23
33
  /**
24
34
  * The name of the column used for the {@link OutboxEvent.id} property.
25
35
  */
@@ -63,11 +73,19 @@ export class SpannerOutboxSender extends OutboxEventSender {
63
73
  super(publisher, logger, options);
64
74
  this.entityManager = entityManager;
65
75
  this.outboxEventType = outboxEventType;
66
- this.sharding = options.sharding;
76
+ this.sharding = options.sharding
77
+ ? { roundRobin: true, ...options.sharding }
78
+ : undefined;
67
79
  this.idColumn = options.idColumn ?? DEFAULT_ID_COLUMN;
68
80
  this.leaseExpirationColumn =
69
81
  options.leaseExpirationColumn ?? DEFAULT_LEASE_EXPIRATION_COLUMN;
70
82
  this.index = options.index;
83
+ if (this.sharding?.roundRobin) {
84
+ this.shardsPermutation = Array.from({ length: this.sharding.count }, (_, i) => [i, Math.random()])
85
+ .sort(([, a], [, b]) => a - b)
86
+ .map(([i]) => i);
87
+ this.shardsPermutationIndex = 0;
88
+ }
71
89
  ({
72
90
  fetchEventsSql: this.fetchEventsSql,
73
91
  acquireLeaseSql: this.acquireLeaseSql,
@@ -86,8 +104,11 @@ export class SpannerOutboxSender extends OutboxEventSender {
86
104
  const noLeaseFilter = `\`${this.leaseExpirationColumn}\` IS NULL OR \`${this.leaseExpirationColumn}\` < @currentTime`;
87
105
  let fetchFilter = noLeaseFilter;
88
106
  if (this.sharding) {
89
- const { column, count } = this.sharding;
90
- fetchFilter = `\`${column}\` BETWEEN 0 AND ${count - 1} AND (${fetchFilter})`;
107
+ const { column, count, roundRobin } = this.sharding;
108
+ const shardFilter = (roundRobin ?? true)
109
+ ? `\`${column}\` = @shard`
110
+ : `\`${column}\` BETWEEN 0 AND ${count - 1}`;
111
+ fetchFilter = `(${shardFilter}) AND (${fetchFilter})`;
91
112
  }
92
113
  const fetchEventsSql = `
93
114
  SELECT
@@ -131,12 +152,17 @@ export class SpannerOutboxSender extends OutboxEventSender {
131
152
  };
132
153
  }
133
154
  async fetchEvents() {
155
+ const params = {
156
+ currentTime: new Date(),
157
+ batchSize: this.batchSize,
158
+ };
159
+ if (this.shardsPermutationIndex !== undefined && this.shardsPermutation) {
160
+ params.shard = this.shardsPermutation[this.shardsPermutationIndex];
161
+ this.shardsPermutationIndex =
162
+ (this.shardsPermutationIndex + 1) % this.shardsPermutation.length;
163
+ }
134
164
  // Event IDs are first acquired in an (implicit) read-only transaction, to avoid a lock on the entire table.
135
- const currentTime = new Date();
136
- const eventIds = await this.entityManager.query({
137
- sql: this.fetchEventsSql,
138
- params: { currentTime, batchSize: this.batchSize },
139
- });
165
+ const eventIds = await this.entityManager.query({ requestOptions: { priority: SpannerRequestPriority.PRIORITY_MEDIUM } }, { sql: this.fetchEventsSql, params });
140
166
  if (eventIds.length === 0) {
141
167
  return [];
142
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@causa/runtime-google",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "An extension to the Causa runtime SDK (`@causa/runtime`), providing Google-specific features.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,7 +35,7 @@
35
35
  "@causa/runtime": "^1.0.0",
36
36
  "@google-cloud/precise-date": "^5.0.0",
37
37
  "@google-cloud/pubsub": "^5.1.0",
38
- "@google-cloud/spanner": "^8.0.0",
38
+ "@google-cloud/spanner": "^8.1.0",
39
39
  "@google-cloud/tasks": "^6.2.0",
40
40
  "@grpc/grpc-js": "^1.13.4",
41
41
  "@nestjs/common": "^11.1.5",
@@ -54,28 +54,26 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@nestjs/testing": "^11.1.5",
57
- "@swc/core": "^1.13.0",
57
+ "@swc/core": "^1.13.3",
58
58
  "@swc/jest": "^0.2.39",
59
59
  "@tsconfig/node22": "^22.0.2",
60
60
  "@types/jest": "^30.0.0",
61
61
  "@types/jsonwebtoken": "^9.0.10",
62
- "@types/node": "^22.16.4",
62
+ "@types/node": "^22.17.0",
63
63
  "@types/passport-http-bearer": "^1.0.41",
64
64
  "@types/supertest": "^6.0.3",
65
65
  "@types/uuid": "^10.0.0",
66
- "dotenv": "^17.2.0",
67
- "eslint": "^9.31.0",
68
- "eslint-config-prettier": "^10.1.5",
69
- "eslint-plugin-prettier": "^5.5.1",
70
- "jest": "^30.0.4",
66
+ "dotenv": "^17.2.1",
67
+ "eslint": "^9.32.0",
68
+ "eslint-config-prettier": "^10.1.8",
69
+ "eslint-plugin-prettier": "^5.5.3",
70
+ "jest": "^30.0.5",
71
71
  "jest-extended": "^6.0.0",
72
- "pino-pretty": "^13.0.0",
72
+ "pino-pretty": "^13.1.1",
73
73
  "rimraf": "^6.0.1",
74
- "supertest": "^7.1.3",
75
- "ts-jest": "^29.4.0",
76
- "ts-node": "^10.9.2",
77
- "typescript": "^5.8.3",
78
- "typescript-eslint": "^8.37.0",
74
+ "supertest": "^7.1.4",
75
+ "typescript": "^5.9.2",
76
+ "typescript-eslint": "^8.39.0",
79
77
  "uuid": "^11.1.0"
80
78
  }
81
79
  }