jinaga-server 3.5.0 → 3.5.2
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/http/router.d.ts +1 -0
- package/dist/http/router.js +224 -19
- package/dist/http/router.js.map +1 -1
- package/dist/http/stream.d.ts +3 -0
- package/dist/http/stream.js +79 -5
- package/dist/http/stream.js.map +1 -1
- package/docs/analysis/DIAGNOSTIC_LOGGING_README.md +201 -0
- package/docs/analysis/security-audit-feeds-hash-handler.md +407 -0
- package/documentation/streaming-feeds-full-results-solution.md +230 -0
- package/package.json +2 -2
- package/src/http/router.ts +289 -23
- package/src/http/stream.ts +97 -5
- package/test-concurrent-streams.js +219 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Solution: Full Results Delivery for Streamed Feeds Endpoint
|
|
2
|
+
|
|
3
|
+
## Problem Analysis
|
|
4
|
+
|
|
5
|
+
The current streaming feeds endpoint has a critical limitation: it only delivers the first 100 results from the initial query and then switches to push-only mode. This means clients never receive complete historical data for feeds with more than 100 matching results.
|
|
6
|
+
|
|
7
|
+
## Proposed Solution: Continuous Initial Query with Pagination
|
|
8
|
+
|
|
9
|
+
### Core Design Principle
|
|
10
|
+
|
|
11
|
+
Modify the streaming implementation to **continue fetching and delivering all historical results** before transitioning to real-time push mode, while maintaining the streaming connection throughout the process.
|
|
12
|
+
|
|
13
|
+
### Implementation Strategy
|
|
14
|
+
|
|
15
|
+
#### 1. Enhanced `streamFeed` Method
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
private async streamFeed(user: RequestUser | null, params: { [key: string]: string }, query: qs.ParsedQs): Promise<Stream<FeedResponse> | null> {
|
|
19
|
+
const feedHash = params["hash"];
|
|
20
|
+
if (!feedHash) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const feedDefinition = await this.feedCache.getFeed(feedHash);
|
|
25
|
+
if (!feedDefinition) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let bookmark = query["b"] as string ?? "";
|
|
30
|
+
const userIdentity = serializeUserIdentity(user);
|
|
31
|
+
const start = feedDefinition.feed.given.map(label => feedDefinition.namedStart[label.name]);
|
|
32
|
+
const givenHash = computeObjectHash(feedDefinition.namedStart);
|
|
33
|
+
|
|
34
|
+
const stream = new Stream<FeedResponse>();
|
|
35
|
+
|
|
36
|
+
// NEW: Continuous initial query until exhausted
|
|
37
|
+
bookmark = await this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
|
|
38
|
+
|
|
39
|
+
// Set up real-time listeners after initial data is complete
|
|
40
|
+
const inverses = invertSpecification(feedDefinition.feed);
|
|
41
|
+
const listeners = inverses.map(inverse => this.factManager.addSpecificationListener(
|
|
42
|
+
inverse.inverseSpecification,
|
|
43
|
+
async (results) => {
|
|
44
|
+
const matchingResults = results.filter(pr =>
|
|
45
|
+
givenHash === computeTupleSubsetHash(pr.tuple, inverse.givenSubset));
|
|
46
|
+
if (matchingResults.length != 0) {
|
|
47
|
+
bookmark = await this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
));
|
|
51
|
+
|
|
52
|
+
stream.done(() => {
|
|
53
|
+
for (const listener of listeners) {
|
|
54
|
+
this.factManager.removeSpecificationListener(listener);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return stream;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### 2. New `streamAllInitialResults` Method
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
private async streamAllInitialResults(
|
|
66
|
+
userIdentity: UserIdentity | null,
|
|
67
|
+
feedDefinition: FeedObject,
|
|
68
|
+
start: FactReference[],
|
|
69
|
+
initialBookmark: string,
|
|
70
|
+
stream: Stream<FeedResponse>
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
let bookmark = initialBookmark;
|
|
73
|
+
let hasMoreResults = true;
|
|
74
|
+
let pageCount = 0;
|
|
75
|
+
const maxPages = 1000; // Safety limit to prevent infinite loops
|
|
76
|
+
|
|
77
|
+
while (hasMoreResults && pageCount < maxPages) {
|
|
78
|
+
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
79
|
+
|
|
80
|
+
// Check if we got results
|
|
81
|
+
if (results.tuples.length === 0) {
|
|
82
|
+
hasMoreResults = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Process and send results
|
|
87
|
+
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
88
|
+
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (references.length > 0) {
|
|
92
|
+
const response: FeedResponse = {
|
|
93
|
+
references,
|
|
94
|
+
bookmark: results.bookmark
|
|
95
|
+
};
|
|
96
|
+
stream.feed(response);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Update bookmark for next iteration
|
|
100
|
+
const newBookmark = results.bookmark;
|
|
101
|
+
if (newBookmark === bookmark) {
|
|
102
|
+
// No progress made, avoid infinite loop
|
|
103
|
+
hasMoreResults = false;
|
|
104
|
+
} else {
|
|
105
|
+
bookmark = newBookmark;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if we got fewer results than the page size (indicates end)
|
|
109
|
+
if (results.tuples.length < 100) {
|
|
110
|
+
hasMoreResults = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pageCount++;
|
|
114
|
+
|
|
115
|
+
// Add small delay to prevent overwhelming the database
|
|
116
|
+
if (hasMoreResults && pageCount % 10 === 0) {
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return bookmark;
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Key Design Features
|
|
126
|
+
|
|
127
|
+
#### 1. **Continuous Pagination**
|
|
128
|
+
- Loops through all available pages until no more results
|
|
129
|
+
- Uses bookmark progression to detect completion
|
|
130
|
+
- Includes safety limits to prevent infinite loops
|
|
131
|
+
|
|
132
|
+
#### 2. **Stream Integrity**
|
|
133
|
+
- Maintains single persistent connection throughout
|
|
134
|
+
- Delivers results incrementally as pages are fetched
|
|
135
|
+
- No interruption to client connection
|
|
136
|
+
|
|
137
|
+
#### 3. **Performance Safeguards**
|
|
138
|
+
- Maximum page limit (1000 pages = 100,000 results max)
|
|
139
|
+
- Periodic delays to prevent database overload
|
|
140
|
+
- Early termination on duplicate bookmarks
|
|
141
|
+
|
|
142
|
+
#### 4. **Backward Compatibility**
|
|
143
|
+
- Preserves existing API contract
|
|
144
|
+
- Maintains same response format
|
|
145
|
+
- No changes required to client implementations
|
|
146
|
+
|
|
147
|
+
### Alternative Approaches Considered
|
|
148
|
+
|
|
149
|
+
#### Option A: Async Background Fetching
|
|
150
|
+
**Rejected**: Would complicate error handling and resource cleanup
|
|
151
|
+
|
|
152
|
+
#### Option B: Configurable Page Limits
|
|
153
|
+
**Rejected**: Adds complexity without solving the core issue
|
|
154
|
+
|
|
155
|
+
#### Option C: Separate Historical vs Real-time Endpoints
|
|
156
|
+
**Rejected**: Breaks the unified streaming model
|
|
157
|
+
|
|
158
|
+
### Implementation Considerations
|
|
159
|
+
|
|
160
|
+
#### 1. **Database Impact**
|
|
161
|
+
- Multiple sequential queries instead of single query
|
|
162
|
+
- Potential increased load on connection pool
|
|
163
|
+
- Mitigation: Rate limiting and connection reuse
|
|
164
|
+
|
|
165
|
+
#### 2. **Memory Management**
|
|
166
|
+
- Stream queue may grow during rapid pagination
|
|
167
|
+
- Mitigation: Client processing should keep pace with delivery
|
|
168
|
+
- Consider: Optional memory pressure monitoring
|
|
169
|
+
|
|
170
|
+
#### 3. **Timeout Handling**
|
|
171
|
+
- 5-minute stream timeout may be insufficient for large datasets
|
|
172
|
+
- Consider: Dynamic timeout based on data volume
|
|
173
|
+
- Alternative: Configurable timeout per feed
|
|
174
|
+
|
|
175
|
+
#### 4. **Error Recovery**
|
|
176
|
+
- Database errors during pagination should not break stream
|
|
177
|
+
- Implement: Retry logic for individual page failures
|
|
178
|
+
- Fallback: Continue from last successful bookmark
|
|
179
|
+
|
|
180
|
+
### Testing Strategy
|
|
181
|
+
|
|
182
|
+
#### 1. **Unit Tests**
|
|
183
|
+
- Test pagination logic with various result set sizes
|
|
184
|
+
- Verify bookmark progression and termination conditions
|
|
185
|
+
- Test error scenarios and recovery
|
|
186
|
+
|
|
187
|
+
#### 2. **Integration Tests**
|
|
188
|
+
- Large dataset streaming (1000+ results)
|
|
189
|
+
- Concurrent stream handling
|
|
190
|
+
- Database connection pool behavior under load
|
|
191
|
+
|
|
192
|
+
#### 3. **Performance Tests**
|
|
193
|
+
- Memory usage during large result streaming
|
|
194
|
+
- Database query performance impact
|
|
195
|
+
- Client processing capability validation
|
|
196
|
+
|
|
197
|
+
### Deployment Considerations
|
|
198
|
+
|
|
199
|
+
#### 1. **Gradual Rollout**
|
|
200
|
+
- Feature flag to enable/disable full pagination
|
|
201
|
+
- Monitor database performance impact
|
|
202
|
+
- Rollback capability if issues arise
|
|
203
|
+
|
|
204
|
+
#### 2. **Monitoring**
|
|
205
|
+
- Track average pages per stream
|
|
206
|
+
- Monitor database connection pool utilization
|
|
207
|
+
- Alert on streams exceeding safety limits
|
|
208
|
+
|
|
209
|
+
#### 3. **Configuration**
|
|
210
|
+
- Make page limit configurable
|
|
211
|
+
- Allow timeout adjustment per deployment
|
|
212
|
+
- Enable/disable rate limiting
|
|
213
|
+
|
|
214
|
+
## Expected Outcomes
|
|
215
|
+
|
|
216
|
+
### Benefits
|
|
217
|
+
1. **Complete Data Delivery**: Clients receive all historical results
|
|
218
|
+
2. **Unified Experience**: Single endpoint for both historical and real-time data
|
|
219
|
+
3. **Streaming Efficiency**: Maintains connection throughout data delivery
|
|
220
|
+
4. **Backward Compatibility**: No breaking changes to existing clients
|
|
221
|
+
|
|
222
|
+
### Trade-offs
|
|
223
|
+
1. **Increased Database Load**: More queries per stream initialization
|
|
224
|
+
2. **Longer Initial Response Time**: Complete data delivery takes longer
|
|
225
|
+
3. **Memory Usage**: Larger stream queues during pagination
|
|
226
|
+
4. **Complexity**: More sophisticated error handling required
|
|
227
|
+
|
|
228
|
+
## Conclusion
|
|
229
|
+
|
|
230
|
+
This solution addresses the core limitation while maintaining the streaming architecture's benefits. The continuous pagination approach ensures complete data delivery without breaking the real-time streaming model, providing a robust foundation for applications requiring both historical completeness and live updates.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jinaga-server",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"description": "Data management for web and mobile applications.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"express": "^4.21.2",
|
|
51
|
-
"jinaga": "^6.7.
|
|
51
|
+
"jinaga": "^6.7.9",
|
|
52
52
|
"pg": "^8.13.1"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/http/router.ts
CHANGED
|
@@ -40,53 +40,131 @@ function getOrStream<U>(
|
|
|
40
40
|
streamMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<Stream<U> | null>)
|
|
41
41
|
): Handler {
|
|
42
42
|
return (req, res, next) => {
|
|
43
|
+
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
43
44
|
const user = <RequestUser>(req as any).user;
|
|
44
45
|
const accept = req.headers["accept"];
|
|
46
|
+
|
|
47
|
+
console.log(`[HttpConnection:${connectionId}] New request - User: ${user?.id || 'anonymous'}, Accept: ${accept}, URL: ${req.url}`);
|
|
48
|
+
|
|
45
49
|
if (accept && accept.indexOf("application/x-jinaga-feed-stream") >= 0) {
|
|
50
|
+
console.log(`[HttpConnection:${connectionId}] STREAMING request detected`);
|
|
51
|
+
|
|
46
52
|
streamMethod(user, req.params, req.query)
|
|
47
53
|
.then(response => {
|
|
48
54
|
if (!response) {
|
|
55
|
+
console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
|
|
49
56
|
res.sendStatus(404);
|
|
50
57
|
next();
|
|
51
58
|
}
|
|
52
59
|
else {
|
|
60
|
+
console.log(`[HttpConnection:${connectionId}] Setting up streaming response`);
|
|
61
|
+
|
|
53
62
|
res.type("application/x-jinaga-feed-stream");
|
|
54
63
|
res.set("Connection", "keep-alive");
|
|
55
64
|
res.set("Cache-Control", "no-cache");
|
|
56
65
|
res.set("Access-Control-Allow-Origin", "*");
|
|
57
66
|
res.flushHeaders();
|
|
67
|
+
|
|
68
|
+
console.log(`[HttpConnection:${connectionId}] Headers flushed - setting up event handlers`);
|
|
69
|
+
|
|
70
|
+
let clientDisconnected = false;
|
|
71
|
+
let timeoutTriggered = false;
|
|
72
|
+
let streamClosed = false;
|
|
73
|
+
|
|
74
|
+
// Client disconnect handler
|
|
58
75
|
req.on("close", () => {
|
|
59
|
-
|
|
76
|
+
console.log(`[HttpConnection:${connectionId}] CLIENT DISCONNECTED`);
|
|
77
|
+
clientDisconnected = true;
|
|
78
|
+
if (!streamClosed) {
|
|
79
|
+
console.log(`[HttpConnection:${connectionId}] Closing stream due to client disconnect`);
|
|
80
|
+
response.close();
|
|
81
|
+
streamClosed = true;
|
|
82
|
+
}
|
|
60
83
|
});
|
|
84
|
+
|
|
85
|
+
// Timeout handler
|
|
61
86
|
const timeout = setTimeout(() => {
|
|
62
|
-
|
|
87
|
+
console.log(`[HttpConnection:${connectionId}] TIMEOUT TRIGGERED (5 minutes)`);
|
|
88
|
+
timeoutTriggered = true;
|
|
89
|
+
if (!streamClosed) {
|
|
90
|
+
console.log(`[HttpConnection:${connectionId}] Closing stream due to timeout`);
|
|
91
|
+
response.close();
|
|
92
|
+
streamClosed = true;
|
|
93
|
+
}
|
|
63
94
|
}, 5 * 60 * 1000);
|
|
95
|
+
|
|
96
|
+
let messageCount = 0;
|
|
97
|
+
|
|
64
98
|
response
|
|
65
99
|
.next(data => {
|
|
66
|
-
|
|
100
|
+
messageCount++;
|
|
101
|
+
const writeStart = Date.now();
|
|
102
|
+
|
|
103
|
+
if (clientDisconnected) {
|
|
104
|
+
console.warn(`[HttpConnection:${connectionId}] Attempted to write to disconnected client - Message #${messageCount}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const jsonData = JSON.stringify(data) + "\n\n";
|
|
110
|
+
res.write(jsonData);
|
|
111
|
+
const writeDuration = Date.now() - writeStart;
|
|
112
|
+
|
|
113
|
+
console.log(`[HttpConnection:${connectionId}] Message #${messageCount} sent - Size: ${jsonData.length} bytes, Duration: ${writeDuration}ms`);
|
|
114
|
+
|
|
115
|
+
if (writeDuration > 50) {
|
|
116
|
+
console.warn(`[HttpConnection:${connectionId}] SLOW write operation - Duration: ${writeDuration}ms`);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`[HttpConnection:${connectionId}] ERROR writing message #${messageCount}: ${error}`);
|
|
120
|
+
}
|
|
67
121
|
})
|
|
68
122
|
.done(() => {
|
|
123
|
+
console.log(`[HttpConnection:${connectionId}] STREAM DONE - Messages sent: ${messageCount}, Client disconnected: ${clientDisconnected}, Timeout triggered: ${timeoutTriggered}`);
|
|
124
|
+
|
|
69
125
|
clearTimeout(timeout);
|
|
70
|
-
|
|
126
|
+
streamClosed = true;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
if (!clientDisconnected) {
|
|
130
|
+
console.log(`[HttpConnection:${connectionId}] Ending socket connection`);
|
|
131
|
+
res.socket?.end();
|
|
132
|
+
} else {
|
|
133
|
+
console.log(`[HttpConnection:${connectionId}] Client already disconnected - skipping socket end`);
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(`[HttpConnection:${connectionId}] ERROR ending socket: ${error}`);
|
|
137
|
+
}
|
|
71
138
|
});
|
|
139
|
+
|
|
140
|
+
console.log(`[HttpConnection:${connectionId}] Stream handlers configured`);
|
|
72
141
|
}
|
|
73
142
|
})
|
|
74
|
-
.catch(error =>
|
|
143
|
+
.catch(error => {
|
|
144
|
+
console.error(`[HttpConnection:${connectionId}] ERROR in stream method: ${error}`);
|
|
145
|
+
handleError(error, req, res, next);
|
|
146
|
+
});
|
|
75
147
|
}
|
|
76
148
|
else {
|
|
149
|
+
console.log(`[HttpConnection:${connectionId}] Regular GET request`);
|
|
77
150
|
getMethod(user, req.params, req.query)
|
|
78
151
|
.then(response => {
|
|
79
152
|
if (!response) {
|
|
153
|
+
console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
|
|
80
154
|
res.sendStatus(404);
|
|
81
155
|
next();
|
|
82
156
|
}
|
|
83
157
|
else {
|
|
158
|
+
console.log(`[HttpConnection:${connectionId}] Sending JSON response`);
|
|
84
159
|
res.type("json");
|
|
85
160
|
res.send(JSON.stringify(response));
|
|
86
161
|
next();
|
|
87
162
|
}
|
|
88
163
|
})
|
|
89
|
-
.catch(error =>
|
|
164
|
+
.catch(error => {
|
|
165
|
+
console.error(`[HttpConnection:${connectionId}] ERROR in get method: ${error}`);
|
|
166
|
+
handleError(error, req, res, next);
|
|
167
|
+
});
|
|
90
168
|
}
|
|
91
169
|
};
|
|
92
170
|
}
|
|
@@ -466,56 +544,244 @@ export class HttpRouter {
|
|
|
466
544
|
}
|
|
467
545
|
|
|
468
546
|
private async streamFeed(user: RequestUser | null, params: { [key: string]: string }, query: qs.ParsedQs): Promise<Stream<FeedResponse> | null> {
|
|
547
|
+
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
548
|
+
const startTime = Date.now();
|
|
549
|
+
|
|
550
|
+
console.log(`[StreamFeed:${connectionId}] NEW CONNECTION - User: ${user?.id || 'anonymous'}, Hash: ${params["hash"]}`);
|
|
551
|
+
|
|
469
552
|
const feedHash = params["hash"];
|
|
470
553
|
if (!feedHash) {
|
|
554
|
+
console.log(`[StreamFeed:${connectionId}] No feed hash provided`);
|
|
471
555
|
return null;
|
|
472
556
|
}
|
|
473
557
|
|
|
474
558
|
const feedDefinition = await this.feedCache.getFeed(feedHash);
|
|
475
559
|
if (!feedDefinition) {
|
|
560
|
+
console.log(`[StreamFeed:${connectionId}] Feed definition not found for hash: ${feedHash}`);
|
|
476
561
|
return null;
|
|
477
562
|
}
|
|
478
563
|
|
|
479
564
|
let bookmark = query["b"] as string ?? "";
|
|
565
|
+
console.log(`[StreamFeed:${connectionId}] Initial bookmark: ${bookmark || 'empty'}`);
|
|
480
566
|
|
|
481
567
|
const userIdentity = serializeUserIdentity(user);
|
|
482
568
|
const start = feedDefinition.feed.given.map(label => feedDefinition.namedStart[label.name]);
|
|
483
569
|
const givenHash = computeObjectHash(feedDefinition.namedStart);
|
|
484
570
|
|
|
571
|
+
console.log(`[StreamFeed:${connectionId}] Feed setup - Given hash: ${givenHash.substring(0, 8)}..., Start facts: ${start.length}`);
|
|
572
|
+
|
|
485
573
|
const stream = new Stream<FeedResponse>();
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
// Continuous initial query until exhausted
|
|
577
|
+
console.log(`[StreamFeed:${connectionId}] Starting initial data streaming...`);
|
|
578
|
+
const initialStart = Date.now();
|
|
579
|
+
bookmark = await this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
|
|
580
|
+
const initialDuration = Date.now() - initialStart;
|
|
581
|
+
console.log(`[StreamFeed:${connectionId}] Initial data streaming complete - Duration: ${initialDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
582
|
+
|
|
583
|
+
// Set up real-time listeners after initial data is complete
|
|
584
|
+
console.log(`[StreamFeed:${connectionId}] Setting up real-time listeners...`);
|
|
585
|
+
const inverses = invertSpecification(feedDefinition.feed);
|
|
586
|
+
console.log(`[StreamFeed:${connectionId}] Created ${inverses.length} inverse specifications`);
|
|
587
|
+
|
|
588
|
+
const listeners = inverses.map((inverse, index) => {
|
|
589
|
+
console.log(`[StreamFeed:${connectionId}] Adding listener ${index + 1}/${inverses.length}`);
|
|
590
|
+
|
|
591
|
+
return this.factManager.addSpecificationListener(
|
|
592
|
+
inverse.inverseSpecification,
|
|
593
|
+
async (results) => {
|
|
594
|
+
const eventStart = Date.now();
|
|
595
|
+
console.log(`[StreamFeed:${connectionId}] EVENT RECEIVED - Listener ${index + 1}, Results: ${results.length}`);
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
// Filter out results that do not match the given.
|
|
599
|
+
const matchingResults = results.filter(pr =>
|
|
600
|
+
givenHash === computeTupleSubsetHash(pr.tuple, inverse.givenSubset));
|
|
601
|
+
|
|
602
|
+
console.log(`[StreamFeed:${connectionId}] Filtered results - Matching: ${matchingResults.length}/${results.length}`);
|
|
603
|
+
|
|
604
|
+
if (matchingResults.length != 0) {
|
|
605
|
+
console.log(`[StreamFeed:${connectionId}] Processing matching results...`);
|
|
606
|
+
const responseStart = Date.now();
|
|
607
|
+
bookmark = await this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
608
|
+
const responseDuration = Date.now() - responseStart;
|
|
609
|
+
console.log(`[StreamFeed:${connectionId}] Feed response sent - Duration: ${responseDuration}ms, New bookmark: ${bookmark || 'empty'}`);
|
|
610
|
+
} else {
|
|
611
|
+
console.log(`[StreamFeed:${connectionId}] No matching results - skipping response`);
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
console.error(`[StreamFeed:${connectionId}] ERROR processing event: ${error}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const eventDuration = Date.now() - eventStart;
|
|
618
|
+
if (eventDuration > 100) {
|
|
619
|
+
console.warn(`[StreamFeed:${connectionId}] SLOW event processing - Duration: ${eventDuration}ms`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
console.log(`[StreamFeed:${connectionId}] All listeners registered - Count: ${listeners.length}`);
|
|
626
|
+
|
|
627
|
+
stream.done(() => {
|
|
628
|
+
const cleanupStart = Date.now();
|
|
629
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length} listeners`);
|
|
630
|
+
|
|
631
|
+
for (let i = 0; i < listeners.length; i++) {
|
|
632
|
+
try {
|
|
633
|
+
this.factManager.removeSpecificationListener(listeners[i]);
|
|
634
|
+
console.log(`[StreamFeed:${connectionId}] Removed listener ${i + 1}/${listeners.length}`);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
console.error(`[StreamFeed:${connectionId}] ERROR removing listener ${i + 1}: ${error}`);
|
|
637
|
+
}
|
|
496
638
|
}
|
|
639
|
+
|
|
640
|
+
const cleanupDuration = Date.now() - cleanupStart;
|
|
641
|
+
const totalDuration = Date.now() - startTime;
|
|
642
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP COMPLETE - Cleanup duration: ${cleanupDuration}ms, Total connection duration: ${totalDuration}ms`);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const setupDuration = Date.now() - startTime;
|
|
646
|
+
console.log(`[StreamFeed:${connectionId}] Stream setup complete - Duration: ${setupDuration}ms`);
|
|
647
|
+
|
|
648
|
+
return stream;
|
|
649
|
+
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.error(`[StreamFeed:${connectionId}] ERROR during setup: ${error}`);
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async streamAllInitialResults(
|
|
657
|
+
userIdentity: UserIdentity | null,
|
|
658
|
+
feedDefinition: FeedObject,
|
|
659
|
+
start: FactReference[],
|
|
660
|
+
initialBookmark: string,
|
|
661
|
+
stream: Stream<FeedResponse>
|
|
662
|
+
): Promise<string> {
|
|
663
|
+
const streamId = Math.random().toString(36).substring(2, 8);
|
|
664
|
+
console.log(`[InitialResults:${streamId}] Starting initial data fetch - Bookmark: ${initialBookmark || 'empty'}`);
|
|
665
|
+
|
|
666
|
+
let bookmark = initialBookmark;
|
|
667
|
+
let hasMoreResults = true;
|
|
668
|
+
let pageCount = 0;
|
|
669
|
+
let totalReferences = 0;
|
|
670
|
+
const maxPages = 1000; // Safety limit to prevent infinite loops
|
|
671
|
+
const startTime = Date.now();
|
|
672
|
+
|
|
673
|
+
while (hasMoreResults && pageCount < maxPages) {
|
|
674
|
+
const pageStart = Date.now();
|
|
675
|
+
console.log(`[InitialResults:${streamId}] Fetching page ${pageCount + 1} - Bookmark: ${bookmark || 'empty'}`);
|
|
676
|
+
|
|
677
|
+
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
678
|
+
const fetchDuration = Date.now() - pageStart;
|
|
679
|
+
|
|
680
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} fetched - Tuples: ${results.tuples.length}, Duration: ${fetchDuration}ms`);
|
|
681
|
+
|
|
682
|
+
// Check if we got results
|
|
683
|
+
if (results.tuples.length === 0) {
|
|
684
|
+
console.log(`[InitialResults:${streamId}] No more results - ending pagination`);
|
|
685
|
+
hasMoreResults = false;
|
|
686
|
+
break;
|
|
497
687
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
688
|
+
|
|
689
|
+
// Process and send results
|
|
690
|
+
const processStart = Date.now();
|
|
691
|
+
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
692
|
+
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
693
|
+
);
|
|
694
|
+
const processDuration = Date.now() - processStart;
|
|
695
|
+
|
|
696
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} processed - References: ${references.length}, Process duration: ${processDuration}ms`);
|
|
697
|
+
|
|
698
|
+
if (references.length > 0) {
|
|
699
|
+
const response: FeedResponse = {
|
|
700
|
+
references,
|
|
701
|
+
bookmark: results.bookmark
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const feedStart = Date.now();
|
|
705
|
+
stream.feed(response);
|
|
706
|
+
const feedDuration = Date.now() - feedStart;
|
|
707
|
+
|
|
708
|
+
totalReferences += references.length;
|
|
709
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} sent to stream - Feed duration: ${feedDuration}ms`);
|
|
502
710
|
}
|
|
503
|
-
|
|
504
|
-
|
|
711
|
+
|
|
712
|
+
// Update bookmark for next iteration
|
|
713
|
+
const newBookmark = results.bookmark;
|
|
714
|
+
if (newBookmark === bookmark) {
|
|
715
|
+
console.log(`[InitialResults:${streamId}] Bookmark unchanged - ending pagination`);
|
|
716
|
+
hasMoreResults = false;
|
|
717
|
+
} else {
|
|
718
|
+
bookmark = newBookmark;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Check if we got fewer results than the page size (indicates end)
|
|
722
|
+
if (results.tuples.length < 100) {
|
|
723
|
+
console.log(`[InitialResults:${streamId}] Partial page received (${results.tuples.length} < 100) - ending pagination`);
|
|
724
|
+
hasMoreResults = false;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
pageCount++;
|
|
728
|
+
|
|
729
|
+
// Add small delay to prevent overwhelming the database
|
|
730
|
+
if (hasMoreResults && pageCount % 10 === 0) {
|
|
731
|
+
console.log(`[InitialResults:${streamId}] Adding delay after ${pageCount} pages`);
|
|
732
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const totalDuration = Date.now() - startTime;
|
|
737
|
+
console.log(`[InitialResults:${streamId}] Initial data streaming complete - Pages: ${pageCount}, Total references: ${totalReferences}, Duration: ${totalDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
738
|
+
|
|
739
|
+
if (pageCount >= maxPages) {
|
|
740
|
+
console.warn(`[InitialResults:${streamId}] Hit maximum page limit (${maxPages}) - possible infinite loop`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return bookmark;
|
|
505
744
|
}
|
|
506
745
|
|
|
507
746
|
private async streamFeedResponse(userIdentity: UserIdentity | null, feedDefinition: FeedObject, start: FactReference[], bookmark: string, stream: Stream<FeedResponse>, skipIfEmpty = false): Promise<string> {
|
|
747
|
+
const responseId = Math.random().toString(36).substring(2, 6);
|
|
748
|
+
const startTime = Date.now();
|
|
749
|
+
|
|
750
|
+
console.log(`[FeedResponse:${responseId}] Starting feed response - Bookmark: ${bookmark || 'empty'}, Skip if empty: ${skipIfEmpty}`);
|
|
751
|
+
|
|
752
|
+
const feedStart = Date.now();
|
|
508
753
|
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
754
|
+
const feedDuration = Date.now() - feedStart;
|
|
755
|
+
|
|
756
|
+
console.log(`[FeedResponse:${responseId}] Authorization feed complete - Tuples: ${results.tuples.length}, Duration: ${feedDuration}ms`);
|
|
757
|
+
|
|
509
758
|
// Return distinct fact references from all the tuples.
|
|
510
|
-
const
|
|
759
|
+
const processStart = Date.now();
|
|
760
|
+
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
761
|
+
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
511
762
|
);
|
|
763
|
+
const processDuration = Date.now() - processStart;
|
|
764
|
+
|
|
765
|
+
console.log(`[FeedResponse:${responseId}] References processed - Count: ${references.length}, Duration: ${processDuration}ms`);
|
|
766
|
+
|
|
512
767
|
if (!skipIfEmpty || references.length > 0) {
|
|
513
768
|
const response: FeedResponse = {
|
|
514
769
|
references,
|
|
515
770
|
bookmark: results.bookmark
|
|
516
771
|
};
|
|
772
|
+
|
|
773
|
+
const streamStart = Date.now();
|
|
517
774
|
stream.feed(response);
|
|
775
|
+
const streamDuration = Date.now() - streamStart;
|
|
776
|
+
|
|
777
|
+
console.log(`[FeedResponse:${responseId}] Response sent to stream - Duration: ${streamDuration}ms`);
|
|
778
|
+
} else {
|
|
779
|
+
console.log(`[FeedResponse:${responseId}] Skipping empty response`);
|
|
518
780
|
}
|
|
781
|
+
|
|
782
|
+
const totalDuration = Date.now() - startTime;
|
|
783
|
+
console.log(`[FeedResponse:${responseId}] Feed response complete - New bookmark: ${results.bookmark || 'empty'}, Total duration: ${totalDuration}ms`);
|
|
784
|
+
|
|
519
785
|
return results.bookmark;
|
|
520
786
|
}
|
|
521
787
|
|