タイムスタンプの精度を落とすときは切り捨てろ

とあるプロジェクトでナノ秒からミリ秒への変換で四捨五入してきた人がいて、時刻を扱うときは保存精度未満は切り捨てるべきというのが常識になっていないなーと思ったので。

2023-10-01 を、何年か表示する時に、2024年に丸める人はいないだろう。 13:45 が何時か表示する時も、13時と表示するだろう。(口頭で何時?と聞かれたら14時と答えるかもしれないけれど)

つまり、ある精度で表した時刻は、実際には次のような半開区間を示しているのである。

  • 2023-01-01 00:00:00 <= 2023年 < 2024-01-01 00:00:00
  • 13:45:00.000 <= 13:45 < 13:46:00.000

そして、そう決めたからには一貫して同じように、指定精度未満は切り捨てというルールを維持しなければならない。秒以下は四捨五入で、とかやってはいけないのだ。

一貫しないと何が問題かというと、精度が異なる区間が綺麗に分割されずにオーバーラップしてしまう。たとえば次の時刻を考えてみよう。

2023-12-31 23:59:59.999555

これは年にすると 2023 年だし、日付にすると2023-12-31だ。 しかしこれをミリ秒で四捨五入すると、 2024-01-01 00:00:00.000 になって、年も日付も変わってしまう。これは、「ミリ秒は四捨五入」というルールを導入したために、 "2024-01-01 00:00:00.000" が表現する時間の範囲が "2023-12-31 23:59:59.999500" <= "2024-01-01 00:00:00.000" < "2024-01-01 00:00:00.000500" になってしまったからだ。

ミリ秒精度のタイムスタンプが示す日時と記録されてる秒単位の日時が一致しない、というような問題を避けるためには、切り捨てにするか四捨五入にするかは一貫しないといけない。日付で切り捨てを採用している限りは、日付未満も切り捨てを採用する以外無いのだ。

以下、実際に幾つかの言語で秒未満の精度でも切り捨てが使われている例を示す。

構造化ログに格納するトレースバックのフォーマット

structlogのドキュメントを読んでいると、 structured tracebacksというものが登場する。 structured logging が良いものなのだから、structured tracebacksも良いものなのだろうか?

API Reference - structlog 24.1.0 documentation

structlog.processors.dict_tracebacks(logger, name, event_dict)

Replace an exc_info field with an exception field containing structured tracebacks suitable for, e.g., JSON output.

これを使うと、次のように1フレーム1 dictの形式でスタックトレースが出力される。

"frames": [{"filename": "/Users/inada-n/notes/structlog-test/traceback_sample.py", "lineno": 17, "name": "<module>", "line": "", "locals": {"__name__": "__main__", "__doc__": "None",....

デフォルトではローカル変数が表示されてしまっているので、ローカルでデバッグするくらいならいいかもしれないが、構造化ログに入れてObservability Platformに送信するにはデカすぎる。

dict_tracebacks の代わりに ExceptionRendererExceptionDictTransformer を使って自分で設定すればローカル変数を表示しないことは可能だが、それでもプレインテキストのトレースバックに比べると大幅に大きくなってしまう。

なぜ構造化ログが使いたかったのかと言えば、SQLライクな構文でログをフィルタリングするのに便利だったり、ログから値を集計するのに便利だったりするからだ。トレースバックの中身まで構造化して、この目的に寄与するとは思えない。と言うことで、個人的に strucutred tracebacks は却下だ。

構造化ログにどのように例外情報を格納するかについては、OpenTelemetryのConventionが今後の標準になっていくだろうから、それに従うのが一番だろう。

opentelemetry.io

"exception.type" に型を、 "exception.message" にメッセージを、そして "exception.traceback" に、(1フレーム1文字列の配列などではなく)ただ1つの文字列としてスタックトレースを書くことが推奨されている。構造化ログのメリットから考えても、型とメッセージをトレースバックと分けて格納する設計には納得できる。

さらにスタックトレースのフォーマットについては "in the natural representation for the language runtime." と書かれていて、言語の標準的なフォーマットを使うことが推奨されている。

Pythonの標準のtracebackがあまり構造化ログに入れてObservability Platformに送信するのに適していない件は先の記事で書いた。とりあえず当面はその記事で言及した PYTHONNODEBUGRANGES=1 環境変数chain=False オプションを使ってトレースバックの肥大化を避けるのがいいだろう。しかしそれだけでは、他の言語の標準的なトレースバックフォーマットが most recet call first なのに対してPythonのものがmost recent call lastなのは解決できてない。Web UIでログの中のトレースバックを見る時にはスクロールが必要になるだろう。

methane.hatenablog.jp

より良いログフォーマットとして、この記事で紹介していた minitraceback も試してみてもらえると嬉しい。ドキュメントはまだないけれど、APIはほぼ標準tracebackのサブセットになっているので使い方には困らないだろう。

将来のことを考えると標準化が重要なので、 disscuss.python.org で標準の traceback モジュールに構造化ログとObservability Platformに適した形式でで出力するためのオプションを追加する提案をしているところだ。(時期的にPython 3.13に間に合わすのは厳しいけれども。)

discuss.python.org

uWSGIで大きいログが壊れないようにする設定

uWSGIがファイルやパイプにログを書くときは、各ワーカープロセスが直接ファイルやパイプに書く場合と、一旦masterプロセスにログを送ってmasterプロセスからログを書く場合があります。

masterプロセスにログを送る設定は --log-master ですが、このオプションを暗黙的に有効にするオプションがあります。 UWSGI_OPT_LOG_MASTERUWSGI_OPT_REQ_LOG_MASTER というフラグが指定された場合に log_master が設定されるようになっていて、uwsgi-2.0ブランチではこれらのオプション(エイリアスを含む)にそのフラグが指定されています。

touch-logrotate
touch-logreopen
log-syslog
log-socket
req-logger
logger-req
logger
threaded-logger
log-encoder
log-req-encoder
log-drain
log-filter
log-route
log-req-route
log-alarm
alarm-log
not-log-alarm
not-alarm-log
log-master
log-maxsize

他にもネットワーク経由で外にログを飛ばすようなプラグインがlog_masterを有効にすることもあるので、なにかプラグインを使っている場合は確認してください。

log_masterを有効にしていない場合は、ログの出力先がファイルなら1回のwriteで書かれるサイズは実質的に無制限にアトミックに書かれ、他のスレッドやプロセスからのwrite()と混ざらないと思います。参考に、Linuxの man wirte(2)には次のように書かれていて、Linux 3.14からは問題ないようです。あまりに大きい場合は途中でシグナルが来た時にどうなるかわかりませんが、バッファに乗る範囲のログなら気にしなくてもいいんじゃないかな。

       According to POSIX.1-2008/SUSv4 Section XSI 2.9.7 ("Thread
       Interactions with Regular File Operations"):

           All of the following functions shall be atomic with respect
           to each other in the effects specified in POSIX.1-2008 when
           they operate on regular files or symbolic links: ...

       Among the APIs subsequently listed are write() and writev(2).
       And among the effects that should be atomic across threads (and
       processes) are updates of the file offset.  However, before Linux
       3.14, this was not the case: if two processes that share an open
       file description (see open(2)) perform a write() (or writev(2))
       at the same time, then the I/O operations were not atomic with
       respect to updating the file offset, with the result that the
       blocks of data output by the two processes might (incorrectly)
       overlap.  This problem was fixed in Linux 3.14.

write(2) - Linux manual page

パイプの場合は一度に書き込めるサイズはPIPE_BUFで、POSIXで512バイト、Linuxでは4096バイトになります。これを超える場合は一度でwriteできない可能性があり、1行のログをループで複数回write()する可能性があるので、他のプロセスが書いたログが1行の間に割り込む可能性があります。ログを標準出力等からパイプに書きたい場合は、 --log-master を指定した方が壊れにくくなります。

   PIPE_BUF
       POSIX.1 says that writes of less than PIPE_BUF bytes must be
       atomic: the output data is written to the pipe as a contiguous
       sequence.  Writes of more than PIPE_BUF bytes may be nonatomic:
       the kernel may interleave the data with data written by other
       processes.  POSIX.1 requires PIPE_BUF to be at least 512 bytes.
       (On Linux, PIPE_BUF is 4096 bytes.)

pipe(7) - Linux manual page

つぎは log_master が有効な場合です。この場合、 --log-master-stream を指定していなければUnixドメインソケットでパケット型の通信をします。Linuxの場合は SOCK_SEQPACKET が使われます。

// core/logging.c の create_logpipe()
#if defined(SOCK_SEQPACKET) && defined(__linux__)
        if (socketpair(AF_UNIX, SOCK_SEQPACKET, 0, uwsgi.shared->worker_log_pipe)) {
#else
        if (socketpair(AF_UNIX, SOCK_DGRAM, 0, uwsgi.shared->worker_log_pipe)) {
#endif

システムコールとして送受信できるサイズはソケットバッファに制限され net.core.wmem_default, net.core.rmem_default がデフォルトですが、実際には uWSGI の log_master_bufsize のデフォルト値 8192 バイトがログ1行を壊れずに書けるデフォルトの上限になります。uWSGIがこのサイズの送受信バッファを持っていて、このサイズを超える部分は分割ではなく破棄されるためです。

なるべくログを壊したくない場合は、 --log-master-bufsize オプションにもっと大きい値を指定できまず。ソケットバッファが log-master-bufsize より小さい場合はsetsockoptでソケットバッファも大きくするので、net.core.wmem_default, net.core.rmem_default よりも大きい値を指定できます。 --log-master-bufsize のドキュメントには64bit整数と書かれていますが、 setsockopt を呼び出す時にint型を利用しているので、実質的な最大値は 231-1 になります。

ログ転送ミドルウェアがログを読み込む時にも1レコードの最大サイズを指定することが多いと思うので、それと合わせて、普段の数倍のデカいスタックトレースが生成されてもログを取りこぼさない程度の大きさを指定するといいでしょう。

構造化ログ用のコンパクトなトレースバック minitraceback

Pythonのデフォルトの例外+トレースバック表示は、主にターミナル表示向けに調整されています。(次のPython 3.13からはカラー表示にも対応する予定です。)

ターミナル表示用にフォーマットされたトレースバックは、(もちろんカラーを使わなければ)非構造化ログに書いても特に問題ありませんでした。しかし、1行に1つのJSONの形で構造化ログを書くときにはあまり適していない気がします。

  • SQLAlchemyなどの複雑なライブラリを使うとトレースバックが巨大になり、ログ転送ツール等の1レコードあたり容量制限に気をつけないといけない。
  • エラーとエラーの発生元が最後にくるフォーマットはログが上に流れているターミナルでは見やすいが、Web UIから見る時やJSONをそのまま見るときは最初にエラーが来て欲しい。
  • 改行やクォートがJSONに入る時はエスケープされるので、なおさら見にくくなる。

試しに、SQLAlchemyでsqliteのファイルを開けなかった時のエラーのスタックトレースは次のようになります。

Traceback (most recent call last):
  File "/Users/inada-n/tmp/sample.py", line 74, in main
    conn = engine.connect()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3280, in connect
    return self._connection_cls(self)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 148, in __init__
    Connection._handle_dbapi_exception_noconnection(
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 2444, in _handle_dbapi_exception_noconnection
    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 146, in __init__
    self._dbapi_connection = engine.raw_connection()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3304, in raw_connection
    return self.pool.connect()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 449, in connect
    return _ConnectionFairy._checkout(self)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 1263, in _checkout
    fairy = _ConnectionRecord.checkout(pool)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 712, in checkout
    rec = pool._do_get()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/impl.py", line 179, in _do_get
    with util.safe_reraise():
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 146, in __exit__
    raise exc_value.with_traceback(exc_tb)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/impl.py", line 177, in _do_get
    return self._create_connection()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 390, in _create_connection
    return _ConnectionRecord(self)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 674, in __init__
    self.__connect()
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 900, in __connect
    with util.safe_reraise():
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 146, in __exit__
    raise exc_value.with_traceback(exc_tb)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 896, in __connect
    self.dbapi_connection = connection = pool._invoke_creator(self)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/create.py", line 643, in connect
    return dialect.connect(*cargs, **cparams)
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/default.py", line 617, in connect
    return self.loaded_dbapi.connect(*cargs, **cparams)
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file
(Background on this error at: https://sqlalche.me/e/20/e3q8)

そして、実はこれでも短くしている方なのです。このトレースバックは PYTHONNODEBUGRANGES=1 という環境変数を設定して出力しましたが、この環境変数を設定しなかったら次のように行内の例外発生箇所を示すマークが付いて、1フレームごとに2行ではなく3行消費することがあります。

Traceback (most recent call last):
  File "/Users/inada-n/tmp/sample.py", line 74, in main
    conn = engine.connect()
           ^^^^^^^^^^^^^^^^
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3280, in connect
    return self._connection_cls(self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 148, in __init__

また、 chain=False を設定して最終的にキャッチしたエラーのスタックトレースだけを表示していますが、デフォルトでは chain=True なのでsqliteのファイルを開けなかった部分のスタックトレースも表示されて、合計で100行近くになります。

ある程度の見やすさを確保しつつ、構造化ログに入れることを考えたフォーマットを考えてみました。

  • エラーとエラーの発生元を最初に持ってくる(標準ライブラリと逆)
  • ファイルパスはsys.pathからの相対パスにする
  • 行番号だけあれば調査は可能なので、ソースコードは表示しない。ただしヒントとして関数名だけは書いておく。
  • トレースバック部分は1フレームに1つのNamedTupleで表現し、好きにカスタマイズできるようにする。

これで作ったトレースバックのサンプルがこれです。

sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file
(Background on this error at: https://sqlalche.me/e/20/e3q8)
Traceback (most recent call first):
  sqlalchemy/engine/default.py:617 connect
  sqlalchemy/engine/create.py:643 connect
  sqlalchemy/pool/base.py:896 __connect
  sqlalchemy/util/langhelpers.py:146 __exit__
  sqlalchemy/pool/base.py:900 __connect
  sqlalchemy/pool/base.py:674 __init__
  sqlalchemy/pool/base.py:390 _create_connection
  sqlalchemy/pool/impl.py:177 _do_get
  sqlalchemy/util/langhelpers.py:146 __exit__
  sqlalchemy/pool/impl.py:179 _do_get
  sqlalchemy/pool/base.py:712 checkout
  sqlalchemy/pool/base.py:1263 _checkout
  sqlalchemy/pool/base.py:449 connect
  sqlalchemy/engine/base.py:3304 raw_connection
  sqlalchemy/engine/base.py:146 __init__
  sqlalchemy/engine/base.py:2444 _handle_dbapi_exception_noconnection
  sqlalchemy/engine/base.py:148 __init__
  sqlalchemy/engine/base.py:3280 connect
  sample.py:74 main

JSONにするとこうなります。トレースバックの1行を1文字列にしているのでエスケープ文字が入らないですし、見やすいJSONリーダーなどがあれば十分にそのまま読むことができます。

"exception": ['sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file\n(Background on this error at: https://sqlalche.me/e/20/e3q8)'],
"stacktrace": ["sqlalchemy/engine/default.py:617 connect", "sqlalchemy/engine/create.py:643 connect", "sqlalchemy/pool/base.py:896 __connect", "sqlalchemy/util/langhelpers.py:146 __exit__", "sqlalchemy/pool/base.py:900 __connect", "sqlalchemy/pool/base.py:674 __init__", "sqlalchemy/pool/base.py:390 _create_connection", "sqlalchemy/pool/impl.py:177 _do_get", "sqlalchemy/util/langhelpers.py:146 __exit__", "sqlalchemy/pool/impl.py:179 _do_get", "sqlalchemy/pool/base.py:712 checkout", "sqlalchemy/pool/base.py:1263 _checkout", "sqlalchemy/pool/base.py:449 connect", "sqlalchemy/engine/base.py:3304 raw_connection", "sqlalchemy/engine/base.py:146 __init__", "sqlalchemy/engine/base.py:2444 _handle_dbapi_exception_noconnection", "sqlalchemy/engine/base.py:148 __init__", "sqlalchemy/engine/base.py:3280 connect", "sample.py:74 main"]

とりあえずここまでを minitraceback という名前でPyPIに登録しておきました。 また次回の記事で structlog で利用する方法などを紹介します。

Goのerrorがスタックトレースを含まない理由

Twitterでこんな記事を見かけたので。

zenn.dev

ジェネリクスの件もそうですが、Goの言語設計は現実主義なのになにか特別なポリシーによるものだと宗教化されてしまって、ファンには勝手に崇拝されてアンチにはディスられがちだなーと感じます。

Goのエラー処理を改善する実験プロジェクトxerrorsがGo本体のerrorsにマージされた時、 errors.New()スタックトレースを取得していました。しかしGo 1.13がリリースされる前に削除されました。

削除された理由の1つは、今までの errors.New() のパフォーマンスに依存していたコードの速度が低下しアロケーションが増えることです。

github.com

しかし、これが理由だと今まで思ってたのですが、実際にはもう1つより重要な理由がありました。エラーのフォーマットです。エラーに複数のフォーマットを持たせようという提案が、経験不足を理由に延期されたのです。

Go 1.13 のステータスを説明するコメント

複数の異なるフォーマットの扱いが仕様化されてないということは、スタックトレースの情報をエラーにつけてもそれを表示する手段がありません。すでに使われている表示手段は後方互換性のために変えられないので、スタックトレース付きでエラー表示をするための追加仕様が必要なのです。表示する手段が存在しないのに、単に遅くてアロケーションが多いだけのスタックトレース付きエラーを標準ライブラリに入れる必要はありません。

ということで、Go標準でスタックトレース情報付きのエラーを提供することは、エラーのフォーマットを拡張する提案の中に含まれる形で現在もドラフトになっています。

go.googlesource.com

ということで、Goの開発者やGooglerが何か特別なポリシーや宗教上の理由でスタックトレースをエラーに付けていないわけではありません。単に影響範囲がデカい言語仕様だとかコアライブラリの新規APIに対する仕様追加に極めて慎重で時間をかけてるだけでした。エラーにスタックトレースをつけるライブラリを使っても、Go Wayに違反して善きGopherになれなくなるなんて事は全くないので、安心して使えば良いと思います。

その際は他のライブラリの選定の時と同じく、信頼性のあるソフトウェアを参考にしつつ、依存ライブラリの数やシンプルさ、将来Goの標準ライブラリに追加された時に移行しやすそうかなどを考えて選べば良いでしょう。 特に1つのライブラリをお勧めできるほどGoのエコシステムに詳しいわけではありませんが一例として、CockroachDBは(名前が最悪なのはともかく)Goで書かれた信頼性の高いソフトウェアなので、 github.com/cockroachdb/errors は参考になるかもしれませんし、分散システムを作るのでなければ複雑すぎるかもしれません。

追記

上で紹介したドラフト以外にも、エラーにスタックトレースをつけるための複数の提案がTwitter上で紹介されていました。

github.com

github.com

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。