#!/usr/bin/env python3 """ledger.py — 台帳 unity_ledger_v1.csv に鍵をかけて1行足す(依頼 v12 の 3 章) 夜は実行ファイルを何本も同時に走らせるので、台帳に同時に書かないよう鍵をかける。 ここは **台帳を壊すと戻しにくい所** なので、決まりを狭くしてある。 決まり(C# の ServiceSimLedger と同じ) ・**追記だけ**。前の行は読むだけで、書き換えない・消さない ・同じ run_folder の行は2度書かない ・列の並びは **実体の1行め(ヘッダ)が正**。 C# 側が「ヘッダとコードの列が1字でも違えば追記しない」を守っているので、 実体のヘッダをそのまま信じれば、C# と Python の列がずれることはない。 そのうえで、頭の並びと ledger_schema_version があることだけ確かめる ・値にカンマ・引用符・改行は入れない(CSV を壊さないため) 鍵 unity_ledger_v1.lock を O_EXCL で作れたら持ち主。閉じるときに消す。 作れなければ少し待って何度か試す。 C# から new FileStream(lock, FileMode.CreateNew) で取るのと同じやり方なので、 Unity 側(依頼 v12 の 3 章)と同じ鍵を取り合える。 中身は「札 pid ホスト名 unix秒」の1行。札はこの回だけの字。 2026-10-26(依頼 v14 の 1):**古い鍵を年だけ見て壊す処理はやめた**。 2本が同時に「古い」と判定すると、先に取り直した側の新しい鍵を もう一方が消して二重所有になりうるため。いまは ・待ちの上限(既定 60 秒)を過ぎたら LedgerError(=台帳に書けなかった) ・取れたあと、読み直して自分の札か確かめる ・返すときも、自分の札のときだけ消す 待つ長さは単調時計(time.monotonic)で測る。 鍵が残ったときは人が中身を見て消す(自動では消さない)。 """ import csv import errno import os import socket import uuid import time LEDGER_NAME = 'unity_ledger_v1.csv' LOCK_NAME = 'unity_ledger_v1.lock' # ヘッダの頭の並び(ここだけは変わらない前提で確かめる) HEADER_PREFIX = ('timestamp', 'run_folder', 'status', 'unity_cs', 'layout_generation', 'params_model') # これが無ければ台帳と見なさない HEADER_MUST_HAVE = ('seed', 'store', 'robots', 'ledger_schema_version') # 台帳の列(59列)。C# の ServiceSimLedger.Columns と **同じ名前・同じ順番**。 # 2026-10-26(依頼 v14 の 3):前は「頭の6列」と「末尾10列」だけ見ていたので、 # 途中の列の並びが変わった台帳を通してしまった。全部の名前と順番を照合する。 # **列を足すときは、ここと C# の両方を直す**(data_manifest_rules.md 10節) COLUMNS = ( 'timestamp', 'run_folder', 'status', 'unity_cs', 'layout_generation', 'params_model', 'store', 'staff', 'robots', 'arrivals_per_h', 'mode', 'trays', 'fragile_by', 'seed', 'staff_first_yield', 'yield_order_enabled', 'one_robot_per_aisle', 'robot_first_wait_s', 'everyone_left', 'total_minutes', 'wall_seconds', 'entry_wait_mean', 'staff_wait_mean', 'dish_wait_mean', 'cold_share', 'robot_share', 'staff_util', 'robot_util', 'walk_km_per_staff', 'site_entry_wait_mean', 'site_staff_wait_mean', 'site_dish_wait_mean', 'site_robot_share', 'robot_speed_mps', 'pause_rate', 'pause_s_mean', 'stuck_rate', 'trips', 'timeouts', 'stuck_count', 'stop_s_total', 'corner_stop_time_share', 'gave_up', 'warped', 'table_freed', 'rescues', 'late_staff_dishes', 'staff_yield_share_of_staff_time', 'ledger_schema_version', 'pay_wait_mean', 'site_pay_wait_mean', 'blocked_wait_mean', 'site_blocked_wait_mean', 'turn_mean', 'site_turn_mean', 'clear_wait_mean_s', 'clear_wait_max_s', 'clear_wait_over_count', 'pay_job_wait_mean_s') EXPECT_COL_COUNT = len(COLUMNS) # 台帳の版(C# の ServiceSimLedger.SchemaVersion と同じ値)。 # Python が書く行(止めた回・落ちた回)にも入れる(codex レビュー D7) SCHEMA_VERSION = 2 # 2026-10-26(依頼 v14 の 1):古い鍵を年だけ見て壊す処理はやめた。 # 鍵が残ったときは待ちの上限で失敗にし、人が中身を見て消す class LedgerError(Exception): pass # ---------------- 鍵 ---------------- class Lock(object): """with Lock(dir) as lk: ... の形で使う""" def __init__(self, unity_dir, timeout=60.0, poll=0.25, log=None): self.path = os.path.join(unity_dir, LOCK_NAME) self.timeout = timeout self.poll = poll self._fd = None self._log = log # この回だけの札(C# の NewTag と同じ使い方) self._tag = 'p%d-%s' % (os.getpid(), uuid.uuid4().hex[:12]) def _body(self): return '%s %d %s %f\n' % (self._tag, os.getpid(), socket.gethostname(), time.time()) def _tag_of(self): """鍵の中身の先頭(札)。読めなければ None""" try: with open(self.path, encoding='utf-8') as f: line = f.readline() except OSError: return None if not line: return '' return line.split(' ', 1)[0].strip() def _note(self, msg): if self._log: self._log(msg) def __enter__(self): start = time.monotonic() # 待つ長さは単調時計で測る while True: try: self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) os.write(self._fd, self._body().encode('utf-8')) os.fsync(self._fd) if self._tag_of() == self._tag: # 取れたあと読み直して確かめる return self self._note('台帳の鍵を取り直します(取った直後に札が自分ではありません): %s' % self.path) try: os.close(self._fd) except OSError: pass self._fd = None except OSError as e: if e.errno != errno.EEXIST: raise # ★ 古い鍵を **年だけ見て壊すことはしない**(依頼 v14 の 1)。 # 2本が同時に「古い」と見ると、先に取り直した側の新しい鍵を # もう一方が消して二重所有になりうる。 # 鍵が残ったときは、上限を過ぎて LedgerError を投げ、 # 呼ぶ側が「台帳に書けなかった回」として扱う(残った鍵は人が消す) if time.monotonic() - start >= self.timeout: raise LedgerError( '台帳の鍵を %.0f 秒待っても取れません: %s\n' ' ほかの回が書いている途中か、前の回の鍵が残っています。\n' ' 残っているなら、中身(札 pid ホスト名 時刻)を見てから' '手で消してください(自動では消しません)' % (self.timeout, self.path)) time.sleep(self.poll) def __exit__(self, *exc): if self._fd is not None: try: os.close(self._fd) except OSError: pass self._fd = None # 自分の札のときだけ消す(A2) now = self._tag_of() if now is None: return False if now != self._tag: self._note('台帳の鍵が自分のものではないので消しません: %s(いま %s / 自分 %s)' % (self.path, now, self._tag)) return False try: os.unlink(self.path) except OSError: pass return False # ---------------- 読む ---------------- def ledger_path(unity_dir): return os.path.join(unity_dir, LEDGER_NAME) def read_header(unity_dir): """実体の1行めを列の一覧で返す。台帳の形でなければ LedgerError""" p = ledger_path(unity_dir) if not os.path.exists(p): raise LedgerError('台帳がありません: %s' % p) with open(p, encoding='utf-8') as f: line = f.readline() if not line: raise LedgerError('台帳が空です(ヘッダがありません): %s' % p) cols = tuple(line.rstrip('\r\n').split(',')) if cols == COLUMNS: return list(cols) # どこが違うかを出す(直すときに分かるように) if len(cols) != EXPECT_COL_COUNT: raise LedgerError('台帳の列の数が %d ではありません(%d 列): %s\n 実体: %s' % (EXPECT_COL_COUNT, len(cols), p, ','.join(cols))) diff = [(i, a, b) for i, (a, b) in enumerate(zip(cols, COLUMNS)) if a != b] raise LedgerError('台帳のヘッダがコードの列と違います(%d か所): %s\n%s' % (len(diff), p, '\n'.join(' %d 列め: 実体 %s / コード %s' % (i + 1, a, b) for i, a, b in diff[:5]))) def read_rows(unity_dir): """台帳の行を run_folder → 行(dict)で返す。読むだけ""" p = ledger_path(unity_dir) out = {} if not os.path.exists(p): return out with open(p, encoding='utf-8') as f: for r in csv.DictReader(f): folder = (r.get('run_folder') or '').strip() if folder: out[folder] = r return out def has_row(unity_dir, run_folder): return run_folder in read_rows(unity_dir) def all_folders(unity_dir): """ 台帳にある run_folder をぜんぶ返す(status は見ない)。 2026-10-26(codex レビュー A6):やり直し(_try2)の行がもうあるかを 調べるのに使う。failed のやり直しを2度使わないため """ try: return set(read_rows(unity_dir).keys()) except (LedgerError, OSError): return set() def completed_folders(unity_dir, status='completed'): return {k for k, r in read_rows(unity_dir).items() if (r.get('status') or '').strip() == status} # ---------------- 書く ---------------- def clean(value): """CSV を壊さない字にする(C# の ServiceSimLedger.Clean と同じ)""" if value is None: return '' s = str(value) s = s.replace(',', ' ').replace('"', ' ') s = s.replace('\r', ' ').replace('\n', ' ') return s.strip() def append_row(unity_dir, row, log=None, timeout=60.0): """ 台帳に1行足す。足したら True、足さなかったら False(理由は log に出す)。 row は 列名 → 値。ヘッダに無い列名が入っていたら LedgerError(打ち間違いを通さない)。 鍵は呼ぶ側で取らず、ここで取る。 """ def note(msg): if log: log(msg) folder = clean(row.get('run_folder')) if not folder: raise LedgerError('run_folder が空なので足しません(照合キーです)') with Lock(unity_dir, timeout=timeout, log=log): cols = read_header(unity_dir) # 鍵を取ってから読む unknown = [k for k in row if k not in cols] if unknown: raise LedgerError('台帳に無い列名です: %s' % ', '.join(sorted(unknown))) if folder in read_rows(unity_dir): note('もう台帳にあるので足しません(2度書かない): %s' % folder) return False line = ','.join(clean(row.get(c)) for c in cols) if line.count(',') != len(cols) - 1: raise LedgerError('列の数が合いません(%d 列のはず)' % len(cols)) p = ledger_path(unity_dir) # 末尾が改行で終わっていなければ **足さない**(2026-10-26・codex レビュー A4)。 # 前は改行を1つ継ぎ足していたが、それでは # 「前の追記が途中で切れた」ことが台帳の中に黙って残る。 # C# 側(ServiceSimLedger.EndsWithNewline)と同じ断り方にそろえた。 # 自動で直さない(台帳は書き換えない決まり) with open(p, 'rb') as f: if f.seek(0, os.SEEK_END) > 0: f.seek(-1, os.SEEK_END) if f.read(1) != b'\n': raise LedgerError( '台帳の最後が改行で終わっていません(前の追記が途中で切れた疑い): %s\n' ' つなげて書くと列がずれるので、何も足しませんでした。\n' ' 最後の行を見て、エイイチに相談してから直してください' '(自動では直しません)' % p) # 1行ぶんを UTF-8 の byte にしてから **1回で** 書く(依頼 v14 の 2) data = (line + '\n').encode('utf-8') with open(p, 'ab') as f: f.write(data) f.flush() os.fsync(f.fileno()) note('台帳に1行足しました: %s / %s' % (folder, clean(row.get('status')))) return True