pool.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import {randomUUID} from 'node:crypto'
  2. import {DatabaseSync} from 'node:sqlite'
  3. import type {ComputerProvider,ComputerSpecification} from './container.js'
  4. import {ComputerError} from './container.js'
  5. export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
  6. type Status='free'|'preparing'|'active'|'stopping'|'uncertain'
  7. interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
  8. interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
  9. /** Durable exclusive use. A generation is invalidated before cancellation; no
  10. * successor is admitted until the old private environment is confirmed stopped. */
  11. export class ComputerPool {
  12. private active=new Map<string,Active>()
  13. private changing=new Map<string,Promise<unknown>>()
  14. private ready=false
  15. private closing=false
  16. private maintenance=false
  17. holdMaintenance(){this.maintenance=true;let released=false;return ()=>{if(!released){released=true;this.maintenance=false}}}
  18. beginMaintenance(){
  19. if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'))throw new ComputerError('computer_busy','请先停止电脑任务并交还控制权,再管理镜像')
  20. return this.holdMaintenance()
  21. }
  22. constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
  23. if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
  24. db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
  25. }
  26. private rows():Entry[]{return (this.db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))}
  27. private get(id:string):Entry|undefined {const row=this.db.prepare('SELECT value FROM computer_environments WHERE id=?').get(id) as {value:string}|undefined;return row?JSON.parse(row.value):undefined}
  28. private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify(entry))}
  29. private serial<T>(id:string,action:()=>Promise<T>):Promise<T>{
  30. const pending=(this.changing.get(id)??Promise.resolve()).catch(()=>{}).then(action)
  31. this.changing.set(id,pending);void pending.finally(()=>{if(this.changing.get(id)===pending)this.changing.delete(id)}).catch(()=>{})
  32. return pending
  33. }
  34. /** Must run under the Runner's OS instance lock before accepting commands. */
  35. async recover():Promise<void>{
  36. if(this.ready)throw new ComputerError('computer_pool_active','运行中的资源池不能重新执行启动恢复')
  37. for(const entry of this.rows())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
  38. this.ready=true
  39. }
  40. status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderId:row.lease?.holderId,expiresAt:row.expiresAt}))}
  41. private require(lease:ComputerLease,activeOnly=true):Entry {
  42. const entry=this.get(lease.environmentId)
  43. if(!entry||entry.spec.ownerKey!==lease.ownerKey||entry.lease?.id!==lease.id||entry.lease.holderId!==lease.holderId||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
  44. return entry
  45. }
  46. private assert(lease:ComputerLease){
  47. const entry=this.require(lease),active=this.active.get(lease.id)
  48. if(this.closing||!active||active.controller.signal.aborted||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
  49. active.authorize()
  50. }
  51. async acquire(spec:ComputerSpecification,holderId:string,authorize:()=>void,signal?:AbortSignal):Promise<ComputerLease>{
  52. spec=this.provider.validateSpecification(spec)
  53. if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
  54. return this.serial(spec.id,async()=>{
  55. authorize()
  56. if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
  57. if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
  58. let entry=this.get(spec.id)
  59. if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
  60. if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
  61. if(entry&&entry.status!=='free')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
  62. const rows=this.rows()
  63. if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free').length>=this.limits.concurrent)throw new ComputerError('computer_quota','电脑资源已达上限,请等待其他任务释放')
  64. const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
  65. entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
  66. // This reservation is synchronous, before any provider await or next acquire.
  67. this.save(entry)
  68. const abort=()=>{const current=this.get(spec.id);if(current?.lease?.id===lease.id)this.invalidate(current)}
  69. const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)};this.active.set(lease.id,active)
  70. signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
  71. const guard=()=>{const current=this.require(lease,false);if(this.closing||active.controller.signal.aborted||current.status!=='preparing'||current.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑启动授权已失效');authorize()}
  72. try{
  73. await this.provider.ensure(spec,guard);guard()
  74. entry.status='active';this.save(entry);return lease
  75. }catch(error){await this.stopEntry(entry).catch(()=>{});throw error}
  76. })
  77. }
  78. async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
  79. spec=this.provider.validateSpecification(spec)
  80. return this.serial(spec.id,async()=>{
  81. authorize();if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
  82. if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
  83. const entry=this.get(spec.id);if(!entry)return
  84. if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  85. if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
  86. if(entry.status!=='free')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
  87. entry.status='stopping';entry.generation++;this.save(entry)
  88. try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
  89. catch(error){entry.status='uncertain';this.save(entry);throw error}
  90. })
  91. }
  92. async transfer(lease:ComputerLease,holderId:string,authorize:()=>void):Promise<ComputerLease>{
  93. return this.serial(lease.environmentId,async()=>{
  94. this.assert(lease);authorize()
  95. const active=this.active.get(lease.id)!
  96. if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有在途操作,不能移交控制')
  97. const entry=this.require(lease),state=await this.provider.inspect(entry.spec)
  98. this.assert(lease);authorize()
  99. if(active.operations.size||!state?.running)throw new ComputerError('computer_busy','电脑尚未就绪,不能移交控制')
  100. const next:ComputerLease={...lease,id:randomUUID(),holderId,generation:entry.generation+1}
  101. active.detach();active.controller.abort();this.active.delete(lease.id)
  102. this.active.set(next.id,{lease:next,authorize,controller:new AbortController(),operations:new Set(),detach:()=>{}})
  103. entry.lease=next;entry.generation=next.generation;entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)
  104. return next
  105. })
  106. }
  107. authorize(lease:ComputerLease){this.assert(lease)}
  108. renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
  109. use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
  110. this.assert(lease)
  111. const active=this.active.get(lease.id)!
  112. const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease)})}).then(value=>{this.assert(lease);return value})
  113. active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
  114. return pending
  115. }
  116. release(lease:ComputerLease):Promise<void>{
  117. const existing=this.get(lease.environmentId)
  118. const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
  119. this.invalidate(entry)
  120. return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
  121. }
  122. definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
  123. async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
  124. return this.serial(id,async()=>{
  125. const entry=this.get(id);if(!entry)return
  126. if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  127. if(entry.status!=='free')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
  128. await this.provider.remove(entry.spec)
  129. this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
  130. return entry.spec
  131. })
  132. }
  133. async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
  134. const entry=this.get(id)
  135. if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
  136. if(entry.lease?.holderId!==holderId)return
  137. this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry))
  138. }
  139. private invalidate(expected:Entry){
  140. const entry=this.get(expected.spec.id)
  141. if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
  142. if(entry.status!=='stopping'){entry.generation++;entry.status='stopping';entry.expiresAt=0;this.save(entry)}
  143. const active=entry.lease?this.active.get(entry.lease.id):undefined
  144. active?.controller.abort()
  145. return entry
  146. }
  147. private async stopEntry(expected:Entry):Promise<void>{
  148. const entry=this.invalidate(expected)
  149. if(!entry)return
  150. const active=entry.lease?this.active.get(entry.lease.id):undefined
  151. try{
  152. await this.provider.stop(entry.spec)
  153. // Retire the runtime identity as well as the lease generation. A delayed
  154. // exec targeting the old container ID can never enter its successor.
  155. await this.provider.remove(entry.spec)
  156. active?.detach();if(entry.lease)this.active.delete(entry.lease.id)
  157. entry.status='free';entry.lease=undefined;entry.expiresAt=0;this.save(entry)
  158. }catch(error){entry.status='uncertain';this.save(entry);throw error}
  159. }
  160. async expire():Promise<void>{
  161. for(const id of this.rows().map(entry=>entry.spec.id)){
  162. const entry=this.get(id)!
  163. if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
  164. this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
  165. }
  166. }
  167. }
  168. async close():Promise<void>{
  169. this.closing=true
  170. for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
  171. await Promise.allSettled(this.rows().filter(entry=>entry.status!=='free').map(entry=>this.serial(entry.spec.id,()=>this.stopEntry(this.get(entry.spec.id)!))))
  172. if(this.rows().some(entry=>entry.status!=='free'))throw new ComputerError('computer_stop_uncertain','尚未确认所有电脑环境已停止')
  173. }
  174. }