fix(indeehub): prove idle legacy worker termination before backup

This commit is contained in:
archipelago
2026-10-07 21:18:36 -04:00
parent 470d4f3944
commit a30c12193d
3 changed files with 158 additions and 11 deletions
@@ -293,3 +293,35 @@ or widening manager write access. The controller uses that fixed command now;
25 pure controller tests pass, including refusal before fence creation on dump 25 pure controller tests pass, including refusal before fence creation on dump
failure. A new helper hash requires a matching backend rebuild. Candidate-helper failure. A new helper hash requires a matching backend rebuild. Candidate-helper
qualification remains separate from actual backend transaction acceptance. qualification remains separate from actual backend transaction acceptance.
### Legacy worker termination qualification — 2026-10-07
The held disposable-VM operation `4320fe90-8ab5-4496-a4e7-cf11ca0fd376`
exposed the original worker's Node-as-PID1 SIGTERM behavior. A no-network,
no-volume probe from its exact recovery image exited 137 without init and 143
with init. This is not graceful shutdown or proof of completed jobs.
The controller now requires an exact six-field nonnegative integer queue
observation; absent `active` can no longer imply idle. Its narrowly scoped legacy
worker path records **forced idle termination**, with `graceful=false` and
`completed_work_claim=false`, only after paused all-zero observations before and
after, closed ingress/frontend, exact operation/original/recovery image and known
command, unchanged saved unit, original stop intent and died event, and proof the
process is dead. Other writers' 137 exits remain refused. All 31 pure controller
regressions passed, including missing/nonzero counts, reopened queue, changed
identity, command, unit and process state. Actual worker-only classification passed
under the hardened service → user scope with the lifecycle lock held.
The same fixture's API has npm as PID1 and one direct `node dist/main` child.
After fresh empty-business-state proof and durable stop intent, an exact-command,
parent-validated SIGTERM to that child stopped the original container; npm emitted
exit 1. The existing clean-exit gate correctly retained the hold. API termination
classification is still under review; no generic exit-1 allowance was added.
Frontend/worker are confirmed stopped; API is stopped but unconfirmed; storage
members remain running. Backup/fresh-restore, full RPC rollback/success and live
activation are **not passed**. Installed pinned helper remains unchanged; this is
private candidate-helper qualification only. Live Yaya remains unchanged.
Future worker image source now includes idempotent SIGTERM/SIGINT shutdown in app
commit `29627fc` with four passing Jest tests. Its image has not been built; the
previous frontend/API-only candidate catalog cannot cover that new worker image.
+47 -8
View File
@@ -7,6 +7,9 @@ import datetime, hashlib, json, os, pathlib, re, shutil, subprocess, sys, time,
NAMES = ('indeedhub','indeedhub-api','indeedhub-ffmpeg','indeedhub-minio','indeedhub-postgres','indeedhub-redis','indeedhub-relay') NAMES = ('indeedhub','indeedhub-api','indeedhub-ffmpeg','indeedhub-minio','indeedhub-postgres','indeedhub-redis','indeedhub-relay')
VOLUMES = ('indeedhub-minio-data','indeedhub-postgres-data','indeedhub-redis-data','indeedhub-relay-data') VOLUMES = ('indeedhub-minio-data','indeedhub-postgres-data','indeedhub-redis-data','indeedhub-relay-data')
DATA = pathlib.Path('/var/lib/archipelago') DATA = pathlib.Path('/var/lib/archipelago')
QUEUE_COUNTS = frozenset(('active','waiting','paused','delayed','failed','completed'))
def valid_queue_counts(counts):
return isinstance(counts,dict) and set(counts)==QUEUE_COUNTS and all(type(v) is int and v>=0 for v in counts.values())
QUEUE_SCRIPT = r'''const {Queue}=require('bullmq'); QUEUE_SCRIPT = r'''const {Queue}=require('bullmq');
(async()=>{const q=new Queue('transcode',{connection:{host:process.env.QUEUE_HOST,port:Number(process.env.QUEUE_PORT||6379),password:process.env.QUEUE_PASSWORD,maxRetriesPerRequest:1}}); (async()=>{const q=new Queue('transcode',{connection:{host:process.env.QUEUE_HOST,port:Number(process.env.QUEUE_PORT||6379),password:process.env.QUEUE_PASSWORD,maxRetriesPerRequest:1}});
try{const action=process.argv[1];if(action==='pause')await q.pause();else if(action==='resume')await q.resume();else if(action!=='status')throw Error('action'); try{const action=process.argv[1];if(action==='pause')await q.pause();else if(action==='resume')await q.resume();else if(action!=='status')throw Error('action');
@@ -193,8 +196,7 @@ class Controller:
self.record['ingress_closed']=True;self.save() self.record['ingress_closed']=True;self.save()
def queue(self, action): def queue(self, action):
result=json.loads(self.run(['podman','exec','indeedhub-api','node','-e',QUEUE_SCRIPT,action])) result=json.loads(self.run(['podman','exec','indeedhub-api','node','-e',QUEUE_SCRIPT,action]))
require(type(result.get('paused')) is bool and isinstance(result.get('counts'),dict),'Invalid queue observation') require(type(result.get('paused')) is bool and valid_queue_counts(result.get('counts')),'Incomplete or invalid queue observation')
for value in result['counts'].values():require(type(value) is int and value>=0,'Invalid job count')
return result return result
def pause_queue(self): def pause_queue(self):
if 'queue_was_paused' not in self.record: if 'queue_was_paused' not in self.record:
@@ -207,7 +209,7 @@ class Controller:
# completed: this path requires a fresh empty store behind closed ingress. # completed: this path requires a fresh empty store behind closed ingress.
require(self.record.get('stopped',{}).get('indeedhub',{}).get('confirmed'),'Frontend ingress must already be stopped') require(self.record.get('stopped',{}).get('indeedhub',{}).get('confirmed'),'Frontend ingress must already be stopped')
require(self.record.get('stopped',{}).get('indeedhub-ffmpeg',{}).get('confirmed'),'Transcode worker must already be stopped') require(self.record.get('stopped',{}).get('indeedhub-ffmpeg',{}).get('confirmed'),'Transcode worker must already be stopped')
require(self.record.get('queue_pause_confirmed') is True and self.record.get('last_queue_counts',{}).get('active')==0,'Worker queue is not proven idle') require(self.record.get('queue_pause_confirmed') is True and valid_queue_counts(self.record.get('last_queue_counts')) and self.record['last_queue_counts']['active']==0,'Worker queue is not proven idle')
tables=('projects','contents','payments','shareholders','subscriptions','library_items') tables=('projects','contents','payments','shareholders','subscriptions','library_items')
fields=','.join("'%s',(SELECT count(*) FROM public.%s)"%(name,name) for name in tables) fields=','.join("'%s',(SELECT count(*) FROM public.%s)"%(name,name) for name in tables)
sql="SELECT json_build_object("+fields+",'other_active_transactions',(SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid() AND state<>'idle'))" sql="SELECT json_build_object("+fields+",'other_active_transactions',(SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid() AND state<>'idle'))"
@@ -215,6 +217,41 @@ class Controller:
require(set(counts)==set(tables)|{'other_active_transactions'},'Legacy API business-state observation incomplete') require(set(counts)==set(tables)|{'other_active_transactions'},'Legacy API business-state observation incomplete')
require(all(type(value) is int and value==0 for value in counts.values()),'Legacy API has business work or active transactions; completion cannot be inferred') require(all(type(value) is int and value==0 for value in counts.values()),'Legacy API has business work or active transactions; completion cannot be inferred')
self.record['legacy_api_empty_state']=counts;self.save() self.record['legacy_api_empty_state']=counts;self.save()
def legacy_idle_worker_termination(self, member, properties):
# Compatibility only for the observed original Node-as-PID1 worker.
# A timeout/SIGKILL is never renamed graceful or completed work.
require(member['name']=='indeedhub-ffmpeg','Forced termination is not allowed for this writer')
self.holds();self.fence_matches()
before=self.record.get('last_queue_counts')
require(self.record.get('ingress_closed') is True and self.record.get('stopped',{}).get('indeedhub',{}).get('confirmed') is True,'Legacy worker ingress is not closed')
require(self.record.get('queue_pause_confirmed') is True and valid_queue_counts(before) and all(v==0 for v in before.values()),'Legacy worker queue was not proven completely empty')
stopped=self.record['stopped'][member['name']]
require(stopped.get('container_id')==member['container_id'] and type(stopped.get('intent_at')) in (int,float),'Legacy worker stop intent changed')
state=dict(line.split('=',1) for line in properties.splitlines() if '=' in line)
require(state.get('ActiveState') in ('inactive','failed') and state.get('SubState') in ('dead','failed') and state.get('ExecMainStatus')=='137','Legacy worker process death is not established')
runtime=json.loads((self.data/'update-transactions'/'supervised'/(self.operation+'.json')).read_text())
require(runtime.get('id')==self.operation and runtime.get('phase') in ('Editing','Restoring') and runtime.get('target_startup_began') is False,'Legacy worker operation changed')
records=[m for m in runtime['members'] if m['original']['name']==member['name']]
require(len(records)==1,'Ambiguous legacy worker recovery identity')
original=records[0]['original'];recovery=records[0]['recovery_image']
require(original['container_id']==member['container_id'] and original['image'].removeprefix('sha256:')==member['image_id'].removeprefix('sha256:') and original['config_sha256']==member['config_sha256'] and hashlib.sha256(original['body'].encode()).hexdigest()==member['unit_sha256'],'Legacy worker original identity changed')
require(recovery['source_container_id']==member['container_id'] and recovery['operation_id']==self.operation,'Legacy worker recovery image belongs to another operation')
rows=json.loads(self.run(['podman','image','inspect',recovery['image']]))
require(len(rows)==1 and rows[0]['Id'].removeprefix('sha256:')==recovery['image'].removeprefix('sha256:'),'Legacy worker recovery image changed')
image=rows[0];config=image['Config']
require(config.get('Cmd')==['node','dist/ffmpeg-worker/worker.js'] and config.get('Entrypoint')==['docker-entrypoint.sh'],'Unrecognized legacy worker command')
created=datetime.datetime.fromisoformat(image['Created'].replace('Z','+00:00')).timestamp()
require(created<=stopped['intent_at'],'Legacy worker recovery image was not captured before stop')
source=pathlib.Path(self.run(['systemctl','--user','show',member['name']+'.service','--property=SourcePath','--value']).decode().strip())
require(source.is_file() and not source.is_symlink() and source.suffix=='.container' and source.stat().st_uid==os.getuid() and sha(source)==member['unit_sha256'],'Legacy worker saved unit changed')
rows=self.run(['podman','ps','--all','--no-trunc','--filter','id='+member['container_id'],'--format','{{.ID}} {{.State}}']).decode().splitlines()
require(not rows or rows==[member['container_id']+' exited'],'Legacy worker may still be running')
after=self.queue('status')
require(after['paused'] is True and all(v==0 for v in after['counts'].values()),'Legacy worker queue reopened or changed')
return {'classification':'legacy-idle-worker-forced-termination','graceful':False,'completed_work_claim':False,
'original_container_id':member['container_id'],'original_image_id':member['image_id'],
'recovery_image_id':recovery['image'],'unit_sha256':member['unit_sha256'],
'before_counts':before,'after_counts':after['counts'],'process_dead':True}
def graceful_stop(self, name): def graceful_stop(self, name):
# Save the obligation before systemd can remove an AutoRemove container. # Save the obligation before systemd can remove an AutoRemove container.
stopped=self.record.setdefault('stopped',{}) stopped=self.record.setdefault('stopped',{})
@@ -225,7 +262,6 @@ class Controller:
stopped[name]={'intent_at':time.time(),'container_id':actual['Id']};self.save() stopped[name]={'intent_at':time.time(),'container_id':actual['Id']};self.save()
self.run(['systemctl','--user','stop',name+'.service'],timeout=180) self.run(['systemctl','--user','stop',name+'.service'],timeout=180)
properties=self.run(['systemctl','--user','show',name+'.service','--property=ActiveState,SubState,Result,ExecMainStatus']).decode() properties=self.run(['systemctl','--user','show',name+'.service','--property=ActiveState,SubState,Result,ExecMainStatus']).decode()
require('ActiveState=inactive' in properties and 'Result=success' in properties,'Service did not stop successfully')
# --rm removes inspect state. Require a persisted Podman died event for # --rm removes inspect state. Require a persisted Podman died event for
# this exact original ID; a forced SIGKILL is never called completed work. # this exact original ID; a forced SIGKILL is never called completed work.
events=self.run(['podman','events','--stream=false','--since',str(int(stopped[name]['intent_at'])-1),'--filter','container='+member['container_id'],'--filter','event=died','--format','json']).decode().splitlines() events=self.run(['podman','events','--stream=false','--since',str(int(stopped[name]['intent_at'])-1),'--filter','container='+member['container_id'],'--filter','event=died','--format','json']).decode().splitlines()
@@ -233,10 +269,13 @@ class Controller:
matching=[event for event in matching if event.get('ID',event.get('id'))==member['container_id']] matching=[event for event in matching if event.get('ID',event.get('id'))==member['container_id']]
require(matching,'Original process exit evidence unavailable; hold retained') require(matching,'Original process exit evidence unavailable; hold retained')
code=matching[-1].get('ContainerExitCode',matching[-1].get('containerExitCode')) code=matching[-1].get('ContainerExitCode',matching[-1].get('containerExitCode'))
idle_worker = name=='indeedhub-ffmpeg' and self.record.get('queue_pause_confirmed') is True and self.record.get('last_queue_counts',{}).get('active')==0 idle_worker = name=='indeedhub-ffmpeg' and self.record.get('queue_pause_confirmed') is True and valid_queue_counts(self.record.get('last_queue_counts')) and self.record['last_queue_counts']['active']==0
empty_api = name=='indeedhub-api' and self.record.get('legacy_api_empty_state') is not None empty_api = name=='indeedhub-api' and self.record.get('legacy_api_empty_state') is not None
require(str(code)=='0' or (str(code)=='143' and (idle_worker or empty_api)),'Original process did not exit cleanly; active work is not claimed completed') forced=self.legacy_idle_worker_termination(member,properties) if str(code)=='137' else None
classification=('idle-worker-terminated-after-queue-drain' if idle_worker else 'empty-business-store-legacy-api-terminated') if str(code)=='143' else 'clean-process-exit' require(forced is not None or ('ActiveState=inactive' in properties and 'Result=success' in properties),'Service did not stop successfully')
require(forced is not None or str(code)=='0' or (str(code)=='143' and (idle_worker or empty_api)),'Original process did not exit cleanly; active work is not claimed completed')
classification=forced['classification'] if forced else (('idle-worker-terminated-after-queue-drain' if idle_worker else 'empty-business-store-legacy-api-terminated') if str(code)=='143' else 'clean-process-exit')
if forced:stopped[name]['legacy_idle_termination']=forced
stopped[name].update(confirmed=True,exit_code=int(code),classification=classification,confirmed_at=time.time());self.save() stopped[name].update(confirmed=True,exit_code=int(code),classification=classification,confirmed_at=time.time());self.save()
def volume_sources(self): def volume_sources(self):
expected=VOLUMES expected=VOLUMES
@@ -313,7 +352,7 @@ class Controller:
while True: while True:
state=self.queue('status');require(state['paused'],'Worker admission reopened') state=self.queue('status');require(state['paused'],'Worker admission reopened')
self.record['last_queue_counts']=state['counts'];self.save() self.record['last_queue_counts']=state['counts'];self.save()
if state['counts'].get('active',0)==0:break if state['counts']['active']==0:break
require(time.monotonic()<deadline,'Transcodes still active; retained job state, no forced completion') require(time.monotonic()<deadline,'Transcodes still active; retained job state, no forced completion')
time.sleep(1) time.sleep(1)
self.graceful_stop('indeedhub-ffmpeg');self.legacy_api_idle();self.graceful_stop('indeedhub-api') self.graceful_stop('indeedhub-ffmpeg');self.legacy_api_idle();self.graceful_stop('indeedhub-api')
@@ -135,7 +135,7 @@ class MaintenanceTests(unittest.TestCase):
if argv[:2]==['podman','events']:return json.dumps({'ID':members()[0]['container_id'],'ContainerExitCode':137}).encode() if argv[:2]==['podman','events']:return json.dumps({'ID':members()[0]['container_id'],'ContainerExitCode':137}).encode()
raise AssertionError(argv) raise AssertionError(argv)
c.runner=command c.runner=command
with self.assertRaisesRegex(RuntimeError,'did not exit cleanly'):c.graceful_stop('indeedhub') with self.assertRaisesRegex(RuntimeError,'not allowed for this writer'):c.graceful_stop('indeedhub')
self.assertFalse(c.record['stopped']['indeedhub'].get('confirmed',False)) self.assertFalse(c.record['stopped']['indeedhub'].get('confirmed',False))
self.assertTrue((self.root/'update-transactions'/'holds'/'indeedhub').exists()) self.assertTrue((self.root/'update-transactions'/'holds'/'indeedhub').exists())
def test_failed_stop_can_restore_without_fabricating_completed_drain(self): def test_failed_stop_can_restore_without_fabricating_completed_drain(self):
@@ -183,6 +183,82 @@ class MaintenanceTests(unittest.TestCase):
c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation) c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation)
with self.assertRaisesRegex(RuntimeError,'without journal'):c.release('aborted') with self.assertRaisesRegex(RuntimeError,'without journal'):c.release('aborted')
self.assertTrue(c.fence.exists()) self.assertTrue(c.fence.exists())
def test_queue_requires_complete_nonnegative_integer_observations(self):
valid={name:0 for name in module.QUEUE_COUNTS}
for counts in ({}, {k:v for k,v in valid.items() if k!='active'},
dict(valid,active=-1),dict(valid,active=True),dict(valid,unknown=0)):
with self.subTest(counts=counts):
self.controller.runner=lambda argv,timeout,output:json.dumps({'paused':True,'counts':counts}).encode()
with self.assertRaisesRegex(RuntimeError,'queue observation'):self.controller.queue('status')
self.assertIsNone(self.controller.record)
self.controller.runner=lambda argv,timeout,output:json.dumps({'paused':True,'counts':valid}).encode()
self.assertEqual(self.controller.queue('status')['counts'],valid)
def legacy_forced_fixture(self):
c=self.controller;unit=self.root/'worker.container';unit.write_text('[Container]\nImage=original\n')
worker=next(m for m in members() if m['name']=='indeedhub-ffmpeg');worker['unit_sha256']=module.sha(unit)
counts={name:0 for name in module.QUEUE_COUNTS}
c.record={'ingress_closed':True,'queue_pause_confirmed':True,'last_queue_counts':counts,
'stopped':{'indeedhub':{'confirmed':True},worker['name']:{'container_id':worker['container_id'],'intent_at':1700000000}}}
c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation)
original={'name':worker['name'],'container_id':worker['container_id'],'image':worker['image_id'],'config_sha256':worker['config_sha256'],'body':unit.read_text()}
recovery={'source_container_id':worker['container_id'],'operation_id':self.operation,'image':'d'*64}
runtime={'id':self.operation,'phase':'Restoring','target_startup_began':False,'members':[{'original':original,'recovery_image':recovery}]}
runtimepath=c.data/'update-transactions'/'supervised'/(self.operation+'.json');module.atomic(runtimepath,runtime)
image={'Id':'d'*64,'Created':'2023-01-01T00:00:00Z','Config':{'Cmd':['node','dist/ffmpeg-worker/worker.js'],'Entrypoint':['docker-entrypoint.sh']}}
observed={'paused':True,'counts':dict(counts)};state={'ps':b''}
def runner(argv,timeout,output):
if argv[:3]==['podman','image','inspect']:return json.dumps([image]).encode()
if argv[:3]==['systemctl','--user','show']:return str(unit).encode()
if argv[:2]==['podman','ps']:return state['ps']
if argv[:2]==['podman','exec']:return json.dumps(observed).encode()
raise AssertionError(argv)
c.runner=runner
props='ActiveState=failed\nSubState=failed\nExecMainStatus=137\nResult=exit-code\n'
return c,worker,props,runtime,runtimepath,image,observed,state,unit
def test_legacy_forced_idle_proof_is_explicit_and_does_not_claim_graceful_work(self):
c,w,p,*_=self.legacy_forced_fixture();proof=c.legacy_idle_worker_termination(w,p)
self.assertEqual(proof['classification'],'legacy-idle-worker-forced-termination')
self.assertFalse(proof['graceful']);self.assertFalse(proof['completed_work_claim']);self.assertTrue(proof['process_dead'])
def test_legacy_forced_idle_rejects_missing_active_or_queued_work_before_stop(self):
c,w,p,*_=self.legacy_forced_fixture();valid=dict(c.record['last_queue_counts'])
for field in module.QUEUE_COUNTS:
for bad in ({k:v for k,v in valid.items() if k!=field},dict(valid,**{field:1})):
c.record['last_queue_counts']=bad
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
def test_legacy_forced_idle_rejects_reopened_missing_or_nonempty_after_queue(self):
c,w,p,r,rp,i,after,*_=self.legacy_forced_fixture();valid=dict(after['counts'])
after['paused']=False
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
after['paused']=True
for field in module.QUEUE_COUNTS:
for bad in ({k:v for k,v in valid.items() if k!=field},dict(valid,**{field:1})):
after['counts']=bad
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
def test_legacy_forced_idle_rejects_changed_original_recovery_and_operation(self):
import copy
c,w,p,r,rp,*_=self.legacy_forced_fixture()
mutations=[lambda d:d.update(id='foreign'),lambda d:d.update(target_startup_began=True),lambda d:d.update(phase='Committed')]
for key in ('container_id','image','config_sha256','body'):
mutations.append(lambda d,k=key:d['members'][0]['original'].update({k:'changed'}))
for key in ('source_container_id','operation_id'):
mutations.append(lambda d,k=key:d['members'][0]['recovery_image'].update({k:'changed'}))
for mutate in mutations:
damaged=copy.deepcopy(r);mutate(damaged);module.atomic(rp,damaged)
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
def test_legacy_forced_idle_rejects_unproven_process_command_unit_and_ingress(self):
c,w,p,r,rp,image,after,state,unit=self.legacy_forced_fixture()
for field,value in [('Id','e'*64),('Created','2030-01-01T00:00:00Z'),('Config',{'Cmd':['other'],'Entrypoint':['docker-entrypoint.sh']})]:
old=image[field];image[field]=value
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
image[field]=old
state['ps']=(w['container_id']+' running').encode()
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
state['ps']=b''
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p.replace('ExecMainStatus=137','ExecMainStatus=0'))
c.record['ingress_closed']=False
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
c.record['ingress_closed']=True;unit.write_text('changed')
with self.assertRaises(RuntimeError):c.legacy_idle_worker_termination(w,p)
def test_legacy_worker_sigterm_requires_proven_paused_idle_queue(self): def test_legacy_worker_sigterm_requires_proven_paused_idle_queue(self):
c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','original_members':module.validate_members(members())};c.save() c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','original_members':module.validate_members(members())};c.save()
worker=next(m for m in members() if m['name']=='indeedhub-ffmpeg') worker=next(m for m in members() if m['name']=='indeedhub-ffmpeg')
@@ -194,12 +270,12 @@ class MaintenanceTests(unittest.TestCase):
raise AssertionError(argv) raise AssertionError(argv)
c.runner=command c.runner=command
with self.assertRaises(RuntimeError):c.graceful_stop(worker['name']) with self.assertRaises(RuntimeError):c.graceful_stop(worker['name'])
c.record['queue_pause_confirmed']=True;c.record['last_queue_counts']={'active':1} c.record['queue_pause_confirmed']=True;c.record['last_queue_counts']={name:(1 if name=='active' else 0) for name in module.QUEUE_COUNTS}
with self.assertRaises(RuntimeError):c.graceful_stop(worker['name']) with self.assertRaises(RuntimeError):c.graceful_stop(worker['name'])
c.record['last_queue_counts']['active']=0;c.graceful_stop(worker['name']) c.record['last_queue_counts']['active']=0;c.graceful_stop(worker['name'])
self.assertEqual(c.record['stopped'][worker['name']]['classification'],'idle-worker-terminated-after-queue-drain') self.assertEqual(c.record['stopped'][worker['name']]['classification'],'idle-worker-terminated-after-queue-drain')
def test_legacy_api_compatibility_requires_fresh_empty_business_state(self): def test_legacy_api_compatibility_requires_fresh_empty_business_state(self):
c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','stopped':{'indeedhub':{'confirmed':True},'indeedhub-ffmpeg':{'confirmed':True}},'queue_pause_confirmed':True,'last_queue_counts':{'active':0}};c.save() c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','stopped':{'indeedhub':{'confirmed':True},'indeedhub-ffmpeg':{'confirmed':True}},'queue_pause_confirmed':True,'last_queue_counts':{name:0 for name in module.QUEUE_COUNTS}};c.save()
counts={name:0 for name in ('projects','contents','payments','shareholders','subscriptions','library_items','other_active_transactions')} counts={name:0 for name in ('projects','contents','payments','shareholders','subscriptions','library_items','other_active_transactions')}
c.runner=lambda argv,timeout,output:json.dumps(counts).encode() c.runner=lambda argv,timeout,output:json.dumps(counts).encode()
counts['payments']=1 counts['payments']=1