$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

chat-hatenablogをpip installでインストール可能にした

https://github.com/shibayu36/chat-hatenablog/releases/tag/v0.3.0

chat-hatenablog v0.3.0にてchat-hatenablogをpip installできるようにした。以下のようにすれば、GitHubから直接コマンドをインストールできる。

pip install git+https://github.com/shibayu36/chat-hatenablog.git

この結果、current directoryをベースとして、index.pickleや.envを置くことはできなくなったため、配置場所を変えた。

~/.chat-hatenablog/index.pickle
~/.chat-hatenablog/.env

今回の変更で、git cloneしてそのディレクトリで実行するといった面倒なことをする必要は無くなった。どうぞご利用ください。

chat-hatenablog v0.2.1をリリースした。過去のブログ記事を更新してもインデックスを差分更新できるように等

ChatGPTを使って自分のはてなブログとチャットするツールを作った - $shibayu36->blog;で紹介したchat-hatenablogのv0.2.1をリリースした。

リリース内容

https://github.com/shibayu36/chat-hatenablog/releases/tag/v0.2.0 https://github.com/shibayu36/chat-hatenablog/releases/tag/v0.2.1

今回はbreaking changeもある。

  • [breaking change] 過去のブログ記事を更新したケースでも、うまくインデックスを更新できるように
    • 元々はインデックスを作る -> 古い記事を編集 -> 再エクスポートしてインデックスを作り直しをすると、同じ記事のインデックスが複数できてしまうような問題があった
    • インデックス構造が変わっているので1からインデックスを作る必要あり
  • インデックスが変わる時しかファイル書き込みをしないようにし、差分更新を高速化
  • terminateしたタイミングによってpickleファイルが壊れてしまう問題を修正

開発メモ

古い記事の更新時にもインデックスを更新できるようにする

https://github.com/shibayu36/chat-hatenablog/pull/15 + https://github.com/shibayu36/chat-hatenablog/pull/25

元々のインデックス構造では、ファイル更新に対応できなかったため、次のようなインデックス構造に変更した。

{
    "basename1": {
        "content_hash": "...",
        "title": "title1",
        "embeddings_list": [
            {"body": ..., "embeddings": [...]},
            {"body": ..., "embeddings": [...]},
        ],
    },
    "basename2": { ... },
}

ポイントとしては

  • ブログ記事はpermalinkは変わらないという特性があるので、MovableTypeフォーマットのbasenameを記事を示す値として利用する
  • titleとbodyを含めてhash値を作ることによって、コンテンツが変わったらインデックスを更新するように
    • 記事編集日時とかでも良いが、例えばembeddingsを作るときの本文分割方法が変わった時にインデックスが更新されるようにするため、コンテンツハッシュの手法にした

./chat-hatenablog askをするときは、この構造を[(title, body, basename, embeddings), ...] の構造に変換する必要がある。Pythonだと変化も簡単だった。self.cacheに上の構造が入っているとすると、次のように書くだけで良い。リスト内包表記便利。

items = [
    [info['title'], embeddings_list['body'], basename,
        embeddings_list['embeddings']]
    for basename, info in self.cache.items()
    for embeddings_list in info.get('embeddings_list')
]

インデックスが変わる時しかファイル書き込みをしないようにし、差分更新を高速化

今までは、インデックスの更新が必要ない時でも1エントリ1回ファイル保存が走ってしまっていて、特に2回目以降のインデックス作成時に遅くなってしまっていた。こちらは単純にdirty flagを導入し、必要な時のみファイル保存をするようにした。

https://github.com/shibayu36/chat-hatenablog/pull/19

terminateしたタイミングによってpickleファイルが壊れる問題を直す

別の記事で詳しく解説した。 blog.shibayu36.org

リリースノートをちょっとだけ良い感じにする

.github/release.ymlを設置して、リリースノートをちょっとだけ良い感じにした。

以下の記事を参考にした。

初回のラベル生成は、github-label-syncを用いてさっと作った。

labels.yml

- name: add
  description: Add new features.
  color: "0e8a16"
- name: change
  description: Change existing functionality.
  color: "fbca04"
- name: deprecate
  description: Mark as soon-to-be removed features.
  color: "d93f0b"
- name: remove
  description: Remove features.
  color: "b60205"
- name: fix
  description: Fix bug.
  color: "5319e7"
- name: security
  description: In case of vulnerabilities.
  color: "0052cc"
github-label-sync --access-token ... --labels labels.yml shibayu36/chat-hatenablog --allow-added-labels

Pythonのpickleファイルが壊れないようにatomicに処理する

https://github.com/shibayu36/chat-hatenablog を使っていて、途中でC-cなどでプロセスをterminateした時にpickleファイルが壊れることがあった。pickleファイルが壊れると、次のpickle.load時に EOFError: Ran out of input のようなエラーが出て、再度作り直しとなってしまう。これはかなり辛い。

色々調べているとファイル操作をatomicになるようにすることで、pickleファイルを壊れないようにできたので、そのやり方をメモしておく。間違えている部分があれば指摘してください。

なぜ壊れるか

たとえば pickle.dump(data, f) というコードがあるとすると、pickleはファイルにdataをserializeしたものを書き込む。この操作はファイルに逐次書き込みをするため、この処理が行われている間にTERM signalが送られてプロセスが死んだ時には、中途半端な状態となってしまう。

その後にそのファイルからpickle.loadしたら、中途半端なところまで読み込みをした後、完全なデータロードが終わる前にEOFに到達する。このためEOFError: Ran out of inputとなる。

どうすると良いか

壊れたファイルが作成されるのを防ぎたい。具体的には以下のようにすると良いようだ。

with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
    # pickle.dumpが途中で止まると壊れるのは防げないので、tempileに書き込む
    pickle.dump(data, f)
    # (1) f.flush -> os.fsyncを使い、データが確実にファイルに書き込まれた状態にする
    f.flush()
    os.fsync(f.fileno())
    temp_file_path = f.name

# (2) os.replaceでatomicにファイル操作
os.replace(temp_file_path, file_path)

基本的にはpickle.dumpが途中で止まって壊れるのは防げないので、tempfileに書き込んだ後os.replaceでatomicにファイル移動をするという作戦となる。カッコ書きをした部分をもう少し詳細に説明する。

(1)の部分はファイルに確実に書き込まれた状態にするためのコードだ。確実に書き込まれた状態にするには、Python側で持つバッファとOS側で持つバッファの両方をファイルに書き出さなければならない。f.flushはPython側、os.fsyncはOS側の書き出しだ。次のURLが参考になる。

(2)の部分は、完全に書き込まれたpickleファイルを意図した場所にatomicに移動するコードだ。Pythonの場合atomicに移動する方法としてos.renameとos.replaceがあるようだが、Python 3.3以上なら基本的にos.replaceを使えば良さそうだ。

  • os.renameは宛先にファイルがもともと存在していたときの挙動が、Windowsの時とそれ以外の時で異なる
  • os.replaceはクロスプラットフォームで扱える。ただしPython 3.3で追加されたので、それ以前のバージョンをサポートしたい場合は利用できない

ちなみにどんな場合でも確実にtempfileを消したいと思った場合、これら全体をtryで囲み、tempfileを消す必要がある。今回はそこまで踏み込まない。

これら調査には次のリンクを参考にした。

参考: 今回の調査はChatGPT(GPT-4)と一緒に行った

「pickleファイルが壊れないように」という目的は決まっていたので、その目的に向けてChatGPTを使った。このときの調べかたも参考になるかと思い、参考としてつけておく。

まずシンプルに、pickle.dumpで壊れるんだけどどうしたらいい?という質問から始めた。

しかしこのコードが正しいのかの判定が自分にはできなかった。そこで、ChatGPTへそのままこのコードを渡し、問題点はどこ?と聞いてみた。するとflushとfsyncをしないといけないと教えてもらえた。そのままコードを渡したら、より良いコードが出てくるというのはすごい。

ここまで来ると、調査のためのとっかかりが掴めている。つまり、flushやfsyncを呼び出すことや、os.replaceを呼び出すことがなぜ必要かについて自分でも調査し、ダブルチェックをすれば良い。Google検索やGitHubのコード検索を用いて、公式ドキュメントでの記載があることやGitHub上で普通に行われていることを確認する。

まずGoogle検索では以下のものが見つかり、公式ドキュメントでもfsyncが必要なことが言及されていることを確認。

GitHubで検索をかけると、使われていることは確認できたが、os.renameの方が多く使われていることがわかった。

何でos.replaceを提案したのか気になったので、これもChatGPTへ聞いてみる。

os.replace and os.rename are both used to rename files or directories in Python. However, there is a significant difference between the two:

  1. os.rename(src, dst): This function renames the file or directory from src to dst. If dst already exists, it will raise an OSError on Windows and silently overwrite the destination on Unix-based systems. This behavior can lead to data loss if you're not careful.

  2. os.replace(src, dst): This function also renames the file or directory from src to dst. However, if dst already exists, it will be replaced regardless of the operating system. This function was introduced in Python 3.3 to provide a consistent behavior across different platforms.

In summary, os.replace provides a more consistent behavior across different operating systems when it comes to renaming files and directories, especially when the destination already exists. It is generally recommended to use os.replace over os.rename for better cross-platform compatibility.

なるほどと思い、実際に公式ドキュメントを見てみると、https://docs.python.org/ja/3/library/os.html#os.rename あたりにreplaceのcross-platform性に関する記載がされていた。またPython 3.3以降に導入されたことを知り、GitHub上でrenameが多い理由にも納得ができた。

このような調査をして、大体合っていそうだという気持ちになった。