在访问Django模型时,PDF库中的非确定性行为
我有一个在 Django 4.2
和 Python 3.10
上运行的应用,有时候它的表现不太正常,尤其是在应该是 无状态 的地方。如果我在使用 pypdf 3.17.4
的时候访问数据库(postgresql 14
,通过 pcygopg 3.1.18
),生成的输出文档就会出问题……大约每8次尝试中就会有1次出现这种情况。我该如何找出这个不稳定行为的原因呢?
生成的PDF文件有内容缺失,所以只要比较输出的大小就能判断出这个错误是怎么触发的:
# call as: python3 manage.py minrepro input.pdf
import argparse, io
from django.core.management.base import BaseCommand
from pypdf import PdfReader, PdfWriter
from djangoapp.models import DjangoModel
def main(fin):
pdfout = PdfWriter()
pageout = pdfout.add_blank_page(width=200, height=200)
for i in range(8):
# Note: accessing the database *during* PDF merging is relevant!
# without the next line, the problem cannot be reproduced
for c in range(31): a = DjangoModel.objects.first()
fin.seek(0)
for pagein in PdfReader(fin, strict=True).pages:
pageout.merge_page(pagein)
with io.BytesIO() as fout:
pdfout.write(fout)
return fout.tell()
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(dest="pdf", type=argparse.FileType("rb") )
def handle(self, *args, **options):
for i in range(30):
if i == 0: first_size = main(options["pdf"])
current_size = main(options["pdf"])
if not first_size == current_size:
print(f"presumed stateless call was not {i=}, {first_size=} != {current_size=}")
- 我最初的猜测是
pypdf
没有为(合并后)重复的标识符生成真正唯一的名称,而数据库的访问只是给了这个问题一个显现的机会(延迟)。我查看了相关库中是否有time
或random
的导入,但没有发现明显的错误。而且,像time.sleep(0.02)
这样的代码也无法让我重现这个问题,只有访问数据库才会导致。 - 这是使用 CPython 的,字典是按插入顺序的,大部分代码在相同输入下应该表现一致。我还用
-W module::ResourceWarning -W module::DeprecationWarning -W module::PendingDeprecationWarning
运行了 Python,以便更容易发现我可能犯的错误,但也没有发现任何问题。 - 我在一个单线程的管理命令中重现了这个行为,因此这 与 任何 http/web 服务器的问题无关。
1 个回答
2
根本原因已经找到:在循环中丢弃并重新创建PdfReader对象,导致pypdf.PdfWriter.merge
有很小的概率会重复使用已经用过的标识符映射。
看起来触发这个问题的原因是我循环中的垃圾回收,psycopg3只是提供了这个发生的机会,并不是直接导致它的原因。
关于重置源PDF和目标PDF对象之间关联的相关文档让我发现了之前忽略的细节:pypdf假设但从不检查在合并过程中使用的PdfReader对象不会重复使用Python的id()
。
两个生命周期不重叠的对象可能会有相同的id()值。 -- Python id(object)
在我的代码中确实发生了这种情况,可以通过打印id(page.pdf)轻松看到。
在最后一个输入合并之前,保持对PdfReader对象的引用(或者简单地在输出写入之前)也会阻止我的复现代码正常工作,像这样:
store_for_pypdf = []
for loop:
...
reader = PdfReader(fin, strict=True)
store_for_pypdf.append(reader)
... # use reader
...
pdfout.write(fout)
del store_for_pypdf