chronaxis.results.result
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""从封口暂存提供流式结果,关闭文件后按时间命名发布,完成清单最后出现。23输入是已排序ResultSpool与运行元数据,输出是行迭代器和OutputManifest。4CSV与JSONL使用同一套平坦摘要字段,不保留旧row_json或结果包装副本。5成功生成表、质量记录及完成清单后才安装目录;已有目标拒绝,失败保留诊断临时目录。6close关闭所有活跃迭代器且幂等;默认清理自有暂存,保留原输入和已发布文件。无旧reader/decoder/writer调用。7单个文件或目录按平台不覆盖移动;Windows保留有限共享冲突重试。8元数据、读槽和发布workspace分别记录实际RAM费用,预算更新后取消仍可恢复清账。9读取器真实handle.close失败保留句柄并拒绝继续读;结果close失败可重试,不提前标closed。10上述重试保证只覆盖托管的结果资源,不覆盖任意局部文件句柄的持续关闭失败。11"""1213# 发布只新增本次文件;标准库逐行读写,不覆盖已有结果。14import csv15from contextlib import nullcontext16from collections.abc import Iterator17from datetime import datetime, timezone18import hashlib19import json20import os21from pathlib import Path22import platform23import tempfile24import time25from .codec import decode_row26from .errors import (AnalysisError, BudgetExceededError, ResultClosedError, ResultFormatError,27 ContributionsUnavailableError)28from .manifest import RunMetadata, OutputManifest29from .artifact_exports import ArtifactExportStore30from .rows import ResultRow, row_table31from .summary import (SummaryCalculationRow, SummaryEventRow, SummarySequenceRow, SummarySegmentRow,32 SummaryContributionRow, SummarySelectionRow, SummaryInputRow, SummaryAggregationRow,33 summary_fields, SUMMARY_MODELS, SUMMARY_COLUMNS)34from .owned_directory import directory_identity, remove_owned_directory35from .spool import ResultSpool, _reserve_owned_ram, _release_owned_ram, _cleanup_owner363738def _json(value) -> str:39 """生成标准紧凑JSON文本,拒绝非有限普通数字。4041 参数: value为受控JSON结构。返回: str。异常: ValueError为非法数值。42 """43 try:44 return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(',', ':'))45 finally:46 # 原异常/TB和返回值保持;只断本层临时引用,不清调用方帧。47 value = None484950def _row_fields(row) -> dict:51 """提取当前平坦结果的CSV字段;数组单元格使用有限JSON文本。5253 参数: row为当前登记的结果行。返回: 独立字段字典。异常: 未支持模型或非有限值错误。54 """55 try:56 result = summary_fields(row)57 for name in ('parent_event_ids','group_keys','group_values'):58 if name in result:59 result[name] = _json(result[name])60 return result61 finally:62 row = result = None636465class _DigestOutput:66 """借用本次已打开的输出流,同一次写入计算请求的摘要,不拥有关闭责任。"""67 def __init__(self,stream):68 """建立固定状态。参数: 文本或二进制流。返回: owner。异常: 哈希构造错。"""69 self.stream=stream;self.digest=hashlib.sha256();self.size=07071 def write(self,payload):72 """完整写出有限payload后更新摘要,不关闭借用流。7374 参数: str/bytes。返回: 原长度。异常: 短写/IO;回溯不保留本层载荷引用。75 """76 encoded=None77 try:78 count=self.stream.write(payload)79 if count!=len(payload): raise OSError('结果写入不完整')80 encoded=payload.encode('utf-8') if isinstance(payload,str) else payload81 self.digest.update(encoded);self.size+=len(encoded)82 return count83 finally:84 # 只解除本函数的两份引用,不清调用者或写入器的异常帧。85 payload=encoded=None8687 def info(self,name):88 """返回已关闭文件的有限事实。参数: 文件名。返回: dict。异常: 无。"""89 return dict(name=name,bytes=self.size,sha256=self.digest.hexdigest())909192class _RowIterator:93 """独立文件位置的有限行迭代器,实际读槽与文件关闭完成后才closed。9495 _reader_cost为实际已付槽位,_held_cost仅含本迭代器费用。关闭中的迭代器仍在96 owner登记中,成功关handle后再抵销费用、解除登记;失败可由本对象或父close重试。97 handle及内部发布模式的_index_handle保留真实文件,所有实际FD关闭后才抵销读槽。98 """99100 def __init__(self, owner, table: str):101 """先预留解码槽位,再打开只读暂存并登记活跃资源。102103 参数: owner为AnalysisResult,table为固定表名;publication只供内部legacy JSONL发布。104 返回: 公共typed行迭代器,内部发布模式返回已核完整原行对应的有限质量字段。105 异常: 预算/IO/取消原样传播;持续清理失败附row_iterator_cleanup_owner,父登记仍可达。106 """107 owner._require_open()108 self.owner, self.table, self.closed = owner, table, False109 # 在首次预约前准备可清理字段;父元数据覆盖这个尚无读槽的小登记引用。110 self._budget, self._held_cost, self._reader_cost = owner.spool.budget, 0, 0111 self.handle, self._handle_closed, self._closing = None, True, False112 self._payload = None113 if table not in owner.spool.tables:114 if table not in SUMMARY_COLUMNS:115 raise ValueError('未注册结果表')116 # 未配置legacy是实际无文件的空游标;不登记FD、不申请读槽,但保close接口。117 self.closed = True118 return119 try:120 owner._iterators.add(self)121 # 所有表都按成功追加实长付款;原编码硬限和读前长度检查继续保留。122 self.max_row_bytes = owner.spool.row_limit(table)123 _reserve_owned_ram(self, owner.spool.reader_cost_for(table), '_reader_cost')124 self.handle = owner.spool.files[table].open('rb', buffering=8192)125 self._handle_closed = False126 except BaseException as exc:127 _cleanup_owner(self, exc, 'row_iterator_cleanup_owner', '结果读取器构造')128 raise129130 def __iter__(self):131 """保持当前读取位置,不重读输入。132133 参数: 无。返回: self。异常: 无。134 """135 return self136137 def __next__(self) -> ResultRow | dict:138 """读取一条有限行,EOF或异常关闭资源。139140 参数: 无。返回: 公共typed行或内部认证发布的质量字段。异常: StopIteration/关闭/格式/IO错误。141 """142 self.owner._require_open()143 # 只借出当前严格解码过的一行原字节;下一次推进前释放,不建立结果缓存。144 self._payload = None145 if self.closed:146 raise StopIteration147 # 真实关闭失败或读槽未抵销时禁止继续借出半关闭输入。148 if self._closing:149 raise ResultClosedError('结果读取器正在清理')150 try:151 payload = self.handle.readline(self.max_row_bytes + 1)152 if not payload:153 self.close()154 raise StopIteration155 if (len(payload) > self.max_row_bytes or len(payload) > self.owner.spool.max_row_bytes156 or not payload.endswith(b'\n')):157 raise ResultFormatError('结果行超限或未完整结束')158 row = decode_row(payload, self.max_row_bytes)159 if row_table(row) != self.table:160 raise ResultFormatError('结果暂存表与行类型不一致')161 self._payload = payload162 return row163 except BaseException as exc:164 # 解码/读取原异常优先;清理仍失败时父登记和恢复属性保存实际读取器。165 payload = row = None166 try:167 self.close()168 except BaseException as cleanup:169 exc.add_note(f'结果读取器异常清理附加失败: {type(cleanup).__name__}')170 if not self.closed:171 exc.row_iterator_cleanup_owner = self172 raise173 finally:174 # return已先求值;原TB不额外持有本层行和读取bytes。175 payload = row = None176177 def close(self) -> None:178 """抵销槽位、取消登记并关闭句柄;重复调用安全。179180 参数: 无。返回: None。异常: 原文件关闭/预算取消传播;失败句柄与未付清费用留待重试。181 """182 if self.closed:183 return184 self._closing = True185 self._payload = None186 try:187 # FD尚未关完时保留handle及读槽;关闭后抛取消也根据真实handle.closed记录状态。188 if self.handle is not None and not self._handle_closed:189 try:190 self.handle.close()191 finally:192 if self.handle.closed:193 self._handle_closed = True194 _release_owned_ram(self, self._reader_cost, '_reader_cost')195 # 未关闭资源的迭代器不能先从父登记消失;解除登记失败可由父再次close。196 self.owner._iterators.discard(self)197 finally:198 if self._handle_closed and self._held_cost == 0 and self not in self.owner._iterators:199 self.closed = True200201202class AnalysisResult:203 """保存一次分析的结果,提供可关闭的逐行迭代器和结果发布方法。204205 内部状态初始analysis_complete,发布失败为output_failed/cancelled。206 write可重复导出到同一结果目录;每次只读暂存,不重读旧输入。207 单线程使用;执行输入和旧writer成功关闭的前置条件由AnalysisRunner负责。208 _metadata_cost/_writer_cost为实际已付费用;自有读取器各自持有读槽,spool持有其209 固定/缓冲/归并费用。close进入_closing后拒绝读取或发布,所有层成功关闭才_closed。210 """211212 def __init__(self, spool: ResultSpool, metadata: RunMetadata, table_format: str = 'csv', *, artifact_exports=None,213 owned_work=None):214 """接管已封口暂存并写analysis_complete内部清单。215216 参数: spool为已完成暂存,metadata为真实运行事实,table_format为csv/jsonl/readable_csv;217 低层构造器保留csv默认以兼容现有调用,普通配置与AnalysisRunner默认readable_csv。218 artifact_exports为独立信号/报文导出仓;同样接管、按索引复制并校验、关闭。219 owned_work仅为新会话移交的私有根及创建身份,须为spool直接父目录;默认只接管spool目录。220 返回: None;初始化实例状态。异常: 配置拒绝前不接管spool;资源失败保原取消或原221 AnalysisError包装,持续清理失败附analysis_result_cleanup_owner供实际恢复。222 """223 if (not isinstance(spool, ResultSpool) or spool.state != 'analysis_complete'224 or spool._closed or spool._closing):225 raise AnalysisError('AnalysisResult需要已封口且未关闭的暂存')226 if not isinstance(metadata, RunMetadata) or table_format not in ('csv', 'jsonl', 'readable_csv'):227 raise ValueError('结果需RunMetadata和csv/jsonl/readable_csv格式')228 if owned_work is not None:229 if ((type(owned_work) is not tuple) or (len(owned_work) != 2) or (Path(owned_work[0]).resolve() != spool.path.resolve().parent) or (directory_identity(owned_work[0]) != owned_work[1])):230 raise ValueError('结果只能接管同次新会话登记的spool直接父目录')231 if (set(spool.tables) - set(SUMMARY_COLUMNS) or spool._summary_row_count != sum(spool.counts.values())232 or spool._directory_identity is None):233 raise ValueError('当前结果只接受已登记的精简表及对应行,不能混入旧完整结果')234 if artifact_exports is not None and (type(artifact_exports) is not ArtifactExportStore235 or artifact_exports._budget is not spool.budget or artifact_exports.closed or artifact_exports.state!='sealed'236 or artifact_exports.session_id not in (set(metadata.session_ids))):237 raise ValueError('新导出仓需同预算、同会话且封口')238 # 校验通过后才接管spool;首次预约前安装全部清理字段,已付费用从0开始。239 self.spool, self.metadata, self.table_format = spool, metadata, table_format240 self._work_removed = (metadata.retain_work)241 self._owned_work = owned_work or (spool.path, spool._directory_identity)242 self._pending_output_cleanup = None243 self._readable_writer = None # 发布失败时保留真实索引所有者,供close重试。244 self._published_counts = None245 self._presentation = None246 self._contributions_omitted = 'contributions' not in spool.tables247 self._output_tables = spool.tables248 self._artifact_exports=artifact_exports249 self._budget, self._held_cost, self._metadata_cost, self._writer_cost = spool.budget, 0, 0, 0250 self._iterators = set()251 self._closed = self._closing = False252 self._failure_count = 0253 self.status = 'analysis_complete'254 self.manifest_path = spool.path / 'analysis_manifest.json'255 if (not (metadata.detailed_diagnostics or metadata.retain_work)):256 self.manifest_path = None257 try:258 expected = metadata.estimated_bytes()259 if owned_work is not None:260 expected += 4096 + 64 * len(str(owned_work[0]))261 if artifact_exports is not None:262 expected+=65536+64*len(str(artifact_exports.path))263 _reserve_owned_ram(self, expected, '_metadata_cost')264 self._write_state(self.manifest_path, self.status)265 except BaseException as exc:266 spool._fail(exc)267 _cleanup_owner(self, exc, 'analysis_result_cleanup_owner', '结果对象构造')268 if isinstance(exc, (KeyboardInterrupt, SystemExit)):269 raise270 # 保持原非取消异常包装;未返回owner同时挂在外层,调用方无需猜测异常链。271 error = AnalysisError('结果内部清单写入失败')272 recovery = getattr(exc, 'analysis_result_cleanup_owner', None)273 if recovery is not None:274 error.analysis_result_cleanup_owner = recovery275 error.add_note('释放恢复后调用analysis_result_cleanup_owner.close()')276 raise error from exc277278 def _require_open(self) -> None:279 """拒绝关闭后迭代/发布,不自动重新打开已关闭对象。280281 参数: 无。返回: None。异常: ResultClosedError。282 """283 if self._closed or self._closing or self.spool._closed or self.spool._closing:284 raise ResultClosedError('AnalysisResult已关闭或正在清理')285286 def _manifest(self, status: str, outputs=(), coverage=None) -> dict:287 """构造有界清单副本,分开分析状态与发布状态。288289 参数: status为实际状态,outputs为固定文件记录,coverage为固定状态计数。290 返回: dict;元数据转换成本已申请。异常: 无预期异常。291 """292 manifest = {'status': status, 'run': self.metadata.to_dict(), 'python_version': platform.python_version(),293 'ended_at': datetime.now(timezone.utc).isoformat(), 'error_counts': {'output': self._failure_count},294 'row_counts': {table: self.spool.counts[table] for table in self._output_tables},295 'resources': self.spool.budget.snapshot(),296 'result_buffer_rows': self.spool.result_buffer_rows, 'peak_buffer_rows': self.spool.peak_buffer_rows,297 'table_format': self.table_format, 'outputs': list(outputs)}298 if (self.metadata.retain_work):299 manifest['spool_path'] = str(self.spool.path)300 manifest['spool_files'] = {table: str(self.spool.files[table]) for table in self._output_tables}301 if (self.metadata.detailed_diagnostics):302 manifest['coverage'] = coverage or {}303 defaults={table:version for table,_,version in reversed(tuple(SUMMARY_MODELS.values()))}304 versions={table:sorted(self.spool._summary_versions.get(table,{defaults[table]})) for table in self.spool.tables}305 versions={table:values[0] if len(values)==1 else values for table,values in versions.items()}306 if all(type(v) is int for v in versions.values()) and len(set(versions.values())) == 1:307 manifest['row_schema_version'] = next(iter(versions.values()))308 else:309 manifest['row_schema_versions'] = versions310 if self._artifact_exports is not None:311 manifest['exports']={'index_file':'exports.jsonl','directory':'exports',312 'record_count':self._artifact_exports.count,'device_label':self._artifact_exports.device_label}313 if self.table_format == 'readable_csv':314 from .readable_format import seconds, outcome_messages315 # 可读表不是旧行模型的逐行副本;以实际发布行数和专用表示版本为准。316 manifest.pop('row_schema_version', None)317 manifest.pop('row_schema_versions', None)318 if self._published_counts is not None:319 manifest['row_counts'] = dict(self._published_counts)320 if self._presentation is not None:321 manifest['presentation'] = self._presentation322 manifest['result_messages'] = outcome_messages(self.metadata.analysis_outcome)323 run = manifest['run']324 if 'session_boundaries' in run:325 run['session_boundaries_seconds'] = [(name, seconds(start), seconds(end))326 for name, start, end in run.pop('session_boundaries')]327 # 调试资源仍在显式诊断中提供,普通结果先呈现实际文件和业务概况。328 if not self.metadata.detailed_diagnostics:329 for key in ('python_version', 'resources', 'result_buffer_rows', 'peak_buffer_rows'):330 manifest.pop(key, None)331 return manifest332333 @property334 def run_manifest(self) -> dict:335 """返回当前状态快照,发布失败诊断路径另见manifest_path。336337 参数: 无。返回: 独立小型dict。异常: 关闭后仍可查看诊断,不读文件。338 """339 return self._manifest(self.status)340341 def _write_state(self, path: Path, status: str) -> None:342 """追加状态清单到暂存,每份保留且计入累计配额。343344 参数: path为本任务全新清单路径,status为实际状态。返回: None。345 异常: 配额/IO失败;不能生成虚假completed。346 """347 try:348 if (not (self.metadata.detailed_diagnostics or self.metadata.retain_work)):349 return350 payload = (_json(self._manifest(status)) + '\n').encode('utf-8')351 if len(payload) > self.spool.max_row_bytes:352 raise BudgetExceededError('运行清单超过有限元数据大小')353 with path.open('xb', buffering=0) as handle:354 self.spool._write_bytes(handle, payload)355 finally:356 # 失败状态诊断也只断本层文本/输入,保原IO错误与实际写次数。357 payload = self = path = status = None358359 def iter_events(self) -> Iterator[SummaryEventRow]:360 """创建独立事件流,不装入全量列表。361362 参数: 无。返回: SummaryEventRow迭代器。异常: ResultClosedError表示结果已关闭或正在关闭;BudgetExceededError表示无法为游标分配预算。363 创建或迭代期间OSError表示暂存文件读取失败,ResultFormatError表示暂存行格式损坏。364 """365 return _RowIterator(self, 'events')366367 def iter_sequences(self) -> Iterator[SummarySequenceRow]:368 """逐行读取请求配对结果;未配置配对时返回可关闭空游标。369370 参数: 无。返回: SummarySequenceRow迭代器。异常: ResultClosedError表示结果已关闭或正在关闭;BudgetExceededError表示无法为游标分配预算。371 创建或迭代期间OSError表示暂存文件读取失败,ResultFormatError表示暂存行格式损坏。372 """373 return _RowIterator(self, 'sequences')374375 def iter_segments(self) -> Iterator[SummarySegmentRow]:376 """创建独立片段流,保留全部父事件关联。377378 参数: 无。返回: SummarySegmentRow迭代器。异常: ResultClosedError表示结果已关闭或正在关闭;BudgetExceededError表示无法为游标分配预算。379 创建或迭代期间OSError表示暂存文件读取失败,ResultFormatError表示暂存行格式损坏。380 """381 return _RowIterator(self, 'segments')382383 def iter_calculations(self) -> Iterator[SummaryCalculationRow]:384 """创建计算摘要行流;同时检查value、unit、status、reason和quality。385386 参数: 无。返回: SummaryCalculationRow迭代器。异常: ResultClosedError表示结果已关闭或正在关闭;BudgetExceededError表示无法为游标分配预算。387 创建或迭代期间OSError表示暂存文件读取失败,ResultFormatError表示暂存行格式损坏。388 """389 return _RowIterator(self, 'calculations')390391392 def iter_contributions(self) -> Iterator[SummaryContributionRow]:393 """逐行回查真实贡献与skip,原序号和新求值序号分别保留。394395 参数: 无。返回: 独立有预算游标;逐行返回SummaryContributionRow。396 异常: ContributionsUnavailableError表示本次明确不生成明细;关闭、预算、397 IO或格式错误仍保留。原输入/解码器/求值器均不再调用。398 """399 self._require_open()400 if self._contributions_omitted:401 raise ContributionsUnavailableError('本次未生成贡献明细;请将outputs.contributions设为full后重新运行')402 return _RowIterator(self, 'contributions')403404 def iter_aggregations(self) -> Iterator['SummaryAggregationRow']:405 """按组读取聚合行及总体计数。参数: 无。返回: 受结果生命周期约束的迭代器。异常: 关闭或解码错误。"""406 return _RowIterator(self,'aggregations')407408 def iter_selections(self) -> Iterator[SummarySelectionRow]:409 """读取明确请求的真实样本选择行。参数: 无。返回: 有预算游标,未请求为空。异常: ResultClosedError表示结果关闭;BudgetExceededError表示游标预算不足;410 迭代时ResultFormatError表示暂存格式损坏,OSError表示文件读取失败。"""411 return _RowIterator(self,'selections')412413 def iter_input_trace(self) -> Iterator[SummaryInputRow]:414 """读取显式请求的所需输入追溯。参数: 无。返回: 按行产生SummaryInputRow的可关闭游标。415 异常: ResultClosedError表示结果已关闭;AnalysisError表示未请求diagnostics.trace。416 迭代期间可能因暂存损坏报告ResultFormatError,或传播OSError及预算错误。"""417 self._require_open()418 if 'input_trace' not in self.spool.tables:419 raise AnalysisError('本次未生成输入追溯;请设置diagnostics.trace后重新运行')420 return _RowIterator(self,'input_trace')421422 @property423 def contributions_available(self) -> bool:424 """说明本结果是否允许查询逐消息贡献,不将未生成和零行混为一谈。425426 参数: 无。返回: bool;默认未配置逐消息指标仍允许原空游标。427 异常: 无;关闭后可查看这项固定运行事实,但不能重新创建游标。428 """429 return not self._contributions_omitted430431 def _write_table(self, table: str, destination: Path, quality_handle, coverage: dict):432 """逐行生成单表和质量记录,异常或EOF关闭读取句柄。433434 参数: table为固定表,destination为新文件,quality_handle为质量输出。435 返回: 请求摘要时为文件事实,否则None。异常: 预算/编码/写入错误传播,输出with保证关闭。436 """437 iterator = _RowIterator(self, table)438 count = 0439 failure = None440 digest_output = None441 try:442 mode = 'x' if self.table_format == 'csv' else 'xb'443 options = {'encoding': 'utf-8', 'newline': ''} if mode == 'x' else {}444 with destination.open(mode, **options) as output:445 # 新版只在明确请求时随写摘要;CSV使用newline空串,无平台换行二次转换。446 if (self.metadata.integrity_diagnostics):447 digest_output=_DigestOutput(output);output=digest_output448 columns = SUMMARY_COLUMNS[table]449 versions=self.spool._summary_versions.get(table,())450 if 16 in versions:451 columns=columns+('extension_version',)452 if 10 in versions or 16 in versions:453 columns=columns+('coverage_status',)454 writer = csv.DictWriter(output, fieldnames=columns) if mode == 'x' else None455 if writer is not None:456 writer.writeheader()457 for row in iterator:458 # 普通/CSV路径已完整解码;内部legacy发布已核本次完整行证据。459 # 直接复用原字节,公共iterator仍交付严格模型,全部业务字段原样保留。460 payload = iterator._payload461 fields = ((_row_fields(row) if writer is not None else summary_fields(row)) if (writer is not None or quality_handle is not None)462 else None)463 if writer is not None:464 writer.writerow(fields)465 else:466 output.write(payload)467 # 状态/覆盖分布仅使用固定有限键,不按任意signal/rule建立无限dict。468 if quality_handle is not None:469 state_field = {'selections': 'selection', 'contributions': 'reason',470 'input_trace': 'record_type'}.get(table, 'status')471 state_key = table + ':' + fields[state_field]472 coverage[state_key] = coverage.get(state_key, 0) + 1473 quality_record = {key: fields[key] for key in ('sequence', 'dataset_id', 'entity_id', 'session_id', 'quality', state_field)}474 quality_record['table'] = table475 for key in ('selection', 'value_coverage', 'condition_coverage', 'requested_coverage', 'coverage_status'):476 if key in fields:477 quality_record[key] = fields[key]478 quality_handle.write(_json(quality_record) + '\n')479 count += 1480 if count != self.spool.counts[table]:481 raise ResultFormatError('输出表行数与分析暂存不一致')482 return digest_output.info(destination.name) if digest_output is not None else None483 except BaseException as exc:484 failure = exc485 raise486 finally:487 # 输出/编码原错误不能被读槽收尾取消覆盖,失败读取器仍留父登记供close。488 row = fields = payload = quality_record = None489 try:490 iterator.close()491 except BaseException as cleanup:492 if failure is None:493 raise494 failure.add_note(f'发布表读取器清理附加失败: {type(cleanup).__name__}')495496 def _file_info(self, path: Path) -> dict:497 """以固定读取块计算新输出哈希,不保留全文件内容。498499 参数: path为已关闭输出文件。返回: 名称/bytes/SHA256。500 异常: IO错误;调用侧已预留发布临时槽位。501 """502 if not self.metadata.detailed_diagnostics:503 return {'name': path.name, 'bytes': path.stat().st_size}504 digest = hashlib.sha256()505 chunk = None506 try:507 with path.open('rb') as handle:508 while chunk := handle.read(65536):509 digest.update(chunk)510 return {'name': path.name, 'bytes': path.stat().st_size, 'sha256': digest.hexdigest()}511 finally:512 # 保留原异常栈和摘要策略,只释放本层当前块。513 chunk = None514515 def _install_directory(self, temporary: Path, destination: Path) -> None:516 """同文件系统不覆盖安装;Windows短暂共享冲突保留原有限等待。517518 参数: temporary为目标父目录临时目录,destination为全新目标。519 返回: None。异常: NotImplementedError为平台缺少不覆盖移动能力;FileExistsError/IO错误为安装失败。520 边界: 仅原rename的Win5/32最多尝试六次,五次等待合计0.77秒;521 系统调用本身不受该等待时长约束。移动未完成时保留源目录;移动后的检查或关闭也可能报错,不能仅凭异常断定源目录仍在。522 副作用: 只重试当前路径安装,不重新读取、分析或生成任何输出文件。523 """524 # Windows rename对已存在目录拒绝;POSIX普通rename可覆盖空目录,不能静默复用。525 from .platform_files import move_no_replace526 if os.name != 'nt':527 move_no_replace(temporary,destination)528 return529 delays = (.02, .05, .1, .2, .4)530 for attempt in range(len(delays) + 1):531 # 每次等待后重新保护新出现的目标;检查之后的竞争仍由Windows rename拒绝。532 if destination.exists():533 raise FileExistsError(destination)534 try:535 move_no_replace(temporary, destination)536 return537 except PermissionError as exc:538 # 仅恢复短暂访问/共享冲突;权限长期不允许时仍保最后真实OS错误及现场。539 if getattr(exc, 'winerror', None) not in (5, 32):540 raise541 if attempt == len(delays):542 exc.add_note('Windows输出安装六次均被拒绝;已等待0.77秒,保留临时目录供诊断')543 raise544 time.sleep(delays[attempt])545546 def write(self, output_dir: str | Path) -> OutputManifest:547 """从暂存发布带时间文件名的结果,完成清单最后安装。548549 参数: output_dir为可复用输出目录;格式取本结果table_format。550 返回: 安装成功后的OutputManifest。异常: AnalysisError包含原错误链;关闭异常独立。551 副作用: 成功时创建正式输出目录;失败时按retain_work决定是否保留本次临时目录。552 不覆盖已有目标,不重新读取输入日志;关闭后的结果不能发布。553 """554 self._require_open()555 temporary = None556 temporary_identity = None557 publication = None558 manifest = None559 failure = None560 try:561 destination = Path(output_dir).absolute()562 if (not self.metadata.retain_work) and (destination.resolve().is_relative_to(Path(self._owned_work[0]).resolve())):563 raise ValueError('发布目录不能放在关闭后删除的本次私有工作目录内')564 from .publication import PublicationNames, output_root565 destination = output_root(destination)566 # 转换/CSV/质量/hash临时对象也计入账本,不只计算读取数组。567 _reserve_owned_ram(self, self.spool.reader_cost, '_writer_cost')568 destination.parent.mkdir(parents=True, exist_ok=True)569 temporary = Path(tempfile.mkdtemp(prefix=f'.{destination.name}.incomplete-', dir=destination.parent))570 temporary_identity = directory_identity(temporary)571 coverage = {}572 suffix = '.' + self.table_format573 # 仅发布实际请求的表;none不写空贡献文件,也不会打开其内部空暂存。574 paths = tuple(temporary / (table + suffix) for table in self._output_tables)575 quality_path = temporary / 'quality.jsonl'576 detailed = (self.metadata.detailed_diagnostics)577 quality_paths = (quality_path,) if detailed else ()578 written_infos={}579 with (quality_path.open('x', encoding='utf-8', newline='') if detailed else nullcontext(None)) as quality:580 if self.table_format == 'readable_csv':581 from .readable import ReadableWriter582 self._readable_writer = ReadableWriter(self, temporary, _RowIterator)583 paths = self._readable_writer.write(quality, coverage)584 written_infos.update(self._readable_writer.infos)585 self._published_counts = dict(self._readable_writer.counts)586 self._presentation = self._readable_writer.presentation()587 self._readable_writer.close()588 self._readable_writer = None589 else:590 for table, path in zip(self._output_tables, paths):591 info=self._write_table(table, path, quality, coverage)592 if info is not None: written_infos[path.name]=info593 # 原writer在run内已关闭;发布只复制并核验现成BLF/索引,无旧I/O调用。594 extra_paths = ()595 if self._artifact_exports is not None:596 extra_paths+=(self._artifact_exports.copy_to(temporary),)597 written_infos['exports.jsonl']=self._artifact_exports.index_info598 # 所有表和质量句柄已成功关闭;清单作为最后写入内容。599 infos = tuple(written_infos[path.name] if path.name in written_infos else self._file_info(path)600 for path in (*paths, *quality_paths, *extra_paths))601 # 文件全部关闭后分配本次名称;只改清单中的路径,不回读或改写数据行。602 publication = PublicationNames(destination, (*[p.name for p in temporary.iterdir()], 'run_manifest.json'))603 infos = tuple(dict(info, name=publication.names[info['name']]) for info in infos)604 completed = self._manifest('completed', infos, coverage)605 if 'exports' in completed:606 completed['exports'].update(index_file=publication.names['exports.jsonl'], directory=publication.names['exports'])607 completed['manifest_file'] = publication.names['run_manifest.json']608 with (temporary / 'run_manifest.json').open('x', encoding='utf-8', newline='') as handle:609 handle.write((json.dumps(completed, ensure_ascii=False, allow_nan=False, indent=2)610 if self.table_format == 'readable_csv' else _json(completed)) + '\n')611 # 返回模型也在安装前完成校验;安装成功后仅返回,避免校验错误遗留已发布completed。612 manifest = OutputManifest(self.metadata.run_id, destination, destination / publication.names['run_manifest.json'],613 tuple(destination / publication.names[path.name] for path in (*paths, *quality_paths, *extra_paths)),614 tuple(self._published_counts.items()) if self.table_format == 'readable_csv'615 else tuple((table, self.spool.counts[table]) for table in self._output_tables))616 publication.publish(temporary, 'run_manifest.json', self._install_directory)617 try:618 temporary.rmdir()619 except OSError:620 # 正式清单已发布;空暂存交给已有close责任重试,不将结果谎报为未发布。621 self._pending_output_cleanup = (temporary, temporary_identity)622 self._closing = True623 self.status = 'analysis_complete'624 return manifest625 except BaseException as exc:626 failure = exc627 if publication is not None:628 try:629 publication.close()630 except BaseException as cleanup:631 exc.add_note('输出命名锁释放失败:' + str(cleanup))632 published_manifest = manifest if publication is not None and publication.completed else None633 if published_manifest is not None:634 exc.completed_manifest = published_manifest635 if self._readable_writer is not None:636 try:637 self._readable_writer.close()638 self._readable_writer = None639 except BaseException as cleanup:640 self._closing = True641 exc.analysis_result_cleanup_owner = self642 exc.add_note('可读关联索引关闭失败:' + type(cleanup).__name__)643 raw_failure = False644 raw_failure=raw_failure or (self._artifact_exports is not None and645 getattr(exc,'artifact_export_store_cleanup_owner',None) is self._artifact_exports)646 if raw_failure:647 # 取消也必须保最高结果owner;不能到普通异常包装后才补这条真实责任。648 exc.analysis_result_cleanup_owner = self649 self.status = ('analysis_complete' if published_manifest is not None else650 'cancelled' if isinstance(exc, (KeyboardInterrupt, SystemExit)) else 'output_failed')651 self._failure_count += 1652 # 安装失败时移除临时completed名称,把现存清单改标incomplete;只操作本次新文件。653 if (temporary is not None) and (not self.metadata.retain_work):654 self._pending_output_cleanup = (temporary, temporary_identity)655 try:656 remove_owned_directory(*self._pending_output_cleanup)657 self._pending_output_cleanup = None658 except BaseException as cleanup:659 self._closing = True660 exc.analysis_result_cleanup_owner = self661 exc.add_note('输出临时目录清理失败,可重试close:'+type(cleanup).__name__)662 elif temporary is not None:663 marker = temporary / 'run_manifest.json'664 try:665 if marker.exists():666 with marker.open('w', encoding='utf-8') as handle:667 handle.write(_json({'status': self.status, 'run_id': self.metadata.run_id}) + '\n')668 with (temporary / 'failure.json').open('x', encoding='utf-8') as handle:669 handle.write(_json({'status': self.status, 'error_type': type(exc).__name__}) + '\n')670 except BaseException as diagnostic:671 # 诊断写入失败不替换发布原错;原磁盘失败限制及incomplete目录保持。672 if not isinstance(diagnostic, OSError):673 exc.add_note(f'发布临时诊断附加失败: {type(diagnostic).__name__}')674 try:675 path = self.spool.path / f'output_failure-{self._failure_count:06d}.json'676 self._write_state(path, self.status)677 self.manifest_path = (None if (not (self.metadata.detailed_diagnostics or self.metadata.retain_work)) else path)678 except BaseException as diagnostic:679 if not isinstance(diagnostic, (OSError, BudgetExceededError)):680 exc.add_note(f'发布状态诊断附加失败: {type(diagnostic).__name__}')681 if isinstance(exc, (KeyboardInterrupt, SystemExit)):682 raise683 error = AnalysisError(f'结果发布失败,状态{self.status}')684 if published_manifest is not None:685 error.completed_manifest = published_manifest686 if raw_failure or self._pending_output_cleanup is not None or self._readable_writer is not None:687 # 原始复制的实际FD仍由本结果的仓持有;包装不得丢最高恢复入口。688 error.analysis_result_cleanup_owner = self689 failure = error690 raise error from exc691 finally:692 # 局部with已关闭真实输出;发布槽的实际余额由同一owner保存供失败后close。693 try:694 _release_owned_ram(self, self._writer_cost, '_writer_cost')695 except BaseException as cleanup:696 if publication is not None and publication.completed:697 cleanup.completed_manifest = manifest698 if self._writer_cost:699 self._closing = True700 target = cleanup if failure is None else failure701 target.analysis_result_cleanup_owner = self702 if failure is None:703 raise704 failure.add_note(f'发布workspace清理附加失败: {type(cleanup).__name__}')705706 def close(self) -> None:707 """关闭活跃游标并释放资源;默认清理本次自有暂存,保留原始输入与已发布结果。708709 参数: 无。返回: None;重复调用安全。diagnostics.retain_work为True时保留工作现场。710 异常: 关闭失败仍尝试其余句柄,最后抛首个原错误;未完成清理时可重试close。711 """712 if self._closed:713 return714 # 清理中拒绝新读取/发布;尝试全部读取器,但不提前丢失失败句柄或自有费用。715 self._closing = True716 error = None717 for iterator in tuple(self._iterators):718 try:719 iterator.close()720 except BaseException as exc:721 if error is None:722 error = exc723 else:724 error.add_note(f'其他结果读取器关闭失败: {type(exc).__name__}')725 if error is not None:726 # 原首错和关闭顺序不变,最高结果仍保留失败的真实游标供恢复。727 error.analysis_result_cleanup_owner = self728 raise error729 try:730 # 各层只释放自己的实际申请,子spool从不抵销本层或调用方的借用余额。731 if self._readable_writer is not None:732 self._readable_writer.close()733 self._readable_writer = None734 if self._artifact_exports is not None:735 self._artifact_exports.close()736 if not self._artifact_exports.closed:737 raise AnalysisError('新导出仓没有真实关闭')738 _release_owned_ram(self, self._writer_cost, '_writer_cost')739 _release_owned_ram(self, self._metadata_cost, '_metadata_cost')740 self.spool.close()741 if self._pending_output_cleanup is not None:742 remove_owned_directory(*self._pending_output_cleanup)743 self._pending_output_cleanup = None744 if not self._work_removed:745 remove_owned_directory(*self._owned_work)746 self._work_removed = True747 except BaseException as exc:748 # 新原始仓持续关闭失败仍从本结果恢复,不要求调用方绕过其真实父。749 exc.analysis_result_cleanup_owner = self750 raise751 finally:752 if (not self._iterators and self._held_cost == 0 and self.spool._closed753 and self._work_removed and self._pending_output_cleanup is None754 and (self._artifact_exports is None or self._artifact_exports.closed)):755 self._closed = True756757 def __enter__(self) -> 'AnalysisResult':758 """进入结果上下文,不再次执行输入读取。759760 参数: 无。返回: self。异常: ResultClosedError为已关闭。761 """762 self._require_open()763 return self764765 def __exit__(self, exc_type, exc_value, traceback):766 """退出上下文时释放资源,保留业务原异常。767768 参数: exc_type/exc_value/traceback为上下文异常。返回: False,不吞异常。769 异常: 无原业务异常时关闭错误传播,否则原业务异常优先。770 """771 try:772 self.close()773 except BaseException as cleanup:774 if exc_value is None:775 raise776 # 原上下文异常优先,但持续关闭失败仍说明实际恢复入口。777 exc_value.add_note(f'结果上下文清理附加失败: {type(cleanup).__name__}')778 if not self._closed:779 exc_value.analysis_result_cleanup_owner = self780 return False781