Chronaxis用户手册English
本页目录

chronaxis.results.spool

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""用有限缓冲暂存当前平坦结果,再按稳定业务键完成有界外部归并。23只为请求的表创建文件,每个缓冲先付编码与排序所需费用,再写JSONL。4活动/片段须已形成最终事实;此模块不读取原日志或调用解码器。5归并每次处理两路且只持当前行,路径按层次和计数生成,不保存全部run列表。6失败关闭句柄并清理自有RAM,保留原错误和可重试的最高所有者。7文件生命周期由结果所有者管理;本层不删除用户文件或仍有消费者的暂存。8"""910# 归并只使用标准库文件和固定模型;文件名根据常量表名/层次/计数构造。11from contextlib import ExitStack12import json13from pathlib import Path14import tempfile15from .budget import ResourceBudget, _reserve_owned_ram, _release_owned_ram, _cleanup_owner16from .codec import decode_row, encode_row, estimate_row_bytes17from .rows import ResultRow, row_table, row_sort_key18from .summary import SUMMARY_MODELS19from .owned_directory import directory_identity20from .errors import AnalysisError, BudgetExceededError, ResultClosedError, ResultFormatError2122TABLES = ('events', 'segments', 'calculations')232425def _buffer_key(item):26    """取已缓存排序键,避免sort期间生成无界的新对象树。2728    参数: item为键/bytes/已付金额的tuple。返回: tuple键。29    异常: 内部结构错误直接传播。30    """31    return item[0]323334class ResultSpool:35    """保存building/analysis_complete/failed/cancelled状态及本次固定启用表计数。3637    序号必须严格递增;每个缓冲排序后写run,seal完成预算内有界归并。38    不接受已有工作目录,不删除任何暂存;close取消未完成构建。39    路径记录只含有限请求表的当前层和run数量,不随结果数保留Python路径列表。40    _fixed_cost/_pending_cost/_buffer_cost/_merge_cost合计为_held_cost,仅含自有RAM。41    _closing拒绝半关闭继续构建;_closed只有全部自有费用实际释放后才成立。42    """4344    def __init__(self, budget: ResourceBudget, work_dir=None, result_buffer_rows: int = 4096, *, enable_sequences: bool = False, requested_tables=None, failure_diagnostics=True, enable_contributions=False, enable_trace=False,enable_selections=False,enable_aggregations=False):45        """创建唯一工作目录并预留固定状态成本。4647        参数: budget为共享账本;work_dir为全新目录或None;result_buffer_rows为行上限;48            enable_sequences为原生bool,按需增加独立配对结果表;requested_tables可明确取其子集,未请求表不创建;failure_diagnostics决定是否另写失败文件。49            enable_contributions/enable_trace/enable_selections按请求增加对应表。50        返回: ResultSpool。异常: ValueError/TypeError为配置;预算/目录/取消原样传播;51            持续清理失败附result_spool_cleanup_owner。已有目录保持原样,不写失败诊断。52        """53        if not isinstance(budget, ResourceBudget) or type(result_buffer_rows) is not int or result_buffer_rows <= 0:54            raise ValueError('暂存需ResourceBudget和正原生缓冲行数')55        if type(enable_sequences) is not bool:56            raise TypeError('enable_sequences需要原生bool')57        if type(failure_diagnostics) is not bool:58            raise TypeError('失败诊断开关需要bool')59        if any(type(flag) is not bool for flag in (enable_contributions,enable_trace,enable_selections,enable_aggregations)):60            raise TypeError('精简贡献/输入追溯开关需要bool')61        available = (TABLES + (('sequences',) if enable_sequences else ())62                     + (('contributions',) if enable_contributions else ()) + (('input_trace',) if enable_trace else ())63                     + (('selections',) if enable_selections else ()) + (('aggregations',) if enable_aggregations else ()))64        if requested_tables is not None and (type(requested_tables) is not tuple65                or any(type(table) is not str or table not in available for table in requested_tables)66                or len(set(requested_tables)) != len(requested_tables)):67            raise ValueError('请求结果表需已启用白名单中的不重复tuple')68        # 所有可清理字段先于首次预算调用,未返回的对象也能保存真实申请归属。69        self.budget = self._budget = budget70        self.path = None71        self._directory_identity = None72        self._failure_diagnostics = failure_diagnostics73        self._buffer = []74        self._row_limits = None75        self._merge_run_resources = None76        self._fixed_cost = self._pending_cost = self._buffer_cost = self._merge_cost = self._held_cost = 077        self._identity_cost = 078        self._closed = self._closing = False79        self._summary_row_count = 0  # 仅已实际写入的精简行计数,不重扫文件判断格式。80        self._summary_versions = {}  # 有限schema版本集合,随成功append登记,不扫描结果。81        self.identity_summary = None  # 三个共同身份;出现多个值时该位置为None,不保留所有身份。82        self.state = 'building'83        self.tables = available if requested_tables is None else requested_tables84        try:85            # 固定描述与可选配对表状态先预付后创建。86            _reserve_owned_ram(self, 4096 + (4096 if enable_sequences else 0), '_fixed_cost')87            # 只在成功新建后记录自有目录;已有路径拒绝时不向它写入或删除任何文件。88            if work_dir is None:89                path = Path(tempfile.mkdtemp(prefix='chronaxis-result-'))90            else:91                path = Path(work_dir).absolute()92                path.mkdir(parents=True, exist_ok=False)93            self.path = path94            if requested_tables is not None:95                self._directory_identity = directory_identity(path)96            self.result_buffer_rows = result_buffer_rows97            # 原行上限与两路读取余量保持;这些是需求值,实际已付费用另在槽位中。98            self.max_row_bytes = max(1, budget.memory_budget_bytes // 256)99            self.reader_cost = self.max_row_bytes * 64 + 8192100            self._runs = dict.fromkeys(self.tables, 0)101            self.counts = dict.fromkeys(self.tables, 0)102            # 启用表的固定小字典包含在原固定状态费;只记成功追加的编码行长,103            # 不保存每run/每行索引,也不改变任何表的编码硬上界。104            self._row_limits = dict.fromkeys(self.tables, 0)105            self.files = {}106            self._last_sequence = -1107            self.peak_buffer_rows = 0108        except BaseException as exc:109            _cleanup_owner(self, exc, 'result_spool_cleanup_owner', '结果暂存构造')110            raise111112    def _require_building(self) -> None:113        """拒绝关闭或已封口后的追加,保持最终结果不变。114115        参数: 无。返回: None。异常: ResultClosedError/AnalysisError为状态不符。116        """117        if self._closed or self._closing:118            raise ResultClosedError('结果暂存已关闭或正在清理')119        if self.state != 'building':120            raise AnalysisError(f'不能追加到{self.state}暂存')121122    def row_limit(self, table: str) -> int:123        """提供本表成功追加的实际最大行长,所有表仍受原编码硬限约束。124125        参数: table为本次启用的固定表名。返回: 正bytes上界,空表为1。126        异常: ResultFormatError为未知表或内部行长越界;不读取文件猜大小。127        """128        # 此界仅来自成功encode;损坏文件不能用stat扩容后继续解码。129        if table not in self.tables:130            raise ResultFormatError('未启用的结果表')131        maximum = max(1, self._row_limits[table])132        if type(maximum) is not int or not 1 <= maximum <= self.max_row_bytes:133            raise ResultFormatError('结果表已知最大行长非法')134        return maximum135136    def reader_cost_for(self, table: str) -> int:137        """按已知表上界预付解析及FD空间,原64倍工作费不降低。138139        参数: table为固定表名。返回: 整数bytes。140        异常: 沿row_limit的类型/上界检查;不创建reader或分配数组。141        """142        # 按本表最大已编码行预付解码工作区及一个实际文件缓冲。143        cost = self.row_limit(table) * 64 + 8192144        return max(16384, cost)145146    def append(self, row: ResultRow) -> None:147        """预算检查后编码并暂存一行,必要时先释放已有缓冲。148149        参数: row为最终typed行且sequence严格递增。返回: None。150        异常: AnalysisError/预算/格式错误;失败后不再接受追加。151        """152        try:153            # 公共入口始终使用原模型检查;内部复用不会让调用者提交私有事实。154            self._append_core(row)155        finally:156            row = None157158    def _append_core(self, row) -> None:159        """预算先于编码,成功登记后再按有限行数落盘。160161        参数: row为当前冻结行。返回: None。异常: 原预算/格式/IO/取消错误。162        pending金额在安装时转入buffer槽;失败只清本对象费用和借入引用。163        """164        try:165            self._require_building()166        except BaseException:167            row = None168            raise169        try:170            table = row_table(row)171            if table not in self.tables:172                raise ResultFormatError('本次结果暂存未启用该表')173            if table in self.files:174                raise ResultFormatError('该结果表已完成,不能继续追加')175            if row.sequence <= self._last_sequence:176                raise ResultFormatError('结果sequence必须严格递增且不重复')177            cost = estimate_row_bytes(row)178            if self._buffer and self.budget.ram_bytes + cost > self.budget.memory_budget_bytes:179                self._flush()180            _reserve_owned_ram(self, cost, '_pending_cost')181            payload = encode_row(row, self.max_row_bytes)182            key = (table, row_sort_key(row))183            self._buffer.append((key, payload, cost))184            # 同一owner内转移实际已付费用,不重复申请或提前释放缓冲的归属。185            self._buffer_cost += self._pending_cost186            self._pending_cost = 0187            self._last_sequence = row.sequence188            self.counts[table] += 1189            self._summary_row_count += 1190            self._summary_versions.setdefault(table, set()).add(SUMMARY_MODELS[type(row)][2])191            # 同次已计算行顺带维护固定大小身份摘要,导出无需为省略重复身份再扫描明细。192            identity = (row.dataset_id, row.entity_id, row.session_id)193            if self.identity_summary is None:194                _reserve_owned_ram(self, 256 + 8 * sum(len(value) for value in identity), '_identity_cost')195                self.identity_summary = identity196            else:197                self.identity_summary = tuple(old if old == new else None198                                              for old, new in zip(self.identity_summary, identity))199            self._row_limits[table] = max(self._row_limits[table], len(payload))200            self.peak_buffer_rows = max(self.peak_buffer_rows, len(self._buffer))201            row = payload = key = None202            if len(self._buffer) >= self.result_buffer_rows:203                self._flush()204        except BaseException as exc:205            row = payload = key = None206            self._fail(exc)207            raise208        finally:209            row = payload = key = None210211    def _run_path(self, table: str, level: int, index: int) -> Path:212        """以受控常量和计数生成run路径,不接受用户行中的路径。213214        参数: table为固定表名,level/index为内部整数。返回: Path。215        异常: 无;只生成路径,不创建文件。216        """217        return self.path / f'{table}-L{level}-{index:012d}.jsonl'218219    def _write_bytes(self, handle, payload: bytes) -> None:220        """配额预留后完整写bytes,短写继续,零写明确失败。221222        参数: handle为有限缓冲或无缓冲二进制输出;payload为有限行。返回: None。223        异常: BudgetExceededError/OSError为配额、磁盘满或短写失败。224        """225        view = None226        try:227            self.budget.reserve_spool(len(payload))228            view = memoryview(payload)229            while view:230                # FileIO可能短写;不能把部分写入当成成功结果。231                written = handle.write(view)232                if not written:233                    raise OSError('结果暂存写入未推进')234                view = view[written:]235        finally:236            # 不调用可能再抛错的view.release;只断本层借入引用和真实memoryview。237            view = payload = handle = self = None238239    def _clear_buffer(self) -> None:240        """抵销所有缓冲申请并释放对象,不删除已写文件。241242        参数: 无。返回: None。异常: 原释放/取消传播,真实未抵销费用可由close重试。243        """244        # 先丢本层缓冲引用,再一次抵销实际缓冲总费,避免逐条释放后仍留旧费用tuple。245        self._buffer.clear()246        _release_owned_ram(self, self._buffer_cost, '_buffer_cost')247248    def _flush(self) -> None:249        """将有限缓冲排序并按表依次写run,异常也释放RAM。250251        参数: 无。返回: None。异常: 文件或预算错误原样传播。252        """253        # Timsort键/临时指针已包含每行保守估算;同时只开一个输出句柄。254        failure = None255        try:256            self._buffer.sort(key=_buffer_key)257            with ExitStack() as stack:258                table = None259                handle = None260                for key, payload, _ in self._buffer:261                    if key[0] != table:262                        if handle is not None:263                            handle.close()264                        table = key[0]265                        run_path = self._run_path(table, 0, self._runs[table])266                        handle = stack.enter_context(run_path.open('xb', buffering=0))267                        self._runs[table] += 1268                    self._write_bytes(handle, payload)269        except BaseException as exc:270            failure = exc271            raise272        finally:273            # 清理取消不能替代写入原错误;未成功清账的buffer费用仍保留在当前owner。274            key = payload = None275            try:276                self._clear_buffer()277            except BaseException as cleanup:278                if failure is None:279                    raise280                failure.add_note(f'暂存缓冲清理附加失败: {type(cleanup).__name__}')281282    def _read_entry(self, handle, maximum=None):283        """有界读取一行,按当前格式重建稳定键。284285        参数: handle为实际二进制文件;maximum是已付行上界,省略时用硬限。286        返回: (排序键, 原bytes),EOF返回None。异常: 超限、缺换行、格式或IO原错。287        """288        maximum = self.max_row_bytes if maximum is None else maximum289        payload = row = None290        try:291            payload = handle.readline(maximum + 1)292            if not payload:293                return None294            if len(payload) > maximum or len(payload) > self.max_row_bytes or not payload.endswith(b'\n'):295                raise ResultFormatError('暂存行超限或缺少完整换行')296            row = decode_row(payload, maximum)297            return row_sort_key(row), payload298        finally:299            # 原TB保留,但释放读槽后不继续借持解码对象或大字节串。300            payload = row = None301302    def _merge(self, left: Path, right: Path | None, destination: Path, *, table=None) -> None:303        """用双路预算,将真实FD关闭与恢复交给共用归并owner。304305        参数: left/right为原有限输入,destination为新run,table为固定表或None(采用全局硬限)。306        返回: None。异常: 原格式、预算、IO或取消;持续关闭失败保实际owner供重试。307        """308        from .merge_runs import merge_runs309        merge_runs(self, (left,) if right is None else (left, right), destination, table=table)310311    def seal(self) -> None:312        """落盘并完成有限外部排序,成功后标analysis_complete。313314        参数: 无;调用侧应已完成结果生成并交付最终行。315        返回: None,files保存本次固定表的最终排序路径。异常: 失败标failed并关闭资源。316        """317        self._require_building()318        try:319            self._flush()320            for table in self.tables:321                self._seal_table(table)322            self.state = 'analysis_complete'323        except BaseException as exc:324            self._fail(exc)325            raise326327    def _seal_table(self,table):328        """完成一个有后续消费者的最终表,其他表仍可追加,不另复制中间阶段。329330        参数: 本次已启用表名。返回: 最终有序路径;重复调用复用同一文件。331        异常: 未启用表/写入/排序失败;调用方按原运行资源树清理。332        """333        self._require_building()334        if table not in self.tables:335            raise ResultFormatError('不能封存未启用结果表')336        if table in self.files:337            return self.files[table]338        self._flush()339        count, level = self._runs[table], 0340        if count == 0:341            # 空表仍有真实文件,发布时可验证三表齐全。342            self._run_path(table, 0, 0).touch(exist_ok=False)343            count = 1344        while count > 1:345            # 只保留整数层次/数量,路径按索引生成,RAM不随run数增长。346            for index in range(0, count, 2):347                destination = self._run_path(table, level + 1, index // 2)348                right = self._run_path(table, level, index + 1) if index + 1 < count else None349                self._merge(self._run_path(table, level, index), right, destination, table=table)350            count = (count + 1) // 2351            level += 1352        self.files[table] = self._run_path(table, level, 0)353        return self.files[table]354355    def _fail(self, exc: BaseException) -> None:356        """保存明确失败状态,诊断写失败不能掩盖原异常。357358        参数: exc为原失败。返回: None。异常: 无正常新增异常。359        """360        self.state = 'cancelled' if isinstance(exc, (KeyboardInterrupt, SystemExit)) else 'failed'361        # 失败状态阻止继续追加,清理错误只作为附注;尚未释放费用由实际owner保留。362        try:363            self._clear_buffer()364        except BaseException as cleanup:365            exc.add_note(f'暂存失败缓冲清理附加失败: {type(cleanup).__name__}')366        if self._merge_run_resources is not None:367            try:368                self._merge_run_resources.close()369            except BaseException as cleanup:370                exc.add_note(f'多路归并真实资源清理附加失败: {type(cleanup).__name__}')371                exc.result_spool_cleanup_owner = self372        self.identity_summary = None373        for slot in ('_pending_cost', '_merge_cost', '_identity_cost'):374            if slot == '_merge_cost' and self._merge_run_resources is not None:375                # helper仍持真实未关FD或未清费用,不能从父层提前抵销同一读槽。376                continue377            try:378                _release_owned_ram(self, getattr(self, slot), slot)379            except BaseException as cleanup:380                exc.add_note(f'暂存失败{slot}清理附加失败: {type(cleanup).__name__}')381        if self._pending_cost or self._buffer_cost or self._merge_cost or self._identity_cost or self._merge_run_resources is not None:382            exc.result_spool_cleanup_owner = self383        if not self._failure_diagnostics:384            return385        try:386            # 小型独立诊断不计作完成清单;磁盘满/配额不足时仅保留对象状态。387            data = json.dumps({'status': self.state, 'error_type': type(exc).__name__,388                               'resources': self.budget.snapshot()}, ensure_ascii=False).encode()389            with (self.path / 'failure.json').open('xb', buffering=0) as handle:390                self._write_bytes(handle, data)391        except BaseException as diagnostic:392            # 原诊断失败也不能覆盖业务错误;磁盘/配额原限制保持,其余错误留下附注。393            if not isinstance(diagnostic, (OSError, BudgetExceededError)):394                exc.add_note(f'暂存失败诊断附加失败: {type(diagnostic).__name__}')395396    def close(self) -> None:397        """取消未完成构建,或释放封口对象的固定RAM;重复关闭安全。398399        参数: 无。返回: None。异常: 原释放/取消传播,未释放金额保留,重复关闭可重试。400        """401        if self._closed:402            return403        # 开始清理即拒绝新构建;只释放实际自有槽位,不抵销共享账本中的借用余额。404        self._closing = True405        if self._merge_run_resources is not None:406            try:407                self._merge_run_resources.close()408            except BaseException as exc:409                exc.result_spool_cleanup_owner = self410                raise411        self._clear_buffer()412        if self.state == 'building':413            self.state = 'cancelled'414        self.identity_summary = None415        try:416            for slot in ('_pending_cost', '_merge_cost', '_identity_cost', '_fixed_cost'):417                _release_owned_ram(self, getattr(self, slot), slot)418        finally:419            # 释放后取消同样完成最小关闭;释放前取消仍可重试,不能提前closed。420            if self._held_cost == 0 and self._merge_run_resources is None:421                self._closed = True422
Chronaxis 用户手册 · 离线阅读