@vercube/queue 1.3.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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/QueueStrategy-DwLPpfFM.mjs +237 -0
- package/dist/QueueStrategy-saWSBeU6.d.mts +715 -0
- package/dist/Strategies/BullMQStrategy.d.mts +161 -0
- package/dist/Strategies/BullMQStrategy.mjs +328 -0
- package/dist/Strategies/KafkaStrategy.d.mts +152 -0
- package/dist/Strategies/KafkaStrategy.mjs +280 -0
- package/dist/Strategies/MemoryStrategy.d.mts +139 -0
- package/dist/Strategies/MemoryStrategy.mjs +305 -0
- package/dist/Strategies/RabbitMQStrategy.d.mts +205 -0
- package/dist/Strategies/RabbitMQStrategy.mjs +388 -0
- package/dist/decorate-C0p0FnUM.mjs +25 -0
- package/dist/index.d.mts +953 -0
- package/dist/index.mjs +1719 -0
- package/package.json +54 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { f as encodePayload, g as readNumericHeader, m as normalizeHeaders, r as ATTEMPT_HEADER, t as QueueStrategy, u as decodePayload, v as QueueError } from "../QueueStrategy-DwLPpfFM.mjs";
|
|
2
|
+
import { n as toQueueError, t as __decorate } from "../decorate-C0p0FnUM.mjs";
|
|
3
|
+
import { InjectOptional } from "@vercube/di";
|
|
4
|
+
import { Logger } from "@vercube/logger";
|
|
5
|
+
import { Kafka } from "kafkajs";
|
|
6
|
+
//#region src/Strategies/KafkaStrategy.ts
|
|
7
|
+
/**
|
|
8
|
+
* Kafka backed queue implementation.
|
|
9
|
+
*
|
|
10
|
+
* A queue is a topic, the job name travels in the `x-job` header and the payload
|
|
11
|
+
* is JSON. Kafka is a log rather than a job broker: it has no attempts, delays,
|
|
12
|
+
* priorities or per-message acknowledgements, so those are handled by the manager
|
|
13
|
+
* and a job it gives up on only moves the offset forward.
|
|
14
|
+
*
|
|
15
|
+
* Ordering is per partition, so use the `key` job option to keep related jobs on
|
|
16
|
+
* the same partition.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* await queueManager.mount({
|
|
21
|
+
* strategy: KafkaStrategy,
|
|
22
|
+
* initOptions: {
|
|
23
|
+
* client: { clientId: 'orders', brokers: ['localhost:9092'] },
|
|
24
|
+
* groupId: 'orders-workers',
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
var KafkaStrategy = class extends QueueStrategy {
|
|
30
|
+
/** Transport this strategy talks to. */
|
|
31
|
+
transport = "kafka";
|
|
32
|
+
/** Logger instance */
|
|
33
|
+
gLogger;
|
|
34
|
+
/** Options the strategy was initialized with */
|
|
35
|
+
fOptions = null;
|
|
36
|
+
/** The Kafka client */
|
|
37
|
+
fKafka = null;
|
|
38
|
+
/** The connected producer */
|
|
39
|
+
fProducer = null;
|
|
40
|
+
/** Admin client, opened only when counters are read */
|
|
41
|
+
fAdmin = null;
|
|
42
|
+
/** Running consumers, indexed by topic */
|
|
43
|
+
fConsumers = /* @__PURE__ */ new Map();
|
|
44
|
+
/**
|
|
45
|
+
* Kafka is a log: only the offsets it keeps can be reported, everything else
|
|
46
|
+
* about the job model is left to the manager.
|
|
47
|
+
*
|
|
48
|
+
* @returns {QueueTypes.Capabilities} What this strategy supports
|
|
49
|
+
*/
|
|
50
|
+
get capabilities() {
|
|
51
|
+
return {
|
|
52
|
+
retries: false,
|
|
53
|
+
delay: false,
|
|
54
|
+
priority: false,
|
|
55
|
+
progress: false,
|
|
56
|
+
stats: true,
|
|
57
|
+
peek: false
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Creates the client and connects the producer, so a broken configuration is
|
|
62
|
+
* reported at boot instead of on the first job.
|
|
63
|
+
*
|
|
64
|
+
* @param {KafkaStrategyOptions} options - Client configuration and consumer group
|
|
65
|
+
* @returns {Promise<void>} Resolves once the producer is connected
|
|
66
|
+
* @throws {QueueError} When no brokers are given, or the producer cannot connect
|
|
67
|
+
*/
|
|
68
|
+
async initialize(options) {
|
|
69
|
+
if (!options?.client?.brokers) throw new QueueError("Kafka needs at least one broker", "initialize", void 0, void 0, false);
|
|
70
|
+
this.fOptions = options;
|
|
71
|
+
this.fKafka = new Kafka(options.client);
|
|
72
|
+
try {
|
|
73
|
+
this.fProducer = this.fKafka.producer(options.producer);
|
|
74
|
+
await this.fProducer.connect();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.fProducer = null;
|
|
77
|
+
throw toQueueError(error, "Failed to connect the Kafka producer", "initialize");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Produces a single record.
|
|
82
|
+
*
|
|
83
|
+
* @param {QueueTypes.PublishRequest} request - Job to publish
|
|
84
|
+
* @returns {Promise<QueueTypes.JobRef>} Reference to the produced record
|
|
85
|
+
* @throws {QueueError} When the record cannot be produced
|
|
86
|
+
*/
|
|
87
|
+
async publish(request) {
|
|
88
|
+
const [ref] = await this.publishMany([request]);
|
|
89
|
+
return ref;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Produces many records of the same topic in a single request.
|
|
93
|
+
*
|
|
94
|
+
* @param {QueueTypes.PublishRequest[]} requests - Jobs to publish, all on the same topic
|
|
95
|
+
* @returns {Promise<QueueTypes.JobRef[]>} References to the produced records
|
|
96
|
+
* @throws {QueueError} When the records cannot be produced
|
|
97
|
+
*/
|
|
98
|
+
async publishMany(requests) {
|
|
99
|
+
if (requests.length === 0) return [];
|
|
100
|
+
const producer = this.requireProducer("publish");
|
|
101
|
+
const topic = requests[0].queue;
|
|
102
|
+
try {
|
|
103
|
+
const metadata = await producer.send({
|
|
104
|
+
...this.fOptions?.send,
|
|
105
|
+
topic,
|
|
106
|
+
messages: requests.map((request) => ({
|
|
107
|
+
key: request.options.key ?? request.options.jobId ?? null,
|
|
108
|
+
value: encodePayload(request.payload),
|
|
109
|
+
headers: request.headers
|
|
110
|
+
}))
|
|
111
|
+
});
|
|
112
|
+
const partition = metadata[0]?.partition ?? 0;
|
|
113
|
+
const baseOffset = Number(metadata[0]?.baseOffset ?? 0);
|
|
114
|
+
return requests.map((request, index) => ({
|
|
115
|
+
id: `${topic}-${partition}-${baseOffset + index}`,
|
|
116
|
+
queue: topic,
|
|
117
|
+
job: request.job,
|
|
118
|
+
strategy: this.transport
|
|
119
|
+
}));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw toQueueError(error, "Failed to produce Kafka records", "publish", { queue: topic });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Subscribes a consumer of the configured group to a topic.
|
|
126
|
+
*
|
|
127
|
+
* @param {QueueTypes.ConsumeRequest} request - Topic to consume, its concurrency and the dispatch callback
|
|
128
|
+
* @returns {Promise<QueueTypes.ConsumerHandle>} Handle used to stop the consumer again
|
|
129
|
+
* @throws {QueueError} When no consumer group is configured, or the consumer cannot start
|
|
130
|
+
*/
|
|
131
|
+
async consume(request) {
|
|
132
|
+
const options = this.requireOptions("consume");
|
|
133
|
+
const kafka = this.requireClient("consume");
|
|
134
|
+
if (!options.groupId) throw new QueueError("Kafka needs a groupId to consume a topic", "consume", void 0, { queue: request.queue }, false);
|
|
135
|
+
const previous = this.fConsumers.get(request.queue);
|
|
136
|
+
if (previous) {
|
|
137
|
+
this.fConsumers.delete(request.queue);
|
|
138
|
+
await previous.disconnect().catch(() => void 0);
|
|
139
|
+
}
|
|
140
|
+
const consumer = kafka.consumer({
|
|
141
|
+
...options.consumer,
|
|
142
|
+
groupId: options.groupId
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
await consumer.connect();
|
|
146
|
+
await consumer.subscribe({
|
|
147
|
+
topic: request.queue,
|
|
148
|
+
fromBeginning: options.fromBeginning ?? false
|
|
149
|
+
});
|
|
150
|
+
await consumer.run({
|
|
151
|
+
partitionsConsumedConcurrently: Math.max(1, request.concurrency),
|
|
152
|
+
eachMessage: async ({ partition, message }) => {
|
|
153
|
+
const headers = normalizeHeaders(message.headers);
|
|
154
|
+
try {
|
|
155
|
+
await request.dispatch({
|
|
156
|
+
id: `${request.queue}-${partition}-${message.offset}`,
|
|
157
|
+
job: headers["x-job"] ?? "unknown",
|
|
158
|
+
payload: decodePayload(message.value),
|
|
159
|
+
headers,
|
|
160
|
+
attempt: readNumericHeader(headers[ATTEMPT_HEADER], 1),
|
|
161
|
+
raw: message
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if ((options.onFailure ?? "skip") === "crash") throw error;
|
|
165
|
+
this.gLogger?.error(`Vercube/KafkaStrategy::Skipping a failed job of "${request.queue}"`, error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
await consumer.disconnect().catch(() => void 0);
|
|
171
|
+
throw toQueueError(error, "Failed to consume the Kafka topic", "consume", { queue: request.queue });
|
|
172
|
+
}
|
|
173
|
+
this.fConsumers.set(request.queue, consumer);
|
|
174
|
+
return {
|
|
175
|
+
queue: request.queue,
|
|
176
|
+
stop: async () => {
|
|
177
|
+
if (this.fConsumers.get(request.queue) === consumer) this.fConsumers.delete(request.queue);
|
|
178
|
+
await consumer.disconnect();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Reports how far the consumer group is behind the end of the topic.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} queue - Topic to read
|
|
186
|
+
* @returns {Promise<QueueTypes.QueueStats>} How many records are still to be read
|
|
187
|
+
* @throws {QueueError} When the offsets cannot be read
|
|
188
|
+
*/
|
|
189
|
+
async stats(queue) {
|
|
190
|
+
const options = this.requireOptions("stats");
|
|
191
|
+
if (!options.groupId) return {};
|
|
192
|
+
try {
|
|
193
|
+
const admin = await this.adminClient();
|
|
194
|
+
const [ends, committed] = await Promise.all([admin.fetchTopicOffsets(queue), admin.fetchOffsets({
|
|
195
|
+
groupId: options.groupId,
|
|
196
|
+
topics: [queue]
|
|
197
|
+
})]);
|
|
198
|
+
const offsets = new Map(committed[0]?.partitions.map((entry) => [entry.partition, Number(entry.offset)]) ?? []);
|
|
199
|
+
let waiting = 0;
|
|
200
|
+
for (const end of ends) {
|
|
201
|
+
const at = Math.max(0, offsets.get(end.partition) ?? 0);
|
|
202
|
+
waiting += Math.max(0, Number(end.offset) - at);
|
|
203
|
+
}
|
|
204
|
+
return { waiting };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw toQueueError(error, "Failed to read Kafka offsets", "stats", { queue });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Disconnects every consumer, the producer and the admin client.
|
|
211
|
+
*
|
|
212
|
+
* @returns {Promise<void>} Resolves once everything is disconnected
|
|
213
|
+
*/
|
|
214
|
+
async close() {
|
|
215
|
+
const consumers = [...this.fConsumers.values()];
|
|
216
|
+
const producer = this.fProducer;
|
|
217
|
+
const admin = this.fAdmin;
|
|
218
|
+
this.fConsumers.clear();
|
|
219
|
+
this.fProducer = null;
|
|
220
|
+
this.fAdmin = null;
|
|
221
|
+
try {
|
|
222
|
+
await Promise.all([
|
|
223
|
+
...consumers.map((consumer) => consumer.disconnect()),
|
|
224
|
+
producer?.disconnect(),
|
|
225
|
+
admin?.disconnect()
|
|
226
|
+
]);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.gLogger?.warn("Vercube/KafkaStrategy::Failed to disconnect", error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Returns the admin client, connecting it on first use.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<Admin>} The connected admin client
|
|
235
|
+
* @throws {QueueError} When the strategy has not been initialized
|
|
236
|
+
*/
|
|
237
|
+
async adminClient() {
|
|
238
|
+
if (!this.fAdmin) {
|
|
239
|
+
this.fAdmin = this.requireClient("stats").admin();
|
|
240
|
+
await this.fAdmin.connect();
|
|
241
|
+
}
|
|
242
|
+
return this.fAdmin;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Returns the options the strategy was initialized with.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} operation - Operation asking for them, reported in the error
|
|
248
|
+
* @returns {KafkaStrategyOptions} The options
|
|
249
|
+
* @throws {QueueError} When the strategy has not been initialized
|
|
250
|
+
*/
|
|
251
|
+
requireOptions(operation) {
|
|
252
|
+
if (!this.fOptions) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
|
|
253
|
+
return this.fOptions;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Returns the Kafka client.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} operation - Operation asking for it, reported in the error
|
|
259
|
+
* @returns {Kafka} The client
|
|
260
|
+
* @throws {QueueError} When the strategy has not been initialized
|
|
261
|
+
*/
|
|
262
|
+
requireClient(operation) {
|
|
263
|
+
if (!this.fKafka) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
|
|
264
|
+
return this.fKafka;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Returns the connected producer.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} operation - Operation asking for it, reported in the error
|
|
270
|
+
* @returns {Producer} The producer
|
|
271
|
+
* @throws {QueueError} When the strategy has not been initialized
|
|
272
|
+
*/
|
|
273
|
+
requireProducer(operation) {
|
|
274
|
+
if (!this.fProducer) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
|
|
275
|
+
return this.fProducer;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
__decorate([InjectOptional(Logger)], KafkaStrategy.prototype, "gLogger", void 0);
|
|
279
|
+
//#endregion
|
|
280
|
+
export { KafkaStrategy };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { n as QueueTypes, t as QueueStrategy } from "../QueueStrategy-saWSBeU6.mjs";
|
|
2
|
+
//#region src/Strategies/MemoryStrategy.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* In-process queue implementation.
|
|
5
|
+
*
|
|
6
|
+
* Jobs never leave the running process, which makes this the strategy to use in
|
|
7
|
+
* tests, in examples and for background work that may be lost on restart. It is
|
|
8
|
+
* also the reference implementation: delays, priorities and progress all behave
|
|
9
|
+
* exactly as the module documents them.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* await queueManager.mount({ strategy: MemoryStrategy });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare class MemoryStrategy extends QueueStrategy {
|
|
17
|
+
/** Transport this strategy talks to. */
|
|
18
|
+
readonly transport: string;
|
|
19
|
+
/** Queues created so far, indexed by name */
|
|
20
|
+
private fQueues;
|
|
21
|
+
/** Monotonic counter keeping jobs of equal priority in publish order */
|
|
22
|
+
private fSequence;
|
|
23
|
+
/** Callbacks waiting for a condition on the queues to hold */
|
|
24
|
+
private fWaiters;
|
|
25
|
+
/**
|
|
26
|
+
* Everything the module offers is supported, jobs simply live in memory.
|
|
27
|
+
*
|
|
28
|
+
* @returns {QueueTypes.Capabilities} What this strategy supports
|
|
29
|
+
*/
|
|
30
|
+
get capabilities(): QueueTypes.Capabilities;
|
|
31
|
+
/**
|
|
32
|
+
* Nothing to connect to.
|
|
33
|
+
*
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
initialize(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Adds a job to an in-memory queue, honouring its delay and priority.
|
|
39
|
+
*
|
|
40
|
+
* @param {QueueTypes.PublishRequest} request - Job to publish
|
|
41
|
+
* @returns {Promise<QueueTypes.JobRef>} Reference to the published job
|
|
42
|
+
*/
|
|
43
|
+
publish(request: QueueTypes.PublishRequest): Promise<QueueTypes.JobRef>;
|
|
44
|
+
/**
|
|
45
|
+
* Starts processing a queue. Only one consumer per queue is kept, a second
|
|
46
|
+
* call replaces the first.
|
|
47
|
+
*
|
|
48
|
+
* @param {QueueTypes.ConsumeRequest} request - Queue to consume, its concurrency and the dispatch callback
|
|
49
|
+
* @returns {Promise<QueueTypes.ConsumerHandle>} Handle used to stop the consumer again
|
|
50
|
+
*/
|
|
51
|
+
consume(request: QueueTypes.ConsumeRequest): Promise<QueueTypes.ConsumerHandle>;
|
|
52
|
+
/**
|
|
53
|
+
* Reads the counters of a queue.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} name - Queue to read
|
|
56
|
+
* @returns {Promise<QueueTypes.QueueStats>} Counters of that queue
|
|
57
|
+
*/
|
|
58
|
+
stats(name: string): Promise<QueueTypes.QueueStats>;
|
|
59
|
+
/**
|
|
60
|
+
* Shows what a queue is holding: the jobs waiting to run and the ones still
|
|
61
|
+
* waiting for their delay.
|
|
62
|
+
*
|
|
63
|
+
* @param {QueueTypes.PeekRequest} request - Queue to look at, how many messages and which states
|
|
64
|
+
* @returns {Promise<QueueTypes.PeekedMessage[]>} The messages found, in the order they would run
|
|
65
|
+
*/
|
|
66
|
+
peek(request: QueueTypes.PeekRequest): Promise<QueueTypes.PeekedMessage[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Drops every queue, pending job and delay timer.
|
|
69
|
+
*
|
|
70
|
+
* @returns {Promise<void>} Resolves once everything is dropped
|
|
71
|
+
*/
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Waits until no job is waiting, delayed or running.
|
|
75
|
+
* Handy in tests, where publishing and processing happen in the same process.
|
|
76
|
+
*
|
|
77
|
+
* @returns {Promise<void>} Resolves once every queue ran dry
|
|
78
|
+
*/
|
|
79
|
+
idle(): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Hands as many waiting jobs to the consumer as its concurrency allows.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} name - Queue to pump
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
private pump;
|
|
87
|
+
/**
|
|
88
|
+
* Runs a single job and keeps the queue moving afterwards.
|
|
89
|
+
*
|
|
90
|
+
* A rejected dispatch means the job failed for good: the manager has already
|
|
91
|
+
* applied the retry policy, so nothing is put back on the queue.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} name - Queue the job belongs to
|
|
94
|
+
* @param {MemoryQueue} queue - State of that queue
|
|
95
|
+
* @param {MemoryJob} job - Job to run
|
|
96
|
+
* @returns {Promise<void>} Resolves once the job settled
|
|
97
|
+
*/
|
|
98
|
+
private run;
|
|
99
|
+
/**
|
|
100
|
+
* Describes a job the way the module reads a peeked message.
|
|
101
|
+
*
|
|
102
|
+
* @param {MemoryJob} job - Job sitting on the queue
|
|
103
|
+
* @param {QueueTypes.PeekState} state - Where it is sitting
|
|
104
|
+
* @returns {QueueTypes.PeekedMessage} The message, ready to be inspected
|
|
105
|
+
*/
|
|
106
|
+
private describe;
|
|
107
|
+
/**
|
|
108
|
+
* Picks the job to run next: lowest priority value first, publish order otherwise.
|
|
109
|
+
*
|
|
110
|
+
* @param {MemoryQueue} queue - Queue to take from
|
|
111
|
+
* @returns {MemoryJob | undefined} The next job, or undefined when the queue is empty
|
|
112
|
+
*/
|
|
113
|
+
private takeNext;
|
|
114
|
+
/**
|
|
115
|
+
* Returns the state of a queue, creating it on first use.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} name - Queue name
|
|
118
|
+
* @returns {MemoryQueue} State of that queue
|
|
119
|
+
*/
|
|
120
|
+
private queueFor;
|
|
121
|
+
/**
|
|
122
|
+
* @returns {boolean} True when no job is waiting, delayed or running
|
|
123
|
+
*/
|
|
124
|
+
private isIdle;
|
|
125
|
+
/**
|
|
126
|
+
* Waits until a condition on the queues holds.
|
|
127
|
+
*
|
|
128
|
+
* @param {() => boolean} ready - Condition to wait for, checked after every job settles
|
|
129
|
+
* @returns {Promise<void>} Resolves once the condition holds
|
|
130
|
+
*/
|
|
131
|
+
private waitFor;
|
|
132
|
+
/**
|
|
133
|
+
* Wakes everyone whose condition now holds.
|
|
134
|
+
*
|
|
135
|
+
* @returns {void}
|
|
136
|
+
*/
|
|
137
|
+
private notify;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|