@pikku/redis 0.12.19 → 0.12.21
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/CHANGELOG.md +52 -0
- package/dist/src/redis-agent-run-service.js.map +1 -1
- package/dist/src/redis-deployment-service.js.map +1 -1
- package/dist/src/redis-secret-service.js +1 -1
- package/dist/src/redis-secret-service.js.map +1 -1
- package/dist/src/redis-workflow-run-service.js.map +1 -1
- package/dist/src/redis-workflow-service.js.map +1 -1
- package/package.json +10 -6
- package/run-tests.sh +59 -24
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/index.ts +0 -10
- package/src/redis-agent-run-service.ts +0 -207
- package/src/redis-channel-store.ts +0 -158
- package/src/redis-deployment-service.ts +0 -171
- package/src/redis-eventhub-store.ts +0 -104
- package/src/redis-secret-service.test.ts +0 -122
- package/src/redis-secret-service.ts +0 -210
- package/src/redis-session-store.ts +0 -71
- package/src/redis-workflow-run-service.ts +0 -231
- package/src/redis-workflow-service.test.ts +0 -115
- package/src/redis-workflow-service.ts +0 -1025
- package/tsconfig.json +0 -17
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AgentThread,
|
|
3
|
-
AgentMessage,
|
|
4
|
-
AgentRunRow,
|
|
5
|
-
AgentRunService,
|
|
6
|
-
} from '@pikku/core/agent'
|
|
7
|
-
import { isOwnedByPrincipal } from '@pikku/core/agent'
|
|
8
|
-
import type { Redis } from 'ioredis'
|
|
9
|
-
|
|
10
|
-
export class RedisAgentRunService implements AgentRunService {
|
|
11
|
-
constructor(
|
|
12
|
-
private redis: Redis,
|
|
13
|
-
private keyPrefix = 'ai'
|
|
14
|
-
) {}
|
|
15
|
-
|
|
16
|
-
private threadKey(threadId: string): string {
|
|
17
|
-
return `${this.keyPrefix}:thread:${threadId}`
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
private threadsIndexKey(): string {
|
|
21
|
-
return `${this.keyPrefix}:threads`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
private messagesKey(threadId: string): string {
|
|
25
|
-
return `${this.keyPrefix}:messages:${threadId}`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
private runKey(runId: string): string {
|
|
29
|
-
return `${this.keyPrefix}:run:${runId}`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private threadRunsKey(threadId: string): string {
|
|
33
|
-
return `${this.keyPrefix}:thread-runs:${threadId}`
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
private agentThreadsKey(agentName: string): string {
|
|
37
|
-
return `${this.keyPrefix}:agent-threads:${agentName}`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async listThreads(options?: {
|
|
41
|
-
agentName?: string
|
|
42
|
-
resourceId?: string
|
|
43
|
-
owners?: string[]
|
|
44
|
-
limit?: number
|
|
45
|
-
offset?: number
|
|
46
|
-
}): Promise<AgentThread[]> {
|
|
47
|
-
const {
|
|
48
|
-
agentName,
|
|
49
|
-
resourceId,
|
|
50
|
-
owners,
|
|
51
|
-
limit = 50,
|
|
52
|
-
offset = 0,
|
|
53
|
-
} = options ?? {}
|
|
54
|
-
|
|
55
|
-
// An owners constraint is an authorization boundary, so an empty list must
|
|
56
|
-
// match nothing rather than degrade to "no filter".
|
|
57
|
-
if (owners && owners.length === 0) return []
|
|
58
|
-
|
|
59
|
-
let threadIds: string[]
|
|
60
|
-
|
|
61
|
-
if (agentName) {
|
|
62
|
-
const allIds = await this.redis.smembers(this.agentThreadsKey(agentName))
|
|
63
|
-
const threadsWithScores: { id: string; score: number }[] = []
|
|
64
|
-
for (const id of allIds) {
|
|
65
|
-
const score = await this.redis.zscore(this.threadsIndexKey(), id)
|
|
66
|
-
if (score) {
|
|
67
|
-
threadsWithScores.push({ id, score: Number(score) })
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
threadsWithScores.sort((a, b) => b.score - a.score)
|
|
71
|
-
threadIds = threadsWithScores.map((t) => t.id)
|
|
72
|
-
} else {
|
|
73
|
-
threadIds = await this.redis.zrevrange(this.threadsIndexKey(), 0, -1)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Filter before paging: slicing first would let a page of another owner's
|
|
77
|
-
// threads collapse to fewer results than requested (and, for `owners`, makes
|
|
78
|
-
// the page size leak how many foreign threads exist).
|
|
79
|
-
const matched: AgentThread[] = []
|
|
80
|
-
for (const id of threadIds) {
|
|
81
|
-
const thread = await this.getThread(id)
|
|
82
|
-
if (!thread) continue
|
|
83
|
-
if (resourceId && thread.resourceId !== resourceId) continue
|
|
84
|
-
if (
|
|
85
|
-
owners &&
|
|
86
|
-
!owners.some((owner) => isOwnedByPrincipal(thread.resourceId, owner))
|
|
87
|
-
) {
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
matched.push(thread)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return matched.slice(offset, offset + limit)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async getThread(threadId: string): Promise<AgentThread | null> {
|
|
97
|
-
const data = await this.redis.hgetall(this.threadKey(threadId))
|
|
98
|
-
if (!data.id) return null
|
|
99
|
-
return {
|
|
100
|
-
id: data.id,
|
|
101
|
-
resourceId: data.resourceId!,
|
|
102
|
-
title: data.title || undefined,
|
|
103
|
-
metadata: data.metadata ? JSON.parse(data.metadata) : undefined,
|
|
104
|
-
createdAt: new Date(Number(data.createdAt)),
|
|
105
|
-
updatedAt: new Date(Number(data.updatedAt)),
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async getThreadMessages(threadId: string): Promise<AgentMessage[]> {
|
|
110
|
-
const entries = await this.redis.zrange(this.messagesKey(threadId), 0, '-1')
|
|
111
|
-
return entries.map((entry) => {
|
|
112
|
-
const msg = JSON.parse(entry)
|
|
113
|
-
return {
|
|
114
|
-
...msg,
|
|
115
|
-
createdAt: new Date(msg.createdAt),
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async getThreadRuns(threadId: string): Promise<AgentRunRow[]> {
|
|
121
|
-
const runIds = await this.redis.zrevrange(
|
|
122
|
-
this.threadRunsKey(threadId),
|
|
123
|
-
0,
|
|
124
|
-
-1
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
const runs: AgentRunRow[] = []
|
|
128
|
-
for (const runId of runIds) {
|
|
129
|
-
const data = await this.redis.hgetall(this.runKey(runId))
|
|
130
|
-
if (!data.runId) continue
|
|
131
|
-
runs.push({
|
|
132
|
-
runId: data.runId,
|
|
133
|
-
agentName: data.agentName!,
|
|
134
|
-
threadId: data.threadId!,
|
|
135
|
-
resourceId: data.resourceId!,
|
|
136
|
-
status: data.status!,
|
|
137
|
-
suspendReason: data.suspendReason || undefined,
|
|
138
|
-
missingRpcs: data.missingRpcs
|
|
139
|
-
? JSON.parse(data.missingRpcs)
|
|
140
|
-
: undefined,
|
|
141
|
-
usageInputTokens: Number(data.usageInputTokens || 0),
|
|
142
|
-
usageOutputTokens: Number(data.usageOutputTokens || 0),
|
|
143
|
-
usageModel: data.usageModel!,
|
|
144
|
-
createdAt: new Date(Number(data.createdAt)),
|
|
145
|
-
updatedAt: new Date(Number(data.updatedAt)),
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return runs
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async deleteThread(threadId: string): Promise<boolean> {
|
|
153
|
-
const exists = await this.redis.exists(this.threadKey(threadId))
|
|
154
|
-
if (!exists) return false
|
|
155
|
-
|
|
156
|
-
const runIds = await this.redis.zrange(
|
|
157
|
-
this.threadRunsKey(threadId),
|
|
158
|
-
0,
|
|
159
|
-
'-1'
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
const keysToDelete = [
|
|
163
|
-
this.threadKey(threadId),
|
|
164
|
-
this.messagesKey(threadId),
|
|
165
|
-
this.threadRunsKey(threadId),
|
|
166
|
-
]
|
|
167
|
-
|
|
168
|
-
for (const runId of runIds) {
|
|
169
|
-
const agentName = await this.redis.hget(this.runKey(runId), 'agentName')
|
|
170
|
-
keysToDelete.push(this.runKey(runId))
|
|
171
|
-
if (agentName) {
|
|
172
|
-
await this.redis.srem(this.agentThreadsKey(agentName), threadId)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
await this.redis.zrem(this.threadsIndexKey(), threadId)
|
|
177
|
-
|
|
178
|
-
if (keysToDelete.length > 0) {
|
|
179
|
-
await this.redis.del(...keysToDelete)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return true
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async getDistinctAgentNames(): Promise<string[]> {
|
|
186
|
-
const keys = await this.scanKeys(`${this.keyPrefix}:agent-threads:*`)
|
|
187
|
-
const prefix = `${this.keyPrefix}:agent-threads:`
|
|
188
|
-
return keys.map((k) => k.slice(prefix.length)).sort()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private async scanKeys(pattern: string): Promise<string[]> {
|
|
192
|
-
const keys: string[] = []
|
|
193
|
-
let cursor = '0'
|
|
194
|
-
do {
|
|
195
|
-
const [newCursor, foundKeys] = await this.redis.scan(
|
|
196
|
-
cursor,
|
|
197
|
-
'MATCH',
|
|
198
|
-
pattern,
|
|
199
|
-
'COUNT',
|
|
200
|
-
100
|
|
201
|
-
)
|
|
202
|
-
cursor = newCursor
|
|
203
|
-
keys.push(...foundKeys)
|
|
204
|
-
} while (cursor !== '0')
|
|
205
|
-
return keys
|
|
206
|
-
}
|
|
207
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { ChannelStore } from '@pikku/core/channel'
|
|
2
|
-
import type { Channel } from '@pikku/core/channel'
|
|
3
|
-
import { Redis, type RedisOptions } from 'ioredis'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Redis-based implementation of ChannelStore
|
|
7
|
-
*
|
|
8
|
-
* Stores channel state in Redis with configurable key prefix.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```typescript
|
|
12
|
-
* const redis = new Redis({ host: 'localhost', port: 6379 })
|
|
13
|
-
* const store = new RedisChannelStore(redis, 'myapp')
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
export class RedisChannelStore extends ChannelStore {
|
|
17
|
-
private redis: Redis
|
|
18
|
-
private keyPrefix: string
|
|
19
|
-
private ownsConnection: boolean
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @param connectionOrConfig - ioredis Redis instance or RedisOptions config
|
|
23
|
-
* @param keyPrefix - Redis key prefix (default: 'pikku')
|
|
24
|
-
*/
|
|
25
|
-
constructor(
|
|
26
|
-
connectionOrConfig: Redis | RedisOptions | string,
|
|
27
|
-
keyPrefix = 'pikku'
|
|
28
|
-
) {
|
|
29
|
-
super()
|
|
30
|
-
this.keyPrefix = keyPrefix
|
|
31
|
-
|
|
32
|
-
// Check if it's a Redis instance or config options
|
|
33
|
-
if (connectionOrConfig instanceof Redis) {
|
|
34
|
-
this.redis = connectionOrConfig
|
|
35
|
-
this.ownsConnection = false
|
|
36
|
-
} else if (typeof connectionOrConfig === 'string') {
|
|
37
|
-
// It's a connection string
|
|
38
|
-
this.redis = new Redis(connectionOrConfig)
|
|
39
|
-
this.ownsConnection = true
|
|
40
|
-
} else {
|
|
41
|
-
// It's a config object
|
|
42
|
-
this.redis = new Redis(connectionOrConfig)
|
|
43
|
-
this.ownsConnection = true
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Initialize the store (no-op for Redis, always ready)
|
|
49
|
-
*/
|
|
50
|
-
public async init(): Promise<void> {
|
|
51
|
-
// Redis doesn't require schema initialization
|
|
52
|
-
// Just verify connection works
|
|
53
|
-
await this.redis.ping()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private channelKey(channelId: string): string {
|
|
57
|
-
return `${this.keyPrefix}:channel:${channelId}`
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
public async addChannel({
|
|
61
|
-
channelId,
|
|
62
|
-
channelName,
|
|
63
|
-
openingData,
|
|
64
|
-
}: Channel): Promise<void> {
|
|
65
|
-
const key = this.channelKey(channelId)
|
|
66
|
-
|
|
67
|
-
await this.redis.hmset(
|
|
68
|
-
key,
|
|
69
|
-
'channelId',
|
|
70
|
-
channelId,
|
|
71
|
-
'channelName',
|
|
72
|
-
channelName,
|
|
73
|
-
'openingData',
|
|
74
|
-
JSON.stringify(openingData || {}),
|
|
75
|
-
'createdAt',
|
|
76
|
-
Date.now().toString()
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
public async removeChannels(channelIds: string[]): Promise<void> {
|
|
81
|
-
if (channelIds.length === 0) {
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const pipeline = this.redis.pipeline()
|
|
86
|
-
|
|
87
|
-
for (const channelId of channelIds) {
|
|
88
|
-
const key = this.channelKey(channelId)
|
|
89
|
-
pipeline.del(key)
|
|
90
|
-
// Also remove from subscriptions
|
|
91
|
-
pipeline.del(`${this.keyPrefix}:subs:${channelId}`)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
await pipeline.exec()
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
public async setPikkuUserId(
|
|
98
|
-
channelId: string,
|
|
99
|
-
pikkuUserId: string | null
|
|
100
|
-
): Promise<void> {
|
|
101
|
-
const key = this.channelKey(channelId)
|
|
102
|
-
|
|
103
|
-
if (pikkuUserId) {
|
|
104
|
-
await this.redis.hset(key, 'pikkuUserId', pikkuUserId)
|
|
105
|
-
} else {
|
|
106
|
-
await this.redis.hdel(key, 'pikkuUserId')
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
public async getChannel(
|
|
111
|
-
channelId: string
|
|
112
|
-
): Promise<Channel & { pikkuUserId?: string }> {
|
|
113
|
-
const key = this.channelKey(channelId)
|
|
114
|
-
const data = await this.redis.hgetall(key)
|
|
115
|
-
|
|
116
|
-
if (!data.channelId) {
|
|
117
|
-
throw new Error(`Channel not found: ${channelId}`)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
channelId: data.channelId!,
|
|
122
|
-
channelName: data.channelName!,
|
|
123
|
-
openingData: data.openingData ? JSON.parse(data.openingData) : {},
|
|
124
|
-
pikkuUserId: data.pikkuUserId || undefined,
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
public async setState(channelId: string, state: unknown): Promise<void> {
|
|
129
|
-
const key = this.channelKey(channelId)
|
|
130
|
-
await this.redis.hset(key, 'state', JSON.stringify(state ?? null))
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
public async getState(channelId: string): Promise<unknown | undefined> {
|
|
134
|
-
const key = this.channelKey(channelId)
|
|
135
|
-
const raw = await this.redis.hget(key, 'state')
|
|
136
|
-
if (!raw) return undefined
|
|
137
|
-
try {
|
|
138
|
-
const parsed = JSON.parse(raw)
|
|
139
|
-
return parsed ?? undefined
|
|
140
|
-
} catch {
|
|
141
|
-
return undefined
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
public async clearState(channelId: string): Promise<void> {
|
|
146
|
-
const key = this.channelKey(channelId)
|
|
147
|
-
await this.redis.hdel(key, 'state')
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Close the Redis connection if owned by this store
|
|
152
|
-
*/
|
|
153
|
-
public async close(): Promise<void> {
|
|
154
|
-
if (this.ownsConnection) {
|
|
155
|
-
await this.redis.quit()
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { buildRemoteHeaders } from '@pikku/core/remote'
|
|
2
|
-
import type {
|
|
3
|
-
DeploymentService,
|
|
4
|
-
DeploymentServiceConfig,
|
|
5
|
-
DeploymentConfig,
|
|
6
|
-
} from '@pikku/core/services'
|
|
7
|
-
import type { JWTService } from '@pikku/core/services'
|
|
8
|
-
import type { SecretService } from '@pikku/core/services'
|
|
9
|
-
import { getAllFunctionNames } from '@pikku/core/function'
|
|
10
|
-
import { Redis, type RedisOptions } from 'ioredis'
|
|
11
|
-
|
|
12
|
-
export class RedisDeploymentService implements DeploymentService {
|
|
13
|
-
private redis: Redis
|
|
14
|
-
private keyPrefix: string
|
|
15
|
-
private ownsConnection: boolean
|
|
16
|
-
private heartbeatTimer?: ReturnType<typeof setInterval>
|
|
17
|
-
private deploymentConfig?: DeploymentConfig
|
|
18
|
-
private heartbeatInterval: number
|
|
19
|
-
private heartbeatTtl: number
|
|
20
|
-
|
|
21
|
-
constructor(
|
|
22
|
-
config: DeploymentServiceConfig,
|
|
23
|
-
connectionOrConfig: Redis | RedisOptions | string,
|
|
24
|
-
keyPrefix = 'pikku',
|
|
25
|
-
private jwt?: JWTService,
|
|
26
|
-
private secrets?: SecretService
|
|
27
|
-
) {
|
|
28
|
-
this.heartbeatInterval = config.heartbeatInterval ?? 10000
|
|
29
|
-
this.heartbeatTtl = config.heartbeatTtl ?? 30000
|
|
30
|
-
this.keyPrefix = keyPrefix
|
|
31
|
-
|
|
32
|
-
if (connectionOrConfig instanceof Redis) {
|
|
33
|
-
this.redis = connectionOrConfig
|
|
34
|
-
this.ownsConnection = false
|
|
35
|
-
} else if (typeof connectionOrConfig === 'string') {
|
|
36
|
-
this.redis = new Redis(connectionOrConfig)
|
|
37
|
-
this.ownsConnection = true
|
|
38
|
-
} else {
|
|
39
|
-
this.redis = new Redis(connectionOrConfig)
|
|
40
|
-
this.ownsConnection = true
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private deploymentKey(deploymentId: string): string {
|
|
45
|
-
return `${this.keyPrefix}:deployment:${deploymentId}`
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private functionsIndexKey(funcName: string): string {
|
|
49
|
-
return `${this.keyPrefix}:fn:${funcName}`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
public async init(): Promise<void> {}
|
|
53
|
-
|
|
54
|
-
async start(config: DeploymentConfig): Promise<void> {
|
|
55
|
-
const functions = config.functions ?? getAllFunctionNames()
|
|
56
|
-
this.deploymentConfig = { ...config, functions }
|
|
57
|
-
const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
|
|
58
|
-
const expiryScore = Date.now() + this.heartbeatTtl
|
|
59
|
-
|
|
60
|
-
const pipeline = this.redis.pipeline()
|
|
61
|
-
|
|
62
|
-
pipeline.hset(this.deploymentKey(config.deploymentId), {
|
|
63
|
-
endpoint: config.endpoint,
|
|
64
|
-
functions: JSON.stringify(functions),
|
|
65
|
-
})
|
|
66
|
-
pipeline.expire(this.deploymentKey(config.deploymentId), ttlSeconds)
|
|
67
|
-
|
|
68
|
-
for (const fn of functions) {
|
|
69
|
-
pipeline.zadd(
|
|
70
|
-
this.functionsIndexKey(fn),
|
|
71
|
-
expiryScore,
|
|
72
|
-
config.deploymentId
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
await pipeline.exec()
|
|
77
|
-
|
|
78
|
-
this.heartbeatTimer = setInterval(
|
|
79
|
-
() => this.sendHeartbeat(),
|
|
80
|
-
this.heartbeatInterval
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async stop(): Promise<void> {
|
|
85
|
-
if (this.heartbeatTimer) {
|
|
86
|
-
clearInterval(this.heartbeatTimer)
|
|
87
|
-
this.heartbeatTimer = undefined
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (this.deploymentConfig) {
|
|
91
|
-
const { deploymentId, functions } = this.deploymentConfig
|
|
92
|
-
const pipeline = this.redis.pipeline()
|
|
93
|
-
|
|
94
|
-
pipeline.del(this.deploymentKey(deploymentId))
|
|
95
|
-
for (const fn of functions!) {
|
|
96
|
-
pipeline.zrem(this.functionsIndexKey(fn), deploymentId)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
await pipeline.exec()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (this.ownsConnection) {
|
|
103
|
-
await this.redis.quit()
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async invoke(
|
|
108
|
-
funcName: string,
|
|
109
|
-
data: unknown,
|
|
110
|
-
session?: unknown,
|
|
111
|
-
traceId?: string
|
|
112
|
-
): Promise<unknown> {
|
|
113
|
-
const headers = await buildRemoteHeaders(
|
|
114
|
-
this.jwt,
|
|
115
|
-
this.secrets,
|
|
116
|
-
funcName,
|
|
117
|
-
session,
|
|
118
|
-
traceId
|
|
119
|
-
)
|
|
120
|
-
const indexKey = this.functionsIndexKey(funcName)
|
|
121
|
-
const now = Date.now()
|
|
122
|
-
|
|
123
|
-
await this.redis.zremrangebyscore(indexKey, '-inf', now)
|
|
124
|
-
const deploymentIds = await this.redis.zrangebyscore(indexKey, now, '+inf')
|
|
125
|
-
|
|
126
|
-
let endpoint: string | null = null
|
|
127
|
-
for (const deploymentId of deploymentIds) {
|
|
128
|
-
endpoint = await this.redis.hget(
|
|
129
|
-
this.deploymentKey(deploymentId),
|
|
130
|
-
'endpoint'
|
|
131
|
-
)
|
|
132
|
-
if (endpoint) break
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (!endpoint) {
|
|
136
|
-
throw new Error(`No deployment found for function '${funcName}'`)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const url = `${endpoint}/remote/rpc/${encodeURIComponent(funcName)}`
|
|
140
|
-
const response = await fetch(url, {
|
|
141
|
-
method: 'POST',
|
|
142
|
-
headers,
|
|
143
|
-
body: JSON.stringify({ data }),
|
|
144
|
-
})
|
|
145
|
-
if (!response.ok) {
|
|
146
|
-
throw new Error(
|
|
147
|
-
`Remote RPC call to '${funcName}' failed: ${response.status}`
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
return response.json()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private async sendHeartbeat(): Promise<void> {
|
|
154
|
-
if (!this.deploymentConfig) return
|
|
155
|
-
|
|
156
|
-
const { deploymentId, functions } = this.deploymentConfig
|
|
157
|
-
const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
|
|
158
|
-
const expiryScore = Date.now() + this.heartbeatTtl
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
const pipeline = this.redis.pipeline()
|
|
162
|
-
pipeline.expire(this.deploymentKey(deploymentId), ttlSeconds)
|
|
163
|
-
for (const fn of functions!) {
|
|
164
|
-
pipeline.zadd(this.functionsIndexKey(fn), expiryScore, deploymentId)
|
|
165
|
-
}
|
|
166
|
-
await pipeline.exec()
|
|
167
|
-
} catch {
|
|
168
|
-
// Heartbeat failed, will retry on next interval
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { EventHubStore } from '@pikku/core/channel'
|
|
2
|
-
import { Redis, type RedisOptions } from 'ioredis'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Redis-based implementation of EventHubStore
|
|
6
|
-
*
|
|
7
|
-
* Manages pub/sub topic subscriptions for channels in Redis.
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```typescript
|
|
11
|
-
* const redis = new Redis({ host: 'localhost', port: 6379 })
|
|
12
|
-
* const store = new RedisEventHubStore(redis, 'myapp')
|
|
13
|
-
* await store.subscribe('user.123', channelId)
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
export class RedisEventHubStore implements EventHubStore {
|
|
17
|
-
private redis: Redis
|
|
18
|
-
private keyPrefix: string
|
|
19
|
-
private ownsConnection: boolean
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @param connectionOrConfig - ioredis Redis instance or RedisOptions config
|
|
23
|
-
* @param keyPrefix - Redis key prefix (default: 'pikku')
|
|
24
|
-
*/
|
|
25
|
-
constructor(
|
|
26
|
-
connectionOrConfig: Redis | RedisOptions | string,
|
|
27
|
-
keyPrefix = 'pikku'
|
|
28
|
-
) {
|
|
29
|
-
this.keyPrefix = keyPrefix
|
|
30
|
-
|
|
31
|
-
// Check if it's a Redis instance or config options
|
|
32
|
-
if (connectionOrConfig instanceof Redis) {
|
|
33
|
-
this.redis = connectionOrConfig
|
|
34
|
-
this.ownsConnection = false
|
|
35
|
-
} else if (typeof connectionOrConfig === 'string') {
|
|
36
|
-
// It's a connection string
|
|
37
|
-
this.redis = new Redis(connectionOrConfig)
|
|
38
|
-
this.ownsConnection = true
|
|
39
|
-
} else {
|
|
40
|
-
// It's a config object
|
|
41
|
-
this.redis = new Redis(connectionOrConfig)
|
|
42
|
-
this.ownsConnection = true
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Initialize the store (no-op for Redis, always ready)
|
|
48
|
-
*/
|
|
49
|
-
public async init(): Promise<void> {
|
|
50
|
-
// Redis doesn't require schema initialization
|
|
51
|
-
await this.redis.ping()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
private topicKey(topic: string): string {
|
|
55
|
-
return `${this.keyPrefix}:topic:${topic}`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private channelSubsKey(channelId: string): string {
|
|
59
|
-
return `${this.keyPrefix}:subs:${channelId}`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
public async getChannelIdsForTopic(topic: string): Promise<string[]> {
|
|
63
|
-
const key = this.topicKey(topic)
|
|
64
|
-
return await this.redis.smembers(key)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
public async subscribe(topic: string, channelId: string): Promise<boolean> {
|
|
68
|
-
const topicKey = this.topicKey(topic)
|
|
69
|
-
const subsKey = this.channelSubsKey(channelId)
|
|
70
|
-
|
|
71
|
-
// Add channelId to topic set and topic to channel's subscription set
|
|
72
|
-
const pipeline = this.redis.pipeline()
|
|
73
|
-
pipeline.sadd(topicKey, channelId)
|
|
74
|
-
pipeline.sadd(subsKey, topic)
|
|
75
|
-
|
|
76
|
-
await pipeline.exec()
|
|
77
|
-
|
|
78
|
-
return true
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
|
|
82
|
-
const topicKey = this.topicKey(topic)
|
|
83
|
-
const subsKey = this.channelSubsKey(channelId)
|
|
84
|
-
|
|
85
|
-
// Remove channelId from topic set and topic from channel's subscription set
|
|
86
|
-
const pipeline = this.redis.pipeline()
|
|
87
|
-
pipeline.srem(topicKey, channelId)
|
|
88
|
-
pipeline.srem(subsKey, topic)
|
|
89
|
-
|
|
90
|
-
const results = await pipeline.exec()
|
|
91
|
-
|
|
92
|
-
// Check if the channel was actually in the topic set
|
|
93
|
-
return (results![0]![1] as number) > 0
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Close the Redis connection if owned by this store
|
|
98
|
-
*/
|
|
99
|
-
public async close(): Promise<void> {
|
|
100
|
-
if (this.ownsConnection) {
|
|
101
|
-
await this.redis.quit()
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|