Coverage for src/qdrant_loader/cli/commands/jobs_cmd.py: 38%

104 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1""" 

2qdrant-loader jobs admin CLI commands. 

3 

4Subcommands: list [--status], retry <id>, trigger --source-type --source --mode, cancel <id>. 

5""" 

6 

7from __future__ import annotations 

8 

9import asyncio 

10import json 

11from pathlib import Path 

12 

13import click 

14from click.exceptions import ClickException 

15from click.types import Choice 

16from click.types import Path as ClickPath 

17 

18from qdrant_loader.core.worker.job_types import JobType 

19 

20 

21def _init_queue(workspace: Path | None, config: Path | None, env: Path | None): 

22 """Load config, build StateManager. Returns state_manager.""" 

23 from qdrant_loader.cli.config_loader import ( 

24 load_config_with_workspace, 

25 setup_workspace, 

26 ) 

27 from qdrant_loader.config import get_global_config 

28 from qdrant_loader.config.workspace import validate_workspace_flags 

29 from qdrant_loader.core.state.state_manager import StateManager 

30 

31 # Validate mutually exclusive flags early 

32 try: 

33 validate_workspace_flags(workspace, config, env) 

34 except ValueError as e: 

35 raise ClickException(str(e)) 

36 

37 if workspace: 

38 ws_config = setup_workspace(workspace) 

39 load_config_with_workspace(workspace_config=ws_config, skip_validation=True) 

40 else: 

41 resolved_config = config 

42 if resolved_config is None: 

43 default_config = Path("config.yaml") 

44 resolved_config = default_config if default_config.exists() else None 

45 if resolved_config is None: 

46 raise ClickException("No config found. Use --workspace or --config.") 

47 load_config_with_workspace( 

48 workspace_config=None, 

49 config_path=resolved_config, 

50 env_path=env, 

51 skip_validation=True, 

52 ) 

53 

54 global_config = get_global_config() 

55 state_manager = StateManager(global_config.state_management) 

56 return state_manager 

57 

58 

59async def _run_with_queue(workspace, config, env, coro_factory): 

60 """Initialize queue, run coro_factory(queue), dispose state manager.""" 

61 state_manager = _init_queue(workspace, config, env) 

62 await state_manager.initialize() 

63 queue_instance = None 

64 try: 

65 from qdrant_loader.core.worker.queue import SQLiteJobQueue 

66 

67 queue_instance = SQLiteJobQueue( 

68 state_manager.session_factory, 

69 db_op_lock=state_manager.queue_db_op_lock, 

70 ) 

71 return await coro_factory(queue_instance) 

72 finally: 

73 await state_manager.dispose() 

74 

75 

76# ──────────────────────────────────────────────── 

77# Click group 

78# ──────────────────────────────────────────────── 

79 

80 

81@click.group("jobs") 

82def jobs_cmd(): 

83 """Inspect and manage background ingestion jobs.""" 

84 

85 

86def _common_options(fn): 

87 """Decorator that adds --workspace, --config, --env options.""" 

88 fn = click.option( 

89 "--workspace", 

90 type=ClickPath(path_type=Path), 

91 default=None, 

92 help="Workspace directory.", 

93 )(fn) 

94 fn = click.option( 

95 "--config", 

96 "config_path", 

97 type=ClickPath(path_type=Path), 

98 default=None, 

99 help="Path to config.yaml.", 

100 )(fn) 

101 fn = click.option( 

102 "--env", 

103 "env_path", 

104 type=ClickPath(path_type=Path), 

105 default=None, 

106 help="Path to .env file.", 

107 )(fn) 

108 return fn 

109 

110 

111# ──────────────────────────────────────────────── 

112# jobs list 

113# ──────────────────────────────────────────────── 

114 

115 

116@jobs_cmd.command("list") 

117@click.option( 

118 "--status", 

119 type=Choice( 

120 ["pending", "running", "done", "failed", "cancelled"], case_sensitive=False 

121 ), 

122 default=None, 

123 help="Filter by job status.", 

124) 

125@click.option( 

126 "--limit", 

127 type=click.IntRange(min=1, max=1000), 

128 default=50, 

129 show_default=True, 

130 help="Max rows to return.", 

131) 

132@click.option("--json", "output_json", is_flag=True, help="Output as JSON.") 

133@_common_options 

134def jobs_list(status, limit, output_json, workspace, config_path, env_path): 

135 """List jobs, optionally filtered by status.""" 

136 

137 async def _list(queue): 

138 jobs = await queue.list(status=status, limit=limit) 

139 if output_json: 

140 rows = [] 

141 for j in jobs: 

142 rows.append( 

143 { 

144 "id": j.id, 

145 "type": j.type, 

146 "status": j.status, 

147 "attempts": j.attempts, 

148 "enqueued_at": ( 

149 j.enqueued_at.isoformat() if j.enqueued_at else None 

150 ), 

151 "started_at": ( 

152 j.started_at.isoformat() if j.started_at else None 

153 ), 

154 "finished_at": ( 

155 j.finished_at.isoformat() if j.finished_at else None 

156 ), 

157 "last_error": j.last_error, 

158 "payload": json.loads(j.payload_json) if j.payload_json else {}, 

159 } 

160 ) 

161 click.echo(json.dumps(rows, indent=2)) 

162 else: 

163 if not jobs: 

164 click.echo("No jobs found.") 

165 return 

166 header = f"{'ID':>6} {'STATUS':<10} {'TYPE':<20} {'ATTEMPTS':>8} {'ENQUEUED_AT':<27} LAST_ERROR" 

167 click.echo(header) 

168 click.echo("-" * len(header)) 

169 for j in jobs: 

170 enq = j.enqueued_at.isoformat() if j.enqueued_at else "" 

171 err = (j.last_error or "")[:40] 

172 click.echo( 

173 f"{j.id:>6} {j.status:<10} {j.type:<20} {j.attempts:>8} {enq:<27} {err}" 

174 ) 

175 

176 asyncio.run(_run_with_queue(workspace, config_path, env_path, _list)) 

177 

178 

179# ──────────────────────────────────────────────── 

180# jobs retry 

181# ──────────────────────────────────────────────── 

182 

183 

184@jobs_cmd.command("retry") 

185@click.argument("job_id", type=int) 

186@_common_options 

187def jobs_retry(job_id, workspace, config_path, env_path): 

188 """Reset a failed or done job back to pending.""" 

189 

190 async def _retry(queue): 

191 ok = await queue.reset_to_pending(job_id) 

192 if ok: 

193 click.echo(f"Job {job_id} reset to pending.") 

194 else: 

195 raise ClickException( 

196 f"Job {job_id} not found or is not in failed/done state." 

197 ) 

198 

199 asyncio.run(_run_with_queue(workspace, config_path, env_path, _retry)) 

200 

201 

202# ──────────────────────────────────────────────── 

203# jobs trigger 

204# ──────────────────────────────────────────────── 

205 

206 

207@jobs_cmd.command("trigger") 

208@click.option( 

209 "--source-type", required=True, help="Source type (e.g. git, confluence)." 

210) 

211@click.option("--source", required=True, help="Source name as configured.") 

212@click.option( 

213 "--mode", 

214 type=Choice(["bulk", "incremental"], case_sensitive=False), 

215 required=True, 

216 help="Ingestion mode.", 

217) 

218@click.option("--project", "project_id", required=True, help="Project ID.") 

219@_common_options 

220def jobs_trigger( 

221 source_type, source, mode, project_id, workspace, config_path, env_path 

222): 

223 """Enqueue a new ingestion job immediately.""" 

224 

225 async def _trigger(queue): 

226 job_type = ( 

227 JobType.BULK_INGEST if mode.lower() == "bulk" else JobType.INCREMENTAL_PULL 

228 ) 

229 payload = { 

230 "project_id": project_id, 

231 "source_type": source_type, 

232 "source": source, 

233 "source_lock": f"{project_id}:{source_type}:{source}", 

234 } 

235 job = await queue.enqueue(job_type, payload) 

236 click.echo(f"Enqueued job {job.id} (type={job_type}, status={job.status}).") 

237 

238 asyncio.run(_run_with_queue(workspace, config_path, env_path, _trigger)) 

239 

240 

241# ──────────────────────────────────────────────── 

242# jobs cancel 

243# ──────────────────────────────────────────────── 

244 

245 

246@jobs_cmd.command("cancel") 

247@click.argument("job_id", type=int) 

248@_common_options 

249def jobs_cancel(job_id, workspace, config_path, env_path): 

250 """Cancel a pending job.""" 

251 

252 async def _cancel(queue): 

253 ok = await queue.cancel(job_id) 

254 if ok: 

255 click.echo(f"Job {job_id} cancelled.") 

256 else: 

257 raise ClickException(f"Job {job_id} not found or is not in pending state.") 

258 

259 asyncio.run(_run_with_queue(workspace, config_path, env_path, _cancel))