Chronaxis用户手册English
本页目录

chronaxis.results.readable

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""将结果流关联成可直接阅读的CSV,不重新读取或计算输入。23事件、窗口及计算的既有排序不同,先用临时SQLite索引关联,再逐行发布。4索引只有本次结果,固定缓存、禁止mmap、写前预付页面;关闭后删除,不发布5原表副本。CSV、JSONL旧路径不调用本模块。所有权保存在AnalysisResult供失败恢复。6"""78# SQLite属于Python标准库;只创建本次发布目录中的私有临时文件。9import csv10import hashlib11import json12import sqlite313from contextlib import contextmanager14from itertools import chain15from math import ceil16from .budget import _reserve_owned_ram, _release_owned_ram17from .errors import ResultFormatError18from .summary import summary_fields19from .readable_format import STATES, REASONS, seconds, explanation, quality_text2021IDENTITY = ('dataset_id', 'entity_id', 'session_id')22LABELS = ('数据集', '对象', '会话')23FILES = {'events': '事件结果.csv', 'segments': '窗口结果.csv', 'calculations': '会话结果.csv',24         'sequences': '配对结果.csv', 'selections': '选择结果.csv', 'aggregations':'聚合结果.csv',25         'contributions': '贡献明细.csv', 'input_trace': '输入追溯.csv'}262728def _json(value):29    """编码临时索引值。参数: 平坦字段。返回: JSON文本。异常: 非有限值拒绝。"""30    return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':'))313233class ReadableWriter:34    """拥有单次发布的关联索引及有限列定义;close可重试,不拥有输入或正式结果。"""3536    def __init__(self, owner, directory, iterator_factory):37        """仅安装可恢复字段,资源在prepare取得。3839        参数: owner为AnalysisResult,directory为本次临时目录,iterator_factory为结果游标。40        返回: 未准备的writer。异常: 无文件副作用。41        """42        self.owner, self.directory, self.iterator_factory = owner, directory, iterator_factory43        self.path = directory / '.readable-index.sqlite'44        self.connection = None45        self._pending_reader = None46        self._pending_output = None47        self._budget = owner.spool.budget48        self._held_cost = self._ram_cost = 049        self.pages = 050        self.closed = False51        self.metrics = {'session': {}, 'event': {}, 'segment': {}}52        self.counts, self.infos = {}, {}53        self.confirmation = False54        self.quality_handle = None55        self.coverage = None5657    def _execute(self, sql, values=(), size=0):58        """写前预留SQLite页面,上限由数据库本身强制执行。5960        参数: sql/values为受控语句,size为本条JSON字符数。返回: cursor。61        异常: 配额、SQL、IO或取消传播。预留包含B树分裂余量,不按总行数预占RAM。62        """63        # page_count包含事务中已分配页;保守覆盖UTF8值、索引键及B树分裂。64        count = self.connection.execute('PRAGMA page_count').fetchone()[0]65        needed = count + 16 + ceil(size * 12 / 4096)66        if needed > self.pages:67            self._budget.reserve_spool((needed - self.pages) * 4096)68            self.pages = needed69            self.connection.execute(f'PRAGMA max_page_count={needed}')70        return self.connection.execute(sql, values)7172    @contextmanager73    def _reader(self, table, *, indexed=False):74        """拥有一次结果流或索引游标,关闭失败留给writer重试。7576        参数: table为当前表;indexed选择自有SQLite游标,默认结果流。77        返回: 上下文中的可迭代读者。异常: 读错优先,关闭错附记;恢复owner保留。78        """79        if self._pending_reader is not None:80            raise RuntimeError('可读结果仍有未关闭的游标,请先重试关闭')81        reader = failure = None82        try:83            reader = (self.connection.execute('SELECT n,payload FROM objects WHERE kind=? ORDER BY n', (table,))84                      if indexed else self.iterator_factory(self.owner, table))85            self._pending_reader = reader86            yield reader87        except GeneratorExit:88            # 主动结束生成器时,关闭失败必须让调用方看到,不能吞掉资源错误。89            raise90        except BaseException as error:91            failure = error92            raise93        finally:94            try:95                if reader is not None:96                    reader.close()97            except BaseException as cleanup:98                target = cleanup if failure is None else failure99                target.readable_cleanup_owner = self100                if failure is None:101                    raise102                failure.add_note('可读结果游标清理附加失败: ' + type(cleanup).__name__)103            else:104                self._pending_reader = None105            finally:106                # 仍未关闭的资源保存在明确owner;错误帧不再另持一份游标引用。107                reader = failure = None108109    @contextmanager110    def _output(self, path):111        """持有唯一输出句柄,写入与关闭同时失败时保留原写入错误。112113        参数: path为本次新CSV路径。返回: 独占创建的二进制句柄。114        异常: 原写入错误优先;未关闭句柄归writer,关闭可以重试。115        """116        if self._pending_output is not None:117            raise RuntimeError('可读结果仍有未关闭的输出,请先重试关闭')118        handle = failure = None119        try:120            handle = path.open('xb')121            self._pending_output = handle122            yield handle123        except BaseException as error:124            failure = error125            raise126        finally:127            try:128                if handle is not None:129                    handle.close()130            except BaseException as cleanup:131                original = cleanup if failure is None else failure132                original.readable_cleanup_owner = self133                if failure is None:134                    raise135                failure.add_note('可读CSV输出关闭附加失败: ' + type(cleanup).__name__)136            finally:137                if handle is not None and handle.closed:138                    self._pending_output = None139                handle = failure = None140141    def _rows(self, table):142        """按原游标逐条读已计算结果,保持严格解析和真实关闭。143144        参数: table为启用表。返回: 平坦字段生成器。异常: 读取/校验/关闭错误传播。145        """146        row = fields = diagnostic = None147        try:148            with self._reader(table) as iterator:149                count = 0150                for row in iterator:151                    count += 1152                    fields = summary_fields(row)153                    # 显式质量诊断仍随唯一必要读取得到,不另扫一遍结果。154                    if self.quality_handle is not None:155                        state_field = {'selections': 'selection', 'contributions': 'reason',156                                       'input_trace': 'record_type'}.get(table, 'status')157                        key = table + ':' + fields[state_field]158                        self.coverage[key] = self.coverage.get(key, 0) + 1159                        diagnostic = {key: fields[key] for key in ('sequence', *IDENTITY, 'quality', state_field)}160                        diagnostic['table'] = table161                        self.quality_handle.write(_json(diagnostic) + '\n')162                    yield fields163                if count != self.owner.spool.counts.get(table, 0):164                    raise ResultFormatError('可读导出行数与暂存不一致')165        finally:166            # 生成器结束或出错后,不让本层异常帧保留刚读出的行。167            row = fields = diagnostic = iterator = None168169    def prepare(self):170        """建立必要关联和列定义,不读取原始日志。171172        参数: 无。返回: None。异常: 重复身份、重复指标、配额/IO传播。173        """174        if not any(self.owner.spool.counts.get(table, 0) for table in ('events', 'segments', 'sequences', 'calculations')):175            # 只有逐条明细或全空结果时没有关联消费者,连空索引也不创建。176            self.confirmation = True177            return178        _reserve_owned_ram(self, 262144, '_ram_cost')179        # 禁止系统临时排序文件和mmap;后续查询只走已声明索引。180        self._budget.reserve_spool(65536)181        self.pages = 16182        self.connection = sqlite3.connect(self.path, isolation_level=None)183        self.connection.execute('PRAGMA page_size=4096')184        self.connection.execute('PRAGMA max_page_count=16')185        self.connection.execute('PRAGMA cache_size=-64')186        self.connection.execute('PRAGMA mmap_size=0')187        self.connection.execute('PRAGMA journal_mode=OFF')188        self.connection.execute('PRAGMA synchronous=OFF')189        self.connection.execute('PRAGMA temp_store=MEMORY')190        self._execute('CREATE TABLE objects(kind TEXT,d TEXT,e TEXT,s TEXT,id TEXT,n INTEGER,payload TEXT,'191                      'PRIMARY KEY(kind,d,e,s,id),UNIQUE(kind,n)) WITHOUT ROWID')192        self._execute('CREATE TABLE metrics(d TEXT,e TEXT,s TEXT,scope TEXT,parent TEXT,name TEXT,payload TEXT,'193                      'PRIMARY KEY(d,e,s,scope,parent,name)) WITHOUT ROWID')194        self.connection.execute('BEGIN')195        # 每表编号来自既有稳定结果排序,不截短哈希,也不跨会话错联。196        for table in ('events', 'segments', 'sequences', 'calculations'):197            if table not in self.owner.spool.tables:198                continue199            for number, row in enumerate(self._rows(table), 1):200                payload = _json(row)201                identity = tuple(row[name] for name in IDENTITY)202                if table == 'calculations':203                    name, scope = row['calculation_id'], row['scope']204                    definition = (row['unit'], row['operation'])205                    if scope != 'session':206                        self._number('events' if scope == 'event' else 'segments', row, row['parent_id'])207                    if name not in self.metrics[scope]:208                        _reserve_owned_ram(self, 2048 + 64 * (len(name) + len(row['unit'])), '_ram_cost')209                        self.metrics[scope][name] = definition210                    elif self.metrics[scope][name] != definition:211                        raise ResultFormatError('同名计算的单位或算子不一致')212                    self._execute('INSERT INTO metrics VALUES(?,?,?,?,?,?,?)',213                                  (*identity, scope, row['parent_id'] or '', name, payload), len(payload))214                    # session本身也是关联对象;多项指标只建立一次会话结果行。215                    if scope == 'session':216                        self._execute('INSERT OR IGNORE INTO objects VALUES(?,?,?,?,?,?,?)',217                                      ('calculations', *identity, '', number, _json(dict(zip(IDENTITY, identity)))), len(payload))218                else:219                    key = {'events': 'event_id', 'segments': 'segment_id', 'sequences': 'result_id'}[table]220                    self._execute('INSERT INTO objects VALUES(?,?,?,?,?,?,?)',221                                  (table, *identity, row[key], number, payload), len(payload))222        # 一次发布共用事务,避免每条结果触发一次磁盘提交;缓存与页面上限仍逐次生效。223        self.connection.execute('COMMIT')224        self.confirmation = bool({'sequences', 'segments'} & set(self.owner.spool.tables)) or not self.metrics['event'] or any(225            operation != 'integral' for _, operation in self.metrics['event'].values())226        if not self.confirmation:227            # 同一计划可能还有独立事件;不能因别的规则做了积分就隐藏其确认时刻。228            self.confirmation = self.connection.execute(229                "SELECT 1 FROM objects o WHERE o.kind='events' AND NOT EXISTS "230                "(SELECT 1 FROM metrics m WHERE m.d=o.d AND m.e=o.e AND m.s=o.s "231                "AND m.scope='event' AND m.parent=o.id) LIMIT 1").fetchone() is not None232233    def _number(self, kind, row, identifier):234        """将完整父ID映射为本次表编号,缺失关系直接报错。235236        参数: kind为events/segments,row含完整身份,identifier可为None。237        返回: 编号或空字符串。异常: ResultFormatError为悬空父关联。238        """239        if identifier is None:240            return ''241        if self.connection is None:242            raise ResultFormatError('可读输出缺少父结果')243        found = self.connection.execute('SELECT n FROM objects WHERE kind=? AND d=? AND e=? AND s=? AND id=?',244                                        (kind, *(row[k] for k in IDENTITY), identifier)).fetchone()245        if found is None:246            raise ResultFormatError(f'可读输出缺少{kind}父结果,不能静默丢失关联')247        return found[0]248249    def _metric_columns(self, scope):250        """创建已付预算的有限指标列。参数: scope。返回: 列名列表。异常: 无。"""251        columns = []252        for name, (unit, operation) in self.metrics[scope].items():253            # min_time是时间范围内最小值,不是最小时刻;指标单位必须原样保留。254            columns.extend((f'{name}({unit})', f'{name}:状态', f'{name}:说明'))255        return columns256257    def _metric_values(self, scope, row, parent):258        """按同一完整身份取指标,未产生的指标不伪装为完整或零。259260        参数: scope/row/parent定位范围。返回: 有限单行列表。异常: 关联/JSON错误传播。261        """262        values = []263        for name, (unit, operation) in self.metrics[scope].items():264            found = self.connection.execute('SELECT payload FROM metrics WHERE d=? AND e=? AND s=? AND scope=? AND parent=? AND name=?',265                (*(row[k] for k in IDENTITY), scope, parent or '', name)).fetchone()266            if found is None:267                values.extend(('', '无此项结果', ''))268                continue269            metric = json.loads(found[0])270            value = metric['value']271            values.extend((value, STATES.get(metric['status'], metric['status']), explanation(metric)))272        return values273274    def _base(self, table, row, number):275        """形成业务列,保留事件/窗口/配对不同含义。276277        参数: table、平坦row、本次number。返回: 单行列表。异常: 悬空关联传播。278        """279        if table == 'calculations':280            return self._metric_values('session', row, '')281        if table == 'events':282            values = [number, row['rule_id'], seconds(row['start_ns'])]283            if self.confirmation:284                values.append(seconds(row['confirmed_ns']))285            values.extend((seconds(row['end_ns']), STATES[row['status']], quality_text(row['quality'])))286            return values + self._metric_values('event', row, row['event_id'])287        if table == 'segments':288            return [number, row['window_id'], seconds(row['start_ns']), seconds(row['end_ns']),289                    STATES[row['status']], '、'.join(str(self._number('events', row, p)) for p in row['parent_event_ids']),290                    quality_text(row['quality'])] + self._metric_values('segment', row, row['segment_id'])291        return [number, row['rule_id'], self._number('events', row, row['request_id']),292                self._number('events', row, row['response_id']), seconds(row['decided_ns']), seconds(row['delay_ns']),293                STATES[row['status']], quality_text(row['quality'])]294295    def _write(self, table, columns, rows):296        """借用行流写最终CSV,并随写计算显式请求的摘要。297298        参数: table/columns/rows为本次有限列和借用的流;不关闭调用者的rows。299        返回: 路径。异常: 编码、IO或取消原样传播;仅清理本层编码缓冲。300        """301        path = self.directory / FILES[table]302        digest = hashlib.sha256() if self.owner.metadata.integrity_diagnostics else None303        count, size = 0, 0304        buffer = writer = values = payload = failure = None305        try:306            # BOM使Windows表格软件能识别中文;chain不把关闭操作转发给借用流。307            with self._output(path) as handle:308                header = b'\xef\xbb\xbf'309                handle.write(header)310                size += len(header)311                if digest is not None:312                    digest.update(header)313                import io314                buffer = io.StringIO(newline='')315                writer = csv.writer(buffer)316                for index, values in enumerate(chain((columns,), rows)):317                    buffer.seek(0)318                    buffer.truncate(0)319                    writer.writerow(values)320                    payload = buffer.getvalue().encode('utf-8')321                    if handle.write(payload) != len(payload):322                        raise OSError('可读CSV写入不完整')323                    size += len(payload)324                    if digest is not None:325                        digest.update(payload)326                    if index:327                        count += 1328            self.counts[table] = count329            info = dict(name=path.name, bytes=size)330            if digest is not None:331                info['sha256'] = digest.hexdigest()332            self.infos[path.name] = info333            return path334        except BaseException as exc:335            failure = exc336            raise337        finally:338            # traceback可以继续保留本帧,但不继续持有整行和CSV缓冲;rows由调用方拥有。339            rows = columns = values = payload = writer = None340            try:341                if buffer is not None:342                    buffer.close()343            except BaseException as cleanup:344                if failure is None:345                    raise346                failure.add_note('可读CSV缓冲清理附加失败: ' + type(cleanup).__name__)347            finally:348                buffer = failure = None349350    def _write_owned_rows(self, table, columns, *, detail=False):351        """写入本对象刚创建的行流,成功、失败或取消后结束该流。352353        参数: table/columns定位本次表;detail选择明细流,默认主表。354        返回: 已写路径。异常: 原写入异常优先;关闭失败由原游标/父结果继续承担。355        """356        rows = failure = None357        try:358            rows = self._detail_rows(table) if detail else self._main_rows(table)359            return self._write(table, columns, rows)360        except BaseException as exc:361            failure = exc362            raise363        finally:364            try:365                if rows is not None:366                    rows.close()367            except BaseException as cleanup:368                if failure is None:369                    raise370                failure.add_note('可读结果行流清理附加失败: ' + type(cleanup).__name__)371            finally:372                rows = failure = columns = None373374    def write(self, quality_handle=None, coverage=None):375        """发布全部关联主表;明细由后续专门分支逐条处理。376377        参数: quality_handle/coverage为显式质量诊断,默认不生成。返回: 路径tuple。378        异常: 关联/预算/IO错误传播。379        """380        self.quality_handle, self.coverage = quality_handle, coverage381        self.prepare()382        identity = self.owner.spool.identity_summary or (None, None, None)383        self.varying = tuple(index for index, value in enumerate(identity) if value is None)384        paths = []385        columns = {386            'calculations': self._metric_columns('session'),387            'events': ['区间', '规则', '开始时间(s)'] + (['确认时间(s)'] if self.confirmation else [])388                + ['结束时间(s)', '区间状态', '说明'] + self._metric_columns('event'),389            'segments': ['窗口编号', '窗口', '开始时间(s)', '结束时间(s)', '窗口状态', '父事件区间', '说明']390                + self._metric_columns('segment'),391            'sequences': ['配对编号', '规则', '请求区间', '响应区间', '判定时间(s)', '延迟(s)', '配对状态', '说明'],392        }393        for table in ('events', 'segments', 'calculations', 'sequences'):394            if table not in self.owner.spool.tables:395                continue396            if table == 'calculations' and not self.metrics['session']:397                continue398            paths.append(self._write_owned_rows(table, [LABELS[i] for i in self.varying] + columns[table]))399        # 明细无需重排或宽表聚合,直接从原暂存逐行写出,不进入SQLite索引。400        detail_columns = {401            'aggregations': ['聚合项','分组','结果','单位','状态','原因','候选数','参与数','定义排除数','质量排除数',402                             '参与的部分值','匹配','确认超时','观测不足','未决','已完整发现总体','质量说明'],403            'selections': ['计算项', '统计范围', '所属区间', '时间(s)', '值', '单位', '数值状态', '选择状态', '说明'],404            'contributions': ['计算项', '来源', '原记录序号', '求值序号', '贡献值', '说明'],405            'input_trace': ['来源', '原记录序号', '时间(s)', '记录类型', '输入位置', '字段值', '说明'],406        }407        for table, names in detail_columns.items():408            if table in self.owner.spool.tables:409                paths.append(self._write_owned_rows(table, [LABELS[i] for i in self.varying] + names, detail=True))410        return tuple(paths)411412    def _detail_rows(self, table):413        """直接输出请求的明细,保留未知、值类型及必要定位。414415        参数: table为明细表。返回: 有界CSV行。异常: 缺失父对象或原读取错误。416        """417        rows = row = values = failure = None418        try:419            rows = self._rows(table)420            for row in rows:421                if table == 'aggregations':422                    values=[row['aggregation_id'],_json(dict(zip(row['group_keys'],row['group_values']))),423                            row['value'],row['unit'],{'complete':'完整','partial':'部分','indeterminate':'不能确定','empty':'确定为空'}[row['status']],424                            {'ok':'正常','partial_population':'总体不完整','no_observation':'无观测','insufficient_data':'观测不足',425                             'empty_population':'无参与值','insufficient_count':'样本数不足','numeric_error':'数值不能确定'}[row['reason']],426                            row['candidate_count'],row['included_count'],row['definition_excluded_count'],row['quality_excluded_count'],427                            row['included_partial_count'],row['matched_count'],row['timeout_count'],row['insufficient_observation_count'],428                            row['pending_count'],'是' if row['discovery_complete'] else '否',quality_text(row['quality'])]429                elif table == 'selections':430                    scope = row['scope']431                    parent = '' if scope == 'session' else self._number(432                        'events' if scope == 'event' else 'segments', row, row['parent_id'])433                    values = [row['calculation_id'], {'session': '会话', 'event': '事件', 'segment': '窗口'}[scope],434                              parent, seconds(row['time_ns']), row['value'], row['unit'],435                              '有效' if row['valid'] else '无效', STATES[row['selection']], explanation(row)]436                elif table == 'contributions':437                    values = [row['calculation_id'], row['source'], row['original_sequence'], row['evaluation_index'],438                              row['value'], explanation(row)]439                else:440                    kinds = {'decoded_message': '解码报文', 'signal_sample': '信号样本', 'signal_clock': '信号时刻'}441                    values = [row['source'], row['original_sequence'], seconds(row['time_ns']), kinds[row['record_type']],442                              row['locator'], row['values_json'], quality_text(row['quality'])]443                yield [row[IDENTITY[i]] for i in self.varying] + values444        except GeneratorExit:445            # 外层主动关闭时,内部关闭错误仍须传回实际所有者。446            raise447        except BaseException as exc:448            failure = exc449            raise450        finally:451            try:452                if rows is not None:453                    rows.close()454            except BaseException as cleanup:455                if failure is None:456                    raise457                failure.add_note('可读明细输入清理附加失败: ' + type(cleanup).__name__)458            finally:459                rows = row = values = failure = None460461    def _main_rows(self, table):462        """按索引顺序产生一行,查询不在RAM排序全表。463464        参数: table为主表。返回: CSV行生成器。异常: JSON/关联错误。465        """466        payload = row = None467        try:468            if self.connection is None:469                return470            with self._reader(table, indexed=True) as cursor:471                for number, payload in cursor:472                    row = json.loads(payload)473                    yield [row[IDENTITY[i]] for i in self.varying] + self._base(table, row, number)474        finally:475            # 生成器结束或出错后,不让本层异常帧保留刚读出的行。476            payload = row = cursor = None477478    def presentation(self):479        """仅返回共同身份和必要使用说明,不增加ID映射副本。480481        参数: 无。返回: 清单字典。异常: 无。482        """483        return dict(version=1, time_unit='s', time_coordinates='保留原分析时间坐标,不从零重排,不推断日期或时区',484                    empty_cell='无数值;请结合本指标状态和说明,不等于0',485                    common_identity={IDENTITY[i]: value for i, value in enumerate(self.owner.spool.identity_summary or ())486                                     if value is not None})487488    def close(self):489        """关闭并删除本次索引,失败保留真实owner供重试。490491        参数: 无。返回: None。异常: IO/释放错误传播,不删除输入或其他文件。492        """493        if self.closed:494            return495        if self._pending_output is not None:496            handle = self._pending_output497            try:498                handle.close()499            finally:500                if handle.closed:501                    self._pending_output = None502        if self._pending_reader is not None:503            self._pending_reader.close()504            self._pending_reader = None505        if self.connection is not None:506            self.connection.close()507            self.connection = None508        self.path.unlink(missing_ok=True)509        self.metrics.clear()510        _release_owned_ram(self, self._ram_cost, '_ram_cost')511        self.closed = True512
Chronaxis 用户手册 · 离线阅读