Chronaxis用户手册English
本页目录

chronaxis.results.artifact_exports

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""拥有本次新增导出目录及有界索引,发布时只复制并核对实际文件。23每个导出至多16个普通文件;片段数量增长只增加磁盘索引,不积累路径列表。4仅请求完整性诊断时保存摘要;复制沿必要读取计算,普通运行核文件身份与大小。5"""67from pathlib import Path8from ..core.validation import require_text9import os10import stat11from hashlib import sha25612import json13from .frame_sort import FrameSort14from .budget import _reserve_owned_ram,_release_owned_ram,_cleanup_owner15from .errors import ResultClosedError,ResultFormatError16from .raw_exports import _write_all171819class ArtifactExportStore:20    """保存一次会话的信号/报文导出及独立清单,不借用原数据reader。"""2122    def __init__(self,budget,path,session_id,device_label,*,integrity=True):23        """创建独占目录及索引。参数: 预算/path/会话/设备。返回: owner。异常: 配置/IO/预算错。24        """25        if type(integrity) is not bool: raise TypeError('integrity必须为bool')26        self._integrity=integrity; self.index_info=None27        self._budget=budget; self.path=Path(path); self.session_id=session_id; self.device_label=device_label28        self._held_cost=self._fixed_cost=0; self._sort=None; self._input=self._output=self._index=self._cursor=None29        self.count=0; self.state='building'; self._closed=False30        try:31            require_text(device_label, 'device_label')32            _reserve_owned_ram(self,524288,'_fixed_cost')33            self.path.mkdir(parents=True,exist_ok=False)34            try:35                self._sort=FrameSort(self.path/'inventory',budget,integrity=integrity)36            except BaseException as error:37                self._sort=getattr(error,'frame_sort_cleanup_owner',None); raise38        except BaseException as error:39            _cleanup_owner(self,error,'artifact_export_store_cleanup_owner','导出索引构造'); raise4041    @property42    def closed(self):43        """读取真实关闭状态。参数: 无。返回: bool。异常: 无。44        """45        return self._closed4647    @property48    def owned_ram(self):49        """汇总本仓及索引实际费用供父层核账。参数: 无。返回: bytes。异常: 无。50        """51        return self._held_cost+(0 if self._sort is None else self._sort._held_cost)5253    def next_path(self):54        """提供当前导出独占子目录。参数: 无。返回: Path。异常: 非building。55        """56        if self._closed or self.state!='building':57            raise ResultClosedError('导出索引不能新增')58        return self.path/f'export-{self.count:08d}'5960    def _close_slot(self,slot):61        """真实close成功后撤槽。参数: slot责任名。返回: None。异常: 原close错,保槽重试。62        """63        owner=getattr(self,slot)64        if owner is not None:65            owner.close(); setattr(self,slot,None)6667    @staticmethod68    def _identity(path):69        """取得普通文件的替换/修改标识。参数: Path。返回: 四整数tuple。异常: 链接/非普通文件/IO。"""70        info=path.lstat()71        if path.is_symlink() or not stat.S_ISREG(info.st_mode):72            raise ResultFormatError('导出内容必须为普通文件')73        return (info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)7475    def _file(self,path,destination=None,expected=None,identity=None):76        """必要复制时才读内容;请求摘要则在同次读取计算。参数: 路径/目标/大小摘要/身份。返回: 大小和可选摘要。"""77        block = None78        try:79            if self._input is not None or self._output is not None:80                raise ResultClosedError('导出文件还有未清理句柄')81            before=self._identity(path)82            # 摘要模式会核实际内容,允许修复为原字节后重试;普通模式没有内容依据,仍拒身份变化。83            if identity is not None and tuple(identity)!=before and not self._integrity:84                raise ResultFormatError('导出文件已被替换或修改')85            if expected is not None and before[2]!=expected[0]:86                raise ResultFormatError('导出文件大小与登记不同')87            if destination is None and not self._integrity:88                return before[2],None89            self._input=path.open('rb',buffering=0)90            opened=os.fstat(self._input.fileno())91            if (opened.st_dev,opened.st_ino,opened.st_size,opened.st_mtime_ns)!=before:92                raise ResultFormatError('导出文件在打开时变化')93            if destination is not None:94                self._output=destination.open('xb',buffering=0)95            digest=sha256() if self._integrity else None; count=096            while True:97                block=self._input.read(65536)98                if not block: break99                count+=len(block)100                if count>before[2]: raise ResultFormatError('导出文件超过登记大小')101                if digest is not None: digest.update(block)102                if self._output is not None:103                    self._budget.reserve_spool(len(block)); _write_all(self._output,block)104            value=None if digest is None else digest.hexdigest()105            if count!=before[2] or self._identity(path)!=before:106                raise ResultFormatError('导出文件在读取时变化')107            if expected is not None and expected[1] is not None and value!=expected[1]:108                raise ResultFormatError('导出文件内容与登记摘要不同')109            self._close_slot('_output'); self._close_slot('_input')110            # 写成功和真实close已确认;不再把新目标整份回读一次。111            return count,value112        finally:113            # 文件槽仍由本对象的可重试关闭负责;这里只清异常帧内的当前复制块。114            block = None115116    def append(self,export_type,scope,interval,*,segment_id=None,completed_files=None):117        """登记刚生成目录中有限文件的实际身份。参数: 类型/范围/区间/片段ID。返回: None。异常: 文件/索引错。118        """119        path=self.next_path(); files=[]120        # 内部writer交出的有限事实避免登记时再次扫描;旧调用未交事实则按原开关读取。121        supplied=None if completed_files is None else {item['name']:item for item in completed_files}122        if supplied is not None and len(supplied)>16: raise ValueError('导出文件事实超过上限')123        try:124            if export_type not in ('signal_csv','can_frames') or scope not in ('session','segments'):125                raise ValueError('导出种类或范围错误')126            for entry in path.iterdir():127                if len(files)>=16 or not entry.is_file() or entry.is_symlink() or not entry.name.isascii():128                    raise ValueError('导出目录只允许至多16个普通文件')129                identity=self._identity(entry)130                if supplied is None:131                    size,digest=self._file(entry,identity=identity)132                else:133                    fact=supplied.pop(entry.name,None)134                    if fact is None or fact['bytes']!=identity[2]:135                        raise ResultFormatError('导出文件与writer事实不符')136                    size,digest=fact['bytes'],fact.get('sha256') if self._integrity else None137                    if self._integrity and (type(digest) is not str or len(digest)!=64):138                        raise ResultFormatError('请求摘要但writer没有提供')139                files.append(dict(name=entry.name,bytes=size,_identity=identity,140                    **({} if digest is None else {'sha256':digest})))141            if supplied: raise ResultFormatError('writer登记的文件缺失')142            if not any(f['name']=='manifest.json' for f in files):143                raise ValueError('导出缺完成清单')144            self._sort.append((self.count,),dict(index=self.count,export_type=export_type,scope=scope,segment_id=segment_id,145                interval=None if interval is None else [interval.start_ns,interval.end_ns],146                directory=path.name,files=sorted(files,key=lambda f:f['name']),device_label=self.device_label))147            self.count+=1148        except BaseException as error:149            self.state='failed'; error.artifact_export_store_cleanup_owner=self; raise150151    def seal(self):152        """封口目录索引后才允许移交。参数: 无。返回: None。异常: 状态/排序IO错。153        """154        self.next_path(); self._sort.seal(); self.state='sealed'155156    def copy_to(self,destination):157        """逐目录复制到新发布位置,按请求在同次读取核摘要。参数: 新结果根。返回: exports.jsonl路径。异常: IO/完整性错。158        """159        if self._closed or self.state!='sealed' or self._cursor is not None or self._index is not None:160            raise ResultClosedError('导出索引未封口或有发布资源待恢复')161        root=Path(destination)/'exports'; root.mkdir(exist_ok=False)162        index=Path(destination)/'exports.jsonl'163        index_digest=sha256() if self._integrity else None; index_bytes=0164        self.index_info=None165        try:166            self._index=index.open('xb',buffering=0)167            self._cursor=self._sort._read(self._sort.path/f'{self._sort.level}-0.jsonl')168            count=0169            for key,sequence,record in self._cursor:170                if key!=[count] and key!=(count,):171                    raise ResultFormatError('导出索引序号不连续')172                if record['directory']!=f'export-{count:08d}':173                    raise ResultFormatError('导出索引目录错误')174                target=root/record['directory']; target.mkdir(exist_ok=False)175                for entry in record['files']:176                    name=entry['name']177                    if Path(name).name!=name or '/' in name or '\\' in name or ':' in name:178                        raise ResultFormatError('导出索引包含不安全文件名')179                    self._file(self.path/record['directory']/name,target/name,180                        (entry['bytes'],entry.get('sha256')),entry['_identity'])181                    # 修改标识仅用于当前文件生命周期,不逐文件发布另一套证明。182                    del entry['_identity']183                payload=(json.dumps(record,ensure_ascii=False,allow_nan=False,sort_keys=True)+'\n').encode('utf-8')184                self._budget.reserve_spool(len(payload)); _write_all(self._index,payload); count+=1185                if index_digest is not None: index_digest.update(payload)186                index_bytes+=len(payload)187            if count!=self.count:188                raise ResultFormatError('导出索引数量不符')189            self._close_slot('_cursor'); self._close_slot('_index')190            self.index_info=dict(name=index.name,bytes=index_bytes,191                **({} if index_digest is None else {'sha256':index_digest.hexdigest()}))192            return index193        except BaseException as error:194            error.artifact_export_store_cleanup_owner=self195            try:196                self.recover_copy()197            except BaseException as cleanup:198                error.add_note('导出复制清理失败: '+type(cleanup).__name__)199            raise200201    def recover_copy(self):202        """关闭失败发布句柄,可重新发布到另一新目录。参数: 无。返回: None。异常: 实际close错可重试。203        """204        for slot in ('_output','_input','_cursor','_index'):205            self._close_slot(slot)206        # 生成器已退出而真实close持续失败时,句柄仍登记在索引仓,重试必须处理它们。207        if self._sort is not None:208            while self._sort.handles:209                self._sort._close_file(self._sort.handles[-1])210211    def close(self):212        """真实关闭索引及IO后释放自己的费用,保留磁盘。参数: 无。返回: None。异常: 原close错。213        """214        if self._closed:215            return216        self.recover_copy(); self._close_slot('_sort')217        _release_owned_ram(self,self._fixed_cost,'_fixed_cost'); self._closed=True218
Chronaxis 用户手册 · 离线阅读