@gravito/launchpad 1.2.1 → 1.3.1

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.
@@ -0,0 +1,143 @@
1
+ import type { Mission } from '../Domain/Mission'
2
+ import type { Rocket } from '../Domain/Rocket'
3
+
4
+ interface QueuedMission {
5
+ mission: Mission
6
+ resolve: (rocket: Rocket) => void
7
+ reject: (error: Error) => void
8
+ enqueuedAt: number
9
+ timeoutId: ReturnType<typeof setTimeout>
10
+ }
11
+
12
+ /**
13
+ * MissionQueue manages pending mission requests when pool is exhausted.
14
+ *
15
+ * Implements FIFO queue with timeout handling and backpressure support.
16
+ *
17
+ * @public
18
+ * @since 1.3.0
19
+ */
20
+ export class MissionQueue {
21
+ private queue: QueuedMission[] = []
22
+ private readonly maxSize: number
23
+ private readonly timeoutMs: number
24
+
25
+ constructor(maxSize = 50, timeoutMs = 30000) {
26
+ this.maxSize = maxSize
27
+ this.timeoutMs = timeoutMs
28
+ }
29
+
30
+ get length(): number {
31
+ return this.queue.length
32
+ }
33
+
34
+ get isFull(): boolean {
35
+ return this.queue.length >= this.maxSize
36
+ }
37
+
38
+ /**
39
+ * 將任務加入隊列,返回 Promise 等待分配
40
+ */
41
+ enqueue(mission: Mission): Promise<Rocket> {
42
+ if (this.isFull) {
43
+ return Promise.reject(
44
+ new PoolExhaustedException(`任務隊列已滿(${this.maxSize}),無法接受新任務`)
45
+ )
46
+ }
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const timeoutId = setTimeout(() => {
50
+ this.removeFromQueue(mission.id)
51
+ reject(new QueueTimeoutException(`任務 ${mission.id} 等待超時`))
52
+ }, this.timeoutMs)
53
+
54
+ this.queue.push({
55
+ mission,
56
+ resolve,
57
+ reject,
58
+ enqueuedAt: Date.now(),
59
+ timeoutId,
60
+ })
61
+
62
+ console.log(
63
+ `[MissionQueue] 任務 ${mission.id} 已加入隊列,當前隊列長度: ${this.queue.length}`
64
+ )
65
+ })
66
+ }
67
+
68
+ /**
69
+ * 取出下一個等待的任務
70
+ */
71
+ dequeue(): QueuedMission | null {
72
+ const item = this.queue.shift()
73
+ if (item) {
74
+ clearTimeout(item.timeoutId)
75
+ }
76
+ return item ?? null
77
+ }
78
+
79
+ /**
80
+ * 從隊列中移除指定任務
81
+ */
82
+ private removeFromQueue(missionId: string): void {
83
+ const index = this.queue.findIndex((q) => q.mission.id === missionId)
84
+ if (index >= 0) {
85
+ const [removed] = this.queue.splice(index, 1)
86
+ clearTimeout(removed.timeoutId)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 清空隊列並拒絕所有等待的任務
92
+ */
93
+ clear(reason: string): void {
94
+ for (const item of this.queue) {
95
+ clearTimeout(item.timeoutId)
96
+ item.reject(new Error(reason))
97
+ }
98
+ this.queue = []
99
+ }
100
+
101
+ /**
102
+ * 取得隊列統計資訊
103
+ */
104
+ getStats(): {
105
+ length: number
106
+ maxSize: number
107
+ oldestWaitMs: number | null
108
+ } {
109
+ const oldestWaitMs = this.queue.length > 0 ? Date.now() - this.queue[0].enqueuedAt : null
110
+
111
+ return {
112
+ length: this.queue.length,
113
+ maxSize: this.maxSize,
114
+ oldestWaitMs,
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Pool 資源耗盡異常
121
+ *
122
+ * @public
123
+ * @since 1.3.0
124
+ */
125
+ export class PoolExhaustedException extends Error {
126
+ constructor(message: string) {
127
+ super(message)
128
+ this.name = 'PoolExhaustedException'
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 隊列等待超時異常
134
+ *
135
+ * @public
136
+ * @since 1.3.0
137
+ */
138
+ export class QueueTimeoutException extends Error {
139
+ constructor(message: string) {
140
+ super(message)
141
+ this.name = 'QueueTimeoutException'
142
+ }
143
+ }
@@ -1,6 +1,14 @@
1
- import type { IDockerAdapter, IRocketRepository, IRouterAdapter } from '../Domain/Interfaces'
1
+ import type {
2
+ IDockerAdapter,
3
+ IRocketRepository,
4
+ IRouterAdapter,
5
+ PoolConfig,
6
+ } from '../Domain/Interfaces'
7
+ import { DEFAULT_POOL_CONFIG } from '../Domain/Interfaces'
2
8
  import type { Mission } from '../Domain/Mission'
3
9
  import { Rocket } from '../Domain/Rocket'
10
+ import { RocketStatus } from '../Domain/RocketStatus'
11
+ import { MissionQueue, PoolExhaustedException } from './MissionQueue'
4
12
  import type { RefurbishUnit } from './RefurbishUnit'
5
13
 
6
14
  /**
@@ -13,51 +21,157 @@ import type { RefurbishUnit } from './RefurbishUnit'
13
21
  * @since 3.0.0
14
22
  */
15
23
  export class PoolManager {
24
+ private readonly config: PoolConfig
25
+ private readonly missionQueue: MissionQueue
26
+ private dynamicRocketCount = 0
27
+
16
28
  constructor(
17
29
  private dockerAdapter: IDockerAdapter,
18
30
  private rocketRepository: IRocketRepository,
19
31
  private refurbishUnit?: RefurbishUnit,
20
- private router?: IRouterAdapter
21
- ) {}
32
+ private router?: IRouterAdapter,
33
+ config?: Partial<PoolConfig>
34
+ ) {
35
+ this.config = { ...DEFAULT_POOL_CONFIG, ...config }
36
+ this.missionQueue = new MissionQueue(this.config.maxQueueSize, this.config.queueTimeoutMs)
37
+ }
38
+
39
+ /**
40
+ * 取得當前 Pool 狀態
41
+ */
42
+ async getPoolStatus(): Promise<{
43
+ total: number
44
+ idle: number
45
+ orbiting: number
46
+ refurbishing: number
47
+ decommissioned: number
48
+ dynamicCount: number
49
+ maxRockets: number
50
+ queueLength: number
51
+ }> {
52
+ const rockets = await this.rocketRepository.findAll()
53
+ const statusCounts = {
54
+ total: rockets.length,
55
+ idle: 0,
56
+ orbiting: 0,
57
+ refurbishing: 0,
58
+ decommissioned: 0,
59
+ }
60
+
61
+ for (const rocket of rockets) {
62
+ switch (rocket.status) {
63
+ case RocketStatus.IDLE:
64
+ statusCounts.idle++
65
+ break
66
+ case RocketStatus.PREPARING:
67
+ case RocketStatus.ORBITING:
68
+ statusCounts.orbiting++
69
+ break
70
+ case RocketStatus.REFURBISHING:
71
+ statusCounts.refurbishing++
72
+ break
73
+ case RocketStatus.DECOMMISSIONED:
74
+ statusCounts.decommissioned++
75
+ break
76
+ }
77
+ }
78
+
79
+ return {
80
+ ...statusCounts,
81
+ dynamicCount: this.dynamicRocketCount,
82
+ maxRockets: this.config.maxRockets,
83
+ queueLength: this.missionQueue.length,
84
+ }
85
+ }
22
86
 
23
87
  /**
24
88
  * 初始化發射場:預先準備指定數量的火箭
25
89
  */
26
- async warmup(count: number): Promise<void> {
90
+ async warmup(count?: number): Promise<void> {
91
+ const targetCount = count ?? this.config.warmupCount
27
92
  const currentRockets = await this.rocketRepository.findAll()
28
- const needed = count - currentRockets.length
93
+ const activeRockets = currentRockets.filter((r) => r.status !== RocketStatus.DECOMMISSIONED)
94
+ const needed = Math.min(
95
+ targetCount - activeRockets.length,
96
+ this.config.maxRockets - activeRockets.length
97
+ )
29
98
 
30
99
  if (needed <= 0) {
100
+ console.log(`[PoolManager] 無需熱機,當前已有 ${activeRockets.length} 架火箭`)
31
101
  return
32
102
  }
33
103
 
34
- console.log(`[LaunchPad] 正在熱機,準備發射 ${needed} 架新火箭...`)
104
+ console.log(`[PoolManager] 正在熱機,準備發射 ${needed} 架新火箭...`)
35
105
 
36
106
  for (let i = 0; i < needed; i++) {
37
107
  const containerId = await this.dockerAdapter.createBaseContainer()
38
- const rocketId = `rocket-${Math.random().toString(36).substring(2, 9)}`
108
+ const rocketId = `rocket-${crypto.randomUUID()}`
39
109
  const rocket = new Rocket(rocketId, containerId)
40
110
  await this.rocketRepository.save(rocket)
41
111
  }
112
+
113
+ console.log(`[PoolManager] 熱機完成,Pool 當前共 ${activeRockets.length + needed} 架火箭`)
42
114
  }
43
115
 
44
116
  /**
45
117
  * 獲取一架可用的火箭並分配任務
118
+ *
119
+ * @throws {PoolExhaustedException} 當 Pool 耗盡且無法處理請求時
46
120
  */
47
121
  async assignMission(mission: Mission): Promise<Rocket> {
122
+ // 1. 嘗試從池中獲取閒置火箭
48
123
  let rocket = await this.rocketRepository.findIdle()
49
124
 
50
- // 如果池子空了,動態建立一架(實務上應該報警或等待,這裡先簡化為動態建立)
51
- if (!rocket) {
52
- console.log(`[LaunchPad] 資源吃緊,正在緊急呼叫後援火箭...`)
53
- const containerId = await this.dockerAdapter.createBaseContainer()
54
- rocket = new Rocket(`rocket-dynamic-${Date.now()}`, containerId)
125
+ if (rocket) {
126
+ rocket.assignMission(mission)
127
+ await this.rocketRepository.save(rocket)
128
+ return rocket
55
129
  }
56
130
 
57
- rocket.assignMission(mission)
58
- await this.rocketRepository.save(rocket)
131
+ // 2. 池子空了,根據策略處理
132
+ const poolStatus = await this.getPoolStatus()
133
+ const activeCount = poolStatus.total - poolStatus.decommissioned
134
+
135
+ console.log(
136
+ `[PoolManager] 無可用火箭,當前狀態: ` +
137
+ `總數=${activeCount}/${this.config.maxRockets}, ` +
138
+ `隊列=${this.missionQueue.length}/${this.config.maxQueueSize}`
139
+ )
59
140
 
60
- return rocket
141
+ switch (this.config.exhaustionStrategy) {
142
+ case 'reject':
143
+ throw new PoolExhaustedException(`Rocket Pool 已耗盡,無法處理任務 ${mission.id}`)
144
+
145
+ case 'dynamic': {
146
+ // 嘗試動態建立(有限制)
147
+ if (
148
+ activeCount < this.config.maxRockets &&
149
+ this.dynamicRocketCount < (this.config.dynamicLimit ?? 5)
150
+ ) {
151
+ console.log(`[PoolManager] 動態建立後援火箭...`)
152
+ const containerId = await this.dockerAdapter.createBaseContainer()
153
+ rocket = new Rocket(`rocket-dynamic-${Date.now()}`, containerId)
154
+ this.dynamicRocketCount++
155
+ rocket.assignMission(mission)
156
+ await this.rocketRepository.save(rocket)
157
+ return rocket
158
+ }
159
+ // 超過動態限制,使用隊列策略
160
+ if (this.missionQueue.isFull) {
161
+ throw new PoolExhaustedException(`Rocket Pool 與隊列均已滿,無法處理任務 ${mission.id}`)
162
+ }
163
+ console.log(`[PoolManager] 任務 ${mission.id} 進入等待隊列`)
164
+ return this.missionQueue.enqueue(mission)
165
+ }
166
+
167
+ case 'queue':
168
+ default:
169
+ if (this.missionQueue.isFull) {
170
+ throw new PoolExhaustedException(`Rocket Pool 與隊列均已滿,無法處理任務 ${mission.id}`)
171
+ }
172
+ console.log(`[PoolManager] 任務 ${mission.id} 進入等待隊列`)
173
+ return this.missionQueue.enqueue(mission)
174
+ }
61
175
  }
62
176
 
63
177
  /**
@@ -68,11 +182,11 @@ export class PoolManager {
68
182
  const rocket = allRockets.find((r) => r.currentMission?.id === missionId)
69
183
 
70
184
  if (!rocket) {
71
- console.warn(`[LaunchPad] 找不到屬於任務 ${missionId} 的火箭`)
185
+ console.warn(`[PoolManager] 找不到屬於任務 ${missionId} 的火箭`)
72
186
  return
73
187
  }
74
188
 
75
- // [New] 註銷域名映射
189
+ // 註銷域名映射
76
190
  if (this.router && rocket.assignedDomain) {
77
191
  this.router.unregister(rocket.assignedDomain)
78
192
  }
@@ -80,11 +194,49 @@ export class PoolManager {
80
194
  if (this.refurbishUnit) {
81
195
  await this.refurbishUnit.refurbish(rocket)
82
196
  } else {
83
- // 回退邏輯:如果沒有提供回收單元,則直接標記完成
84
197
  rocket.splashDown()
85
198
  rocket.finishRefurbishment()
86
199
  }
87
200
 
88
201
  await this.rocketRepository.save(rocket)
202
+
203
+ // 火箭回歸後,檢查是否有等待的任務
204
+ await this.processQueue()
205
+ }
206
+
207
+ /**
208
+ * 處理等待隊列中的任務
209
+ */
210
+ private async processQueue(): Promise<void> {
211
+ const queuedItem = this.missionQueue.dequeue()
212
+ if (!queuedItem) {
213
+ return
214
+ }
215
+
216
+ try {
217
+ const rocket = await this.rocketRepository.findIdle()
218
+ if (rocket) {
219
+ rocket.assignMission(queuedItem.mission)
220
+ await this.rocketRepository.save(rocket)
221
+ queuedItem.resolve(rocket)
222
+ console.log(`[PoolManager] 隊列任務 ${queuedItem.mission.id} 已分配火箭 ${rocket.id}`)
223
+ } else {
224
+ // 重新入隊(不太可能發生,但作為防護)
225
+ console.warn(`[PoolManager] 處理隊列時仍無可用火箭,任務重新入隊`)
226
+ this.missionQueue
227
+ .enqueue(queuedItem.mission)
228
+ .then(queuedItem.resolve)
229
+ .catch(queuedItem.reject)
230
+ }
231
+ } catch (error) {
232
+ queuedItem.reject(error as Error)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 取得隊列統計資訊
238
+ */
239
+ getQueueStats() {
240
+ return this.missionQueue.getStats()
89
241
  }
90
242
  }
@@ -1,4 +1,5 @@
1
- import type { IDockerAdapter } from '../Domain/Interfaces'
1
+ import type { IDockerAdapter, RefurbishConfig } from '../Domain/Interfaces'
2
+ import { DEFAULT_REFURBISH_CONFIG } from '../Domain/Interfaces'
2
3
  import type { Rocket } from '../Domain/Rocket'
3
4
 
4
5
  /**
@@ -12,36 +13,47 @@ import type { Rocket } from '../Domain/Rocket'
12
13
  * @since 3.0.0
13
14
  */
14
15
  export class RefurbishUnit {
15
- constructor(private docker: IDockerAdapter) {}
16
+ private readonly config: RefurbishConfig
17
+
18
+ constructor(
19
+ private docker: IDockerAdapter,
20
+ config?: Partial<RefurbishConfig>
21
+ ) {
22
+ this.config = { ...DEFAULT_REFURBISH_CONFIG, ...config }
23
+ }
16
24
 
17
25
  /**
18
26
  * 執行火箭翻新邏輯
19
27
  */
20
28
  async refurbish(rocket: Rocket): Promise<void> {
21
- console.log(`[RefurbishUnit] 正在翻新火箭: ${rocket.id} (容器: ${rocket.containerId})`)
29
+ console.log(
30
+ `[RefurbishUnit] 正在翻新火箭: ${rocket.id} ` +
31
+ `(策略: ${this.config.strategy}, 容器: ${rocket.containerId})`
32
+ )
22
33
 
23
34
  // 1. 進入狀態機的回收階段
24
35
  rocket.splashDown()
25
36
 
26
37
  try {
27
38
  // 2. 執行深度清理指令
28
- // - 刪除 /app 目錄 (注入的代碼)
29
- // - 殺掉所有除了 tail 以外的 bun 進程
30
- // - 清理暫存檔
31
- const cleanupCommands = ['sh', '-c', 'rm -rf /app/* && pkill -f bun || true && rm -rf /tmp/*']
39
+ const commands = this.config.cleanupCommands ?? []
40
+ const fullCommand = commands.join(' && ')
32
41
 
33
- const result = await this.docker.executeCommand(rocket.containerId, cleanupCommands)
42
+ const result = await this.docker.executeCommand(rocket.containerId, ['sh', '-c', fullCommand])
34
43
 
35
44
  if (result.exitCode !== 0) {
36
45
  console.error(`[RefurbishUnit] 清理失敗: ${result.stderr}`)
37
- // 這裡可以選擇將火箭標記為 DECOMMISSIONED
38
- rocket.decommission()
39
- return
46
+
47
+ if (this.config.failureAction === 'decommission') {
48
+ rocket.decommission()
49
+ return
50
+ }
51
+ // retry 策略在後續版本實現
40
52
  }
41
53
 
42
54
  // 3. 翻新完成,回歸池中
43
55
  rocket.finishRefurbishment()
44
- console.log(`[RefurbishUnit] 火箭 ${rocket.id} 翻新完成,已進入 IDLE 狀態。`)
56
+ console.log(`[RefurbishUnit] 火箭 ${rocket.id} 翻新完成,已進入 IDLE 狀態`)
45
57
  } catch (error) {
46
58
  console.error(`[RefurbishUnit] 回收過程發生異常:`, error)
47
59
  rocket.decommission()
@@ -50,3 +50,49 @@ export class RefurbishmentCompleted extends DomainEvent {
50
50
  super()
51
51
  }
52
52
  }
53
+
54
+ /**
55
+ * Event emitted when the rocket pool is exhausted.
56
+ *
57
+ * @public
58
+ * @since 1.3.0
59
+ */
60
+ export class PoolExhausted extends DomainEvent {
61
+ constructor(
62
+ public readonly totalRockets: number,
63
+ public readonly maxRockets: number,
64
+ public readonly queueLength: number
65
+ ) {
66
+ super()
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Event emitted when a mission is queued due to pool exhaustion.
72
+ *
73
+ * @public
74
+ * @since 1.3.0
75
+ */
76
+ export class MissionQueued extends DomainEvent {
77
+ constructor(
78
+ public readonly missionId: string,
79
+ public readonly queuePosition: number
80
+ ) {
81
+ super()
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Event emitted when a queued mission times out.
87
+ *
88
+ * @public
89
+ * @since 1.3.0
90
+ */
91
+ export class MissionQueueTimeout extends DomainEvent {
92
+ constructor(
93
+ public readonly missionId: string,
94
+ public readonly waitTimeMs: number
95
+ ) {
96
+ super()
97
+ }
98
+ }
@@ -80,3 +80,85 @@ export interface IRocketRepository {
80
80
  findAll(): Promise<Rocket[]>
81
81
  delete(id: string): Promise<void>
82
82
  }
83
+
84
+ /**
85
+ * Pool 管理配置
86
+ *
87
+ * @public
88
+ * @since 1.3.0
89
+ */
90
+ export interface PoolConfig {
91
+ /** 最大 Rocket 數量(硬限制) */
92
+ maxRockets: number
93
+ /** 初始預熱數量 */
94
+ warmupCount: number
95
+ /** 最大等待隊列長度 */
96
+ maxQueueSize: number
97
+ /** 隊列超時時間(毫秒) */
98
+ queueTimeoutMs: number
99
+ /** 資源不足時的策略:'queue' | 'reject' | 'dynamic' */
100
+ exhaustionStrategy: 'queue' | 'reject' | 'dynamic'
101
+ /** 動態建立的限制(僅當 exhaustionStrategy 為 'dynamic' 時) */
102
+ dynamicLimit?: number
103
+ }
104
+
105
+ /**
106
+ * Pool 管理預設配置
107
+ *
108
+ * @public
109
+ * @since 1.3.0
110
+ */
111
+ export const DEFAULT_POOL_CONFIG: PoolConfig = {
112
+ maxRockets: 20,
113
+ warmupCount: 3,
114
+ maxQueueSize: 50,
115
+ queueTimeoutMs: 30000,
116
+ exhaustionStrategy: 'queue',
117
+ dynamicLimit: 5,
118
+ }
119
+
120
+ /**
121
+ * 容器清理策略配置
122
+ *
123
+ * @public
124
+ * @since 1.3.0
125
+ */
126
+ export interface RefurbishConfig {
127
+ /** 清理策略:'deep-clean' | 'destroy-recreate' */
128
+ strategy: 'deep-clean' | 'destroy-recreate'
129
+ /** 深度清理命令(僅 deep-clean 策略) */
130
+ cleanupCommands?: string[]
131
+ /** 清理超時(毫秒) */
132
+ cleanupTimeoutMs: number
133
+ /** 清理失敗時的處理:'decommission' | 'retry' */
134
+ failureAction: 'decommission' | 'retry'
135
+ /** 最大重試次數(僅 retry 模式) */
136
+ maxRetries: number
137
+ }
138
+
139
+ /**
140
+ * 容器清理預設配置
141
+ *
142
+ * @public
143
+ * @since 1.3.0
144
+ */
145
+ export const DEFAULT_REFURBISH_CONFIG: RefurbishConfig = {
146
+ strategy: 'deep-clean',
147
+ cleanupCommands: [
148
+ // 1. 殺掉所有使用者進程
149
+ 'pkill -9 -u bun || true',
150
+ 'pkill -9 -u root -f "bun|node" || true',
151
+ // 2. 清理應用目錄
152
+ 'rm -rf /app/* /app/.* 2>/dev/null || true',
153
+ // 3. 清理暫存檔案
154
+ 'rm -rf /tmp/* /var/tmp/* 2>/dev/null || true',
155
+ // 4. 清理 bun 暫存
156
+ 'rm -rf /root/.bun/install/cache/tmp/* 2>/dev/null || true',
157
+ 'rm -rf /home/bun/.bun/install/cache/tmp/* 2>/dev/null || true',
158
+ // 5. 清理日誌
159
+ 'rm -rf /var/log/*.log 2>/dev/null || true',
160
+ ],
161
+ cleanupTimeoutMs: 30000,
162
+ failureAction: 'decommission',
163
+ maxRetries: 2,
164
+ }
@@ -106,6 +106,24 @@ export class Rocket extends AggregateRoot<string> {
106
106
  this._status = RocketStatus.DECOMMISSIONED
107
107
  }
108
108
 
109
+ /**
110
+ * 替換底層容器(僅在 REFURBISHING 狀態時允許)
111
+ * 用於 destroy-recreate 策略
112
+ *
113
+ * @param newContainerId - 新容器 ID
114
+ * @throws {Error} 當火箭不在 REFURBISHING 狀態時
115
+ *
116
+ * @public
117
+ * @since 1.3.0
118
+ */
119
+ public replaceContainer(newContainerId: string): void {
120
+ if (this._status !== RocketStatus.REFURBISHING) {
121
+ throw new Error(`無法替換容器:火箭 ${this.id} 不在 REFURBISHING 狀態`)
122
+ }
123
+ this._containerId = newContainerId
124
+ this._assignedDomain = null // 清除域名映射
125
+ }
126
+
109
127
  public toJSON() {
110
128
  return {
111
129
  id: this.id,