@dbos-inc/dbos-sdk 4.27.3-preview → 4.27.5-preview
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/dist/src/client.d.ts +12 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +28 -52
- package/dist/src/client.js.map +1 -1
- package/dist/src/context.d.ts +2 -0
- package/dist/src/context.d.ts.map +1 -1
- package/dist/src/context.js +10 -4
- package/dist/src/context.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +2 -0
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +9 -7
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts +36 -3
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +125 -89
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/error.d.ts +22 -0
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js +48 -1
- package/dist/src/error.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/streams.d.ts +17 -0
- package/dist/src/streams.d.ts.map +1 -0
- package/dist/src/streams.js +247 -0
- package/dist/src/streams.js.map +1 -0
- package/dist/src/system_database.d.ts +22 -2
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +126 -64
- package/dist/src/system_database.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
12
|
+
exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
|
|
13
13
|
const dbos_executor_1 = require("./dbos-executor");
|
|
14
14
|
const pg_1 = require("pg");
|
|
15
15
|
const error_1 = require("./error");
|
|
@@ -31,8 +31,25 @@ exports.DBOS_FUNCNAME_SLEEP = 'DBOS.sleep';
|
|
|
31
31
|
exports.DBOS_FUNCNAME_GETSTATUS = 'getStatus';
|
|
32
32
|
exports.DBOS_FUNCNAME_WRITESTREAM = 'DBOS.writeStream';
|
|
33
33
|
exports.DBOS_FUNCNAME_CLOSESTREAM = 'DBOS.closeStream';
|
|
34
|
+
exports.DBOS_FUNCNAME_READSTREAM = 'DBOS.readStream';
|
|
35
|
+
exports.DBOS_FUNCNAME_READSTREAMOFFSET = 'DBOS.readStreamOffset';
|
|
34
36
|
exports.DEFAULT_POOL_SIZE = 10;
|
|
35
37
|
exports.DBOS_STREAM_CLOSED_SENTINEL = '__DBOS_STREAM_CLOSED__';
|
|
38
|
+
// The sentinel as it is stored: portable JSON, the same bytes every language writes and reads.
|
|
39
|
+
exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = serialization_1.DBOSPortableJSON.stringify(exports.DBOS_STREAM_CLOSED_SENTINEL);
|
|
40
|
+
/** Whether a stream value is the marker a closed stream ends with. Takes the deserialized value. */
|
|
41
|
+
function isStreamClosedSentinel(value) {
|
|
42
|
+
return typeof value === 'string' && value === exports.DBOS_STREAM_CLOSED_SENTINEL;
|
|
43
|
+
}
|
|
44
|
+
exports.isStreamClosedSentinel = isStreamClosedSentinel;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a stored value is the marker as releases before the portable form wrote it: unserialized,
|
|
47
|
+
* so no deserializer parses it. Callers must test this before deserializing.
|
|
48
|
+
*/
|
|
49
|
+
function isLegacyClosedSentinel(serializedValue) {
|
|
50
|
+
return serializedValue === exports.DBOS_STREAM_CLOSED_SENTINEL;
|
|
51
|
+
}
|
|
52
|
+
exports.isLegacyClosedSentinel = isLegacyClosedSentinel;
|
|
36
53
|
// LISTEN/NOTIFY channels. Streams and workflow_events are pushed by the notifier loop off the write path; notifications fires from an in-transaction DB trigger so recv is never woken before its row commits.
|
|
37
54
|
exports.DBOS_NOTIFICATIONS_CHANNEL = 'dbos_notifications_channel';
|
|
38
55
|
exports.DBOS_WORKFLOW_EVENTS_CHANNEL = 'dbos_workflow_events_channel';
|
|
@@ -474,6 +491,7 @@ class SystemDatabase {
|
|
|
474
491
|
notificationsClient = null;
|
|
475
492
|
dbPollingIntervalResultMs = 1000;
|
|
476
493
|
dbPollingIntervalEventMs = 10000;
|
|
494
|
+
dbPollingIntervalStreamMs = 1000;
|
|
477
495
|
shouldUseDBNotifications = true;
|
|
478
496
|
notificationsMap = new NotificationMap();
|
|
479
497
|
workflowEventsMap = new NotificationMap();
|
|
@@ -1629,7 +1647,8 @@ class SystemDatabase {
|
|
|
1629
1647
|
// Need to await for the workflow and capture errors.
|
|
1630
1648
|
const awaitWorkflowPromise = workflowPromise
|
|
1631
1649
|
.catch((error) => {
|
|
1632
|
-
this
|
|
1650
|
+
const outcome = this.#destroyed ? 'was abandoned by shutdown' : 'failed';
|
|
1651
|
+
this.logger.debug(`Workflow ${workflowID} ${outcome}: ${error}`);
|
|
1633
1652
|
})
|
|
1634
1653
|
.finally(() => {
|
|
1635
1654
|
onSettled();
|
|
@@ -1664,10 +1683,35 @@ class SystemDatabase {
|
|
|
1664
1683
|
}
|
|
1665
1684
|
return count;
|
|
1666
1685
|
}
|
|
1667
|
-
|
|
1686
|
+
/** Wait up to `timeoutMS` for locally-running workflows to finish. Without a timeout, do not wait at all. */
|
|
1687
|
+
async awaitRunningWorkflows(timeoutMS) {
|
|
1688
|
+
if (timeoutMS !== undefined && timeoutMS > 0) {
|
|
1689
|
+
const deadline = Date.now() + timeoutMS;
|
|
1690
|
+
if (this.runningWorkflowMap.size > 0) {
|
|
1691
|
+
this.logger.info('Waiting for pending workflows to finish.');
|
|
1692
|
+
}
|
|
1693
|
+
// Each pass picks up workflows a draining workflow started, and awaits any given run only once.
|
|
1694
|
+
const awaited = new Set();
|
|
1695
|
+
for (;;) {
|
|
1696
|
+
const pending = Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise).filter((promise) => !awaited.has(promise));
|
|
1697
|
+
if (pending.length === 0)
|
|
1698
|
+
break;
|
|
1699
|
+
for (const promise of pending)
|
|
1700
|
+
awaited.add(promise);
|
|
1701
|
+
let timer;
|
|
1702
|
+
const timedOut = await Promise.race([
|
|
1703
|
+
Promise.allSettled(pending).then(() => false),
|
|
1704
|
+
new Promise((resolve) => {
|
|
1705
|
+
timer = setTimeout(() => resolve(true), Math.max(0, deadline - Date.now()));
|
|
1706
|
+
}),
|
|
1707
|
+
]);
|
|
1708
|
+
clearTimeout(timer);
|
|
1709
|
+
if (timedOut)
|
|
1710
|
+
break;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1668
1713
|
if (this.runningWorkflowMap.size > 0) {
|
|
1669
|
-
this.logger.
|
|
1670
|
-
await Promise.allSettled(Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise));
|
|
1714
|
+
this.logger.warn(`Shutting down while ${this.runningWorkflowMap.size} workflows are still running: ${Array.from(this.runningWorkflowMap.keys()).join(', ')}`);
|
|
1671
1715
|
}
|
|
1672
1716
|
if (this.workflowEventsMap.map.size > 0) {
|
|
1673
1717
|
this.logger.warn('Workflow events map is not empty - shutdown is not clean.');
|
|
@@ -1696,7 +1740,8 @@ class SystemDatabase {
|
|
|
1696
1740
|
* under the polling limiter so it counts against the same concurrency budget
|
|
1697
1741
|
* as the rest of the loop's reads.
|
|
1698
1742
|
*/
|
|
1699
|
-
|
|
1743
|
+
/** Cancellation check for polling waits: goes through the limiter so readers cannot starve the pool. */
|
|
1744
|
+
async checkIfCanceledLimited(workflowID) {
|
|
1700
1745
|
await this.#pollWithLimiter(() => this.#checkIfCanceled(this.pool, workflowID));
|
|
1701
1746
|
}
|
|
1702
1747
|
// A missing row normally means the workflow has not been inserted yet, so
|
|
@@ -1715,7 +1760,7 @@ class SystemDatabase {
|
|
|
1715
1760
|
}
|
|
1716
1761
|
while (true) {
|
|
1717
1762
|
if (callerID)
|
|
1718
|
-
await this
|
|
1763
|
+
await this.checkIfCanceledLimited(callerID);
|
|
1719
1764
|
let rows;
|
|
1720
1765
|
try {
|
|
1721
1766
|
({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output, error, serialization FROM "${this.schemaName}".workflow_status
|
|
@@ -1760,7 +1805,7 @@ class SystemDatabase {
|
|
|
1760
1805
|
const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
|
|
1761
1806
|
while (true) {
|
|
1762
1807
|
if (callerID)
|
|
1763
|
-
await this
|
|
1808
|
+
await this.checkIfCanceledLimited(callerID);
|
|
1764
1809
|
const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
|
|
1765
1810
|
WHERE workflow_uuid IN (${placeholders})
|
|
1766
1811
|
AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')
|
|
@@ -1777,7 +1822,7 @@ class SystemDatabase {
|
|
|
1777
1822
|
while (remainingWorkflowIds.size > 0) {
|
|
1778
1823
|
const currentWorkflowIds = [...remainingWorkflowIds];
|
|
1779
1824
|
if (callerID)
|
|
1780
|
-
await this
|
|
1825
|
+
await this.checkIfCanceledLimited(callerID);
|
|
1781
1826
|
const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
|
|
1782
1827
|
WHERE workflow_uuid = ANY($1::text[])
|
|
1783
1828
|
AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')`, [currentWorkflowIds]));
|
|
@@ -1884,7 +1929,7 @@ class SystemDatabase {
|
|
|
1884
1929
|
const payload = `${workflowID}::${topic}`;
|
|
1885
1930
|
const cbr = this.notificationsMap.registerCallback(payload, resolveNotification);
|
|
1886
1931
|
try {
|
|
1887
|
-
await this
|
|
1932
|
+
await this.checkIfCanceledLimited(workflowID);
|
|
1888
1933
|
// Check if the key is already in the DB, then wait for the notification if it isn't.
|
|
1889
1934
|
const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT topic FROM "${this.schemaName}".notifications WHERE destination_uuid=$1 AND topic=$2 AND consumed = false;`, [workflowID, topic]))).rows;
|
|
1890
1935
|
if (initRecvRows.length !== 0)
|
|
@@ -2019,7 +2064,7 @@ class SystemDatabase {
|
|
|
2019
2064
|
const cbr = this.workflowEventsMap.registerCallback(payloadKey, resolveNotification);
|
|
2020
2065
|
try {
|
|
2021
2066
|
if (callerWorkflow?.workflowID)
|
|
2022
|
-
await this
|
|
2067
|
+
await this.checkIfCanceledLimited(callerWorkflow?.workflowID);
|
|
2023
2068
|
// Check if the key is already in the DB, then wait for the notification if it isn't.
|
|
2024
2069
|
const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT key, value, serialization
|
|
2025
2070
|
FROM "${this.schemaName}".workflow_events
|
|
@@ -2098,67 +2143,79 @@ class SystemDatabase {
|
|
|
2098
2143
|
}
|
|
2099
2144
|
// ==================== Streams ====================
|
|
2100
2145
|
async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2146
|
+
while (true) {
|
|
2147
|
+
try {
|
|
2148
|
+
// Derives the first unused offset inside the insert; two writers can still pick the same one.
|
|
2149
|
+
await this.pool.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
|
|
2150
|
+
SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
|
|
2151
|
+
FROM "${this.schemaName}".streams s
|
|
2152
|
+
WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
|
|
2153
|
+
}
|
|
2154
|
+
catch (e) {
|
|
2155
|
+
// Only an offset conflict resolves on retry; anything else would spin forever.
|
|
2156
|
+
if (e instanceof pg_1.DatabaseError && e.code === '23505') {
|
|
2157
|
+
this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
|
|
2158
|
+
await (0, utils_1.sleepms)(100);
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
this.logger.error(e);
|
|
2162
|
+
throw e;
|
|
2163
|
+
}
|
|
2114
2164
|
// Notify only after commit, so a woken reader sees the value.
|
|
2115
2165
|
this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
|
|
2116
|
-
|
|
2117
|
-
catch (e) {
|
|
2118
|
-
this.logger.error(e);
|
|
2119
|
-
await client.query('ROLLBACK');
|
|
2120
|
-
throw e;
|
|
2121
|
-
}
|
|
2122
|
-
finally {
|
|
2123
|
-
client.release();
|
|
2166
|
+
return;
|
|
2124
2167
|
}
|
|
2125
2168
|
}
|
|
2126
2169
|
async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
|
|
2127
2170
|
const client = await this.#connect();
|
|
2128
2171
|
try {
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2172
|
+
while (true) {
|
|
2173
|
+
// Only a real insert (not a replay) should wake readers.
|
|
2174
|
+
let didWrite = false;
|
|
2175
|
+
try {
|
|
2176
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
2177
|
+
await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
|
|
2178
|
+
// Derives the first unused offset inside the insert; two writers can still pick the same one.
|
|
2179
|
+
await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
|
|
2180
|
+
SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
|
|
2181
|
+
FROM "${this.schemaName}".streams s
|
|
2182
|
+
WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
|
|
2183
|
+
didWrite = true;
|
|
2184
|
+
return undefined;
|
|
2185
|
+
});
|
|
2186
|
+
await client.query('COMMIT');
|
|
2187
|
+
}
|
|
2188
|
+
catch (e) {
|
|
2189
|
+
// Only an offset conflict resolves on retry; anything else would spin forever.
|
|
2190
|
+
const offsetConflict = e instanceof pg_1.DatabaseError && e.code === '23505';
|
|
2191
|
+
// Log before touching the connection again: a failing ROLLBACK is what would propagate.
|
|
2192
|
+
if (!offsetConflict)
|
|
2193
|
+
this.logger.error(e);
|
|
2194
|
+
// Roll back before waiting, so a retry does not hold an aborted transaction open.
|
|
2195
|
+
await client.query('ROLLBACK');
|
|
2196
|
+
if (offsetConflict) {
|
|
2197
|
+
this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
|
|
2198
|
+
await (0, utils_1.sleepms)(100);
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
throw e;
|
|
2202
|
+
}
|
|
2203
|
+
// Notify only after commit, so a woken reader sees the value.
|
|
2204
|
+
if (didWrite) {
|
|
2205
|
+
this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
|
|
2206
|
+
}
|
|
2207
|
+
return;
|
|
2149
2208
|
}
|
|
2150
2209
|
}
|
|
2151
|
-
catch (e) {
|
|
2152
|
-
this.logger.error(e);
|
|
2153
|
-
await client.query('ROLLBACK');
|
|
2154
|
-
throw e;
|
|
2155
|
-
}
|
|
2156
2210
|
finally {
|
|
2157
2211
|
client.release();
|
|
2158
2212
|
}
|
|
2159
2213
|
}
|
|
2160
|
-
async
|
|
2161
|
-
await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.
|
|
2214
|
+
async closeStreamFromWorkflow(workflowID, functionID, key) {
|
|
2215
|
+
await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED, serialization_1.DBOSPortableJSON.name(), exports.DBOS_FUNCNAME_CLOSESTREAM);
|
|
2216
|
+
}
|
|
2217
|
+
async closeStreamFromStep(workflowID, stepID, key) {
|
|
2218
|
+
await this.writeStreamFromStep(workflowID, stepID, key, exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED, serialization_1.DBOSPortableJSON.name());
|
|
2162
2219
|
}
|
|
2163
2220
|
// Read the value at `offset` and the workflow's status in one query: status null = no such workflow, value undefined = nothing at that offset.
|
|
2164
2221
|
async readStreamValue(workflowID, key, offset) {
|
|
@@ -2299,15 +2356,20 @@ class SystemDatabase {
|
|
|
2299
2356
|
WHERE workflow_uuid = $1
|
|
2300
2357
|
ORDER BY key, "offset"`, [workflowID]);
|
|
2301
2358
|
const streams = {};
|
|
2359
|
+
const closed = new Set();
|
|
2302
2360
|
for (const row of result.rows) {
|
|
2303
|
-
|
|
2304
|
-
if (value === exports.DBOS_STREAM_CLOSED_SENTINEL) {
|
|
2361
|
+
if (closed.has(row.key)) {
|
|
2305
2362
|
continue;
|
|
2306
2363
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2364
|
+
// safeParse yields the raw string for the legacy unserialized marker, which does not parse.
|
|
2365
|
+
const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
|
|
2366
|
+
if (isStreamClosedSentinel(value)) {
|
|
2367
|
+
// End the stream where readStream does, so the two never disagree.
|
|
2368
|
+
closed.add(row.key);
|
|
2369
|
+
streams[row.key] ??= [];
|
|
2370
|
+
continue;
|
|
2309
2371
|
}
|
|
2310
|
-
streams[row.key].push(value);
|
|
2372
|
+
(streams[row.key] ??= []).push(value);
|
|
2311
2373
|
}
|
|
2312
2374
|
return streams;
|
|
2313
2375
|
}
|