rust-py-cache 0.2.3__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rust_py_cache-0.3.0/.github/workflows/release.yml +389 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/.gitignore +3 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/CHANGELOG.md +72 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Cargo.lock +1 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Cargo.toml +1 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/PKG-INFO +8 -3
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/README.md +7 -2
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/ROADMAP.md +8 -3
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/pyproject.toml +1 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/python/rust_py_cache/__init__.py +1 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/cache.rs +114 -12
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/entry.rs +1 -1
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_eviction.py +15 -0
- rust_py_cache-0.2.3/.github/workflows/ci.yml +0 -71
- rust_py_cache-0.2.3/.github/workflows/release.yml +0 -256
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/LICENSE +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/Makefile +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/PUBLISHING.md +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/clippy.toml +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/examples/django_example.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/examples/fastapi_example.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/python/rust_py_cache/decorators.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/lib.rs +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/stats.rs +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/sweeper.rs +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/src/ttl.rs +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_cache_basic.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_delete.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_features.py +0 -0
- {rust_py_cache-0.2.3 → rust_py_cache-0.3.0}/tests/test_set_get.py +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# release.yml canônico dos `rust_py_*` — P3-1
|
|
3
|
+
#
|
|
4
|
+
# Um arquivo só por projeto: lint, teste, build da matriz, ensaio no TestPyPI e
|
|
5
|
+
# publicação. Substitui o par `ci.yml` + `release.yml` que `cache` e
|
|
6
|
+
# `rate_limit` mantinham, e que tinha um problema silencioso — ver "Por que um
|
|
7
|
+
# workflow só", abaixo.
|
|
8
|
+
#
|
|
9
|
+
# Gerado a partir de `rust_py_template/.github/workflows/release.yml` em
|
|
10
|
+
# 12/08/2026 — o `cache` é o primeiro projeto a adotar o formato unificado.
|
|
11
|
+
# Correção feita aqui deve voltar para o template, senão a divergência
|
|
12
|
+
# recomeça pelo mesmo caminho de sempre.
|
|
13
|
+
#
|
|
14
|
+
# ⚠️ NÃO RENOMEAR ESTE ARQUIVO. O trusted publisher cadastrado no PyPI amarra
|
|
15
|
+
# `release.yml`; renomear quebra a publicação até o cadastro ser refeito.
|
|
16
|
+
#
|
|
17
|
+
# Substituiu o par `ci.yml` + `release.yml` que o `cache` mantinha.
|
|
18
|
+
#
|
|
19
|
+
# ── POR QUE UM WORKFLOW SÓ ───────────────────────────────────────────────────
|
|
20
|
+
# `cache` e `rate_limit` tinham dois arquivos, e a divisão escondia uma
|
|
21
|
+
# inversão: o `ci.yml` deles rodava a suíte RICA (multi-OS, Python 3.10–3.13)
|
|
22
|
+
# em push/PR sem barrar nada, enquanto o `release.yml` barrava a publicação com
|
|
23
|
+
# uma suíte MAGRA (ubuntu, 3.12). Ou seja, o portão do PyPI era o teste mais
|
|
24
|
+
# fraco dos dois. `needs` não atravessa workflow — é a lição do P1-1, aqui numa
|
|
25
|
+
# segunda forma.
|
|
26
|
+
#
|
|
27
|
+
# Este arquivo resolve juntando: a suíte rica é a que barra o release.
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
name: CI / Release
|
|
31
|
+
|
|
32
|
+
on:
|
|
33
|
+
push:
|
|
34
|
+
branches: [main]
|
|
35
|
+
tags: ["v*"] # `v*`, nunca `*` — ver nota no job `release`
|
|
36
|
+
pull_request:
|
|
37
|
+
workflow_dispatch: {}
|
|
38
|
+
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
|
|
42
|
+
env:
|
|
43
|
+
PACKAGE_NAME: rust-py-cache
|
|
44
|
+
# 2 manylinux + 2 musllinux + 1 windows + 2 macos. O `monitor` soma 4 do
|
|
45
|
+
# PyPy (11); o `scheduler`, com a matriz longa, chega a 15.
|
|
46
|
+
MIN_WHEELS: "7"
|
|
47
|
+
|
|
48
|
+
jobs:
|
|
49
|
+
# ── PORTÃO 1: lint ─────────────────────────────────────────────────────────
|
|
50
|
+
# Barra o release junto com o `test`. Nos repositórios de workflow único, um
|
|
51
|
+
# `lint` vermelho ao lado de uma publicação bem-sucedida seria ilegível — a
|
|
52
|
+
# leitura tem que ser inequívoca: vermelho significa que não publicou.
|
|
53
|
+
lint:
|
|
54
|
+
name: fmt + clippy
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v4
|
|
58
|
+
# Versão fixa, não "stable". Com `-D warnings`, um clippy mais novo no
|
|
59
|
+
# runner reprova código que ninguém tocou, e a CI fica vermelha por conta
|
|
60
|
+
# do calendário. Subir este número vira uma mudança deliberada.
|
|
61
|
+
- uses: dtolnay/rust-toolchain@1.88.0
|
|
62
|
+
with:
|
|
63
|
+
components: rustfmt, clippy
|
|
64
|
+
- uses: Swatinem/rust-cache@v2
|
|
65
|
+
- name: cargo fmt
|
|
66
|
+
run: cargo fmt --all -- --check
|
|
67
|
+
# clippy só checa tipos, não linka, então --all-features é seguro aqui —
|
|
68
|
+
# diferente do `cargo test`, que precisa de --no-default-features.
|
|
69
|
+
- name: cargo clippy
|
|
70
|
+
run: cargo clippy --all-targets --all-features -- -D warnings
|
|
71
|
+
|
|
72
|
+
# ── PORTÃO 2: teste ────────────────────────────────────────────────────────
|
|
73
|
+
# Nada é construído e nada chega ao PyPI antes disto passar. Publicação no
|
|
74
|
+
# PyPI é irreversível: um wheel ruim não pode ser substituído, só yankado.
|
|
75
|
+
test:
|
|
76
|
+
name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
|
77
|
+
runs-on: ${{ matrix.os }}
|
|
78
|
+
strategy:
|
|
79
|
+
fail-fast: false
|
|
80
|
+
# A matriz veio do `ci.yml` do `cache` e do `CI.yml` do `rate_limit`, que
|
|
81
|
+
# tinham a cobertura mais larga dos cinco — e que, por viverem em
|
|
82
|
+
# workflow separado, não barravam nada. Agora barram.
|
|
83
|
+
#
|
|
84
|
+
# O eixo de SO é o do `cache`, reduzido: ele rodava macOS em três versões
|
|
85
|
+
# de Python, o que dobrava a matriz para provar a mesma coisa. Uma versão
|
|
86
|
+
# em macOS basta para pegar o que ubuntu não pega (link da extensão,
|
|
87
|
+
# caminho de arquivo, comportamento de thread). O eixo de Python inteiro
|
|
88
|
+
# fica no ubuntu, que é onde ele importa.
|
|
89
|
+
matrix:
|
|
90
|
+
os: [ubuntu-latest]
|
|
91
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
92
|
+
include:
|
|
93
|
+
- os: macos-latest
|
|
94
|
+
python-version: "3.12"
|
|
95
|
+
steps:
|
|
96
|
+
- uses: actions/checkout@v4
|
|
97
|
+
- uses: dtolnay/rust-toolchain@stable
|
|
98
|
+
- uses: actions/setup-python@v5
|
|
99
|
+
with:
|
|
100
|
+
python-version: ${{ matrix.python-version }}
|
|
101
|
+
- uses: Swatinem/rust-cache@v2
|
|
102
|
+
|
|
103
|
+
# --no-default-features desliga `extension-module`, que manda o linker
|
|
104
|
+
# deixar os símbolos da libpython indefinidos. Correto para a wheel, mas
|
|
105
|
+
# produz um binário de teste que não linka no Linux.
|
|
106
|
+
#
|
|
107
|
+
# ⚠️ O `rate_limit` é o único sem seção `[features]` no Cargo.toml — lá
|
|
108
|
+
# o `extension-module` vem só de `[tool.maturin].features`, e a flag é
|
|
109
|
+
# inócua. Alinhar o Cargo.toml dele é item pendente.
|
|
110
|
+
- name: Rust unit tests
|
|
111
|
+
# Uma vez por SO basta: o core não muda com a versão do Python.
|
|
112
|
+
if: matrix.python-version == '3.12'
|
|
113
|
+
env:
|
|
114
|
+
PYO3_PYTHON: python
|
|
115
|
+
run: cargo test --no-default-features --lib
|
|
116
|
+
|
|
117
|
+
# `pip install` aciona o backend maturin, compila a extensão e a instala
|
|
118
|
+
# no Python do runner. Ao contrário de `maturin develop`, não exige
|
|
119
|
+
# virtualenv — armadilha que custou um release do `monitor` (P1-9).
|
|
120
|
+
# Instalar não-editável faz os testes exercitarem o artefato que embarca.
|
|
121
|
+
- name: Build, install the extension and run the Python tests
|
|
122
|
+
run: |
|
|
123
|
+
python -m pip install --upgrade pip
|
|
124
|
+
python -m pip install ".[dev]"
|
|
125
|
+
python -m pytest -q
|
|
126
|
+
|
|
127
|
+
# ── BUILD ──────────────────────────────────────────────────────────────────
|
|
128
|
+
# Guardados por tag OU disparo manual: um push comum não precisa pagar a
|
|
129
|
+
# matriz inteira, mas o ensaio no TestPyPI precisa dos artefatos.
|
|
130
|
+
#
|
|
131
|
+
# `fail-fast: false` em todas as matrizes: sem isso, um alvo que cai CANCELA
|
|
132
|
+
# os irmãos. Em 12/08/2026 o build x86 do `scheduler` quebrou num 403 do
|
|
133
|
+
# vault.centos.org e levou junto o diagnóstico de armv7/s390x/ppc64le.
|
|
134
|
+
#
|
|
135
|
+
# Nenhum job de build passa `--find-interpreter`: com `abi3-py310` ele não
|
|
136
|
+
# produz nada a mais. Verificado — o `audit` publicava 7 wheels, todos abi3,
|
|
137
|
+
# com a flag ligada. É flag morto e confunde quem lê.
|
|
138
|
+
linux:
|
|
139
|
+
runs-on: ubuntu-latest
|
|
140
|
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
|
141
|
+
needs: [lint, test]
|
|
142
|
+
strategy:
|
|
143
|
+
fail-fast: false
|
|
144
|
+
matrix:
|
|
145
|
+
target: [x86_64, aarch64]
|
|
146
|
+
steps:
|
|
147
|
+
- uses: actions/checkout@v4
|
|
148
|
+
- uses: PyO3/maturin-action@v1
|
|
149
|
+
with:
|
|
150
|
+
target: ${{ matrix.target }}
|
|
151
|
+
args: --release --out dist
|
|
152
|
+
# `auto` constrói dentro do container manylinux, para o wheel
|
|
153
|
+
# declarar um piso de glibc portável em vez do glibc do runner.
|
|
154
|
+
manylinux: auto
|
|
155
|
+
- uses: actions/upload-artifact@v4
|
|
156
|
+
with:
|
|
157
|
+
name: wheels-linux-${{ matrix.target }}
|
|
158
|
+
path: dist
|
|
159
|
+
|
|
160
|
+
musllinux:
|
|
161
|
+
runs-on: ubuntu-latest
|
|
162
|
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
|
163
|
+
needs: [lint, test]
|
|
164
|
+
strategy:
|
|
165
|
+
fail-fast: false
|
|
166
|
+
matrix:
|
|
167
|
+
target: [x86_64, aarch64]
|
|
168
|
+
steps:
|
|
169
|
+
- uses: actions/checkout@v4
|
|
170
|
+
- uses: PyO3/maturin-action@v1
|
|
171
|
+
with:
|
|
172
|
+
target: ${{ matrix.target }}
|
|
173
|
+
args: --release --out dist
|
|
174
|
+
manylinux: musllinux_1_2
|
|
175
|
+
- uses: actions/upload-artifact@v4
|
|
176
|
+
with:
|
|
177
|
+
name: wheels-musllinux-${{ matrix.target }}
|
|
178
|
+
path: dist
|
|
179
|
+
|
|
180
|
+
windows:
|
|
181
|
+
runs-on: windows-latest
|
|
182
|
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
|
183
|
+
needs: [lint, test]
|
|
184
|
+
steps:
|
|
185
|
+
- uses: actions/checkout@v4
|
|
186
|
+
- uses: actions/setup-python@v5
|
|
187
|
+
with:
|
|
188
|
+
architecture: x64
|
|
189
|
+
- uses: PyO3/maturin-action@v1
|
|
190
|
+
with:
|
|
191
|
+
target: x64
|
|
192
|
+
args: --release --out dist
|
|
193
|
+
- uses: actions/upload-artifact@v4
|
|
194
|
+
with:
|
|
195
|
+
name: wheels-windows-x64
|
|
196
|
+
path: dist
|
|
197
|
+
|
|
198
|
+
macos:
|
|
199
|
+
runs-on: macos-latest
|
|
200
|
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
|
201
|
+
needs: [lint, test]
|
|
202
|
+
strategy:
|
|
203
|
+
fail-fast: false
|
|
204
|
+
matrix:
|
|
205
|
+
target: [x86_64, aarch64]
|
|
206
|
+
steps:
|
|
207
|
+
- uses: actions/checkout@v4
|
|
208
|
+
- uses: PyO3/maturin-action@v1
|
|
209
|
+
with:
|
|
210
|
+
target: ${{ matrix.target }}
|
|
211
|
+
args: --release --out dist
|
|
212
|
+
- uses: actions/upload-artifact@v4
|
|
213
|
+
with:
|
|
214
|
+
name: wheels-macos-${{ matrix.target }}
|
|
215
|
+
path: dist
|
|
216
|
+
|
|
217
|
+
sdist:
|
|
218
|
+
runs-on: ubuntu-latest
|
|
219
|
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
|
220
|
+
needs: [lint, test]
|
|
221
|
+
steps:
|
|
222
|
+
- uses: actions/checkout@v4
|
|
223
|
+
- uses: PyO3/maturin-action@v1
|
|
224
|
+
with:
|
|
225
|
+
command: sdist
|
|
226
|
+
args: --out dist
|
|
227
|
+
- uses: actions/upload-artifact@v4
|
|
228
|
+
with:
|
|
229
|
+
name: wheels-sdist
|
|
230
|
+
path: dist
|
|
231
|
+
|
|
232
|
+
# ── ENSAIO ─────────────────────────────────────────────────────────────────
|
|
233
|
+
# Valida a troca OIDC inteira sem tocar no índice de produção. É onde um
|
|
234
|
+
# publisher mal cadastrado deve ser descoberto — no PyPI, um nome de arquivo
|
|
235
|
+
# publicado nunca pode ser reusado.
|
|
236
|
+
#
|
|
237
|
+
# ⚠️ Precisa do seu próprio publisher, cadastrado no TestPyPI (conta separada
|
|
238
|
+
# do PyPI), com environment `testpypi`.
|
|
239
|
+
#
|
|
240
|
+
# Sem verificação pós-upload de propósito: o que se valida aqui é a troca de
|
|
241
|
+
# token, e o TestPyPI expira distribuições — reprovar por um arquivo que o
|
|
242
|
+
# índice de teste descartou seria alarme falso.
|
|
243
|
+
publish-testpypi:
|
|
244
|
+
name: Publish to TestPyPI (ensaio)
|
|
245
|
+
runs-on: ubuntu-latest
|
|
246
|
+
if: github.event_name == 'workflow_dispatch'
|
|
247
|
+
needs: [lint, test, linux, musllinux, windows, macos, sdist]
|
|
248
|
+
environment: testpypi
|
|
249
|
+
permissions:
|
|
250
|
+
id-token: write
|
|
251
|
+
steps:
|
|
252
|
+
- uses: actions/download-artifact@v4
|
|
253
|
+
with:
|
|
254
|
+
pattern: wheels-*
|
|
255
|
+
path: dist
|
|
256
|
+
merge-multiple: true
|
|
257
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
258
|
+
with:
|
|
259
|
+
repository-url: https://test.pypi.org/legacy/
|
|
260
|
+
packages-dir: dist
|
|
261
|
+
skip-existing: true
|
|
262
|
+
|
|
263
|
+
# ── PUBLICAÇÃO ─────────────────────────────────────────────────────────────
|
|
264
|
+
release:
|
|
265
|
+
name: Publish to PyPI
|
|
266
|
+
runs-on: ubuntu-latest
|
|
267
|
+
# SÓ TAG. Sem a cláusula de `workflow_dispatch` que os jobs de build têm —
|
|
268
|
+
# a assimetria é a proteção: disparo manual vai ao TestPyPI, só a tag chega
|
|
269
|
+
# ao índice de verdade.
|
|
270
|
+
#
|
|
271
|
+
# ⚠️ Isto depende do filtro `tags: ["v*"]` lá em cima. O `scheduler` usava
|
|
272
|
+
# `'*'`, e como esta guarda só checa `refs/tags/`, QUALQUER tag empurrada
|
|
273
|
+
# teria publicado. Corrigido em 12/08/2026.
|
|
274
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
275
|
+
needs: [lint, test, linux, musllinux, windows, macos, sdist]
|
|
276
|
+
|
|
277
|
+
# Trusted publishing (OIDC): sem `secrets.PYPI_API_TOKEN`. O GitHub emite
|
|
278
|
+
# um JWT curto afirmando repositório, workflow e ref, e o PyPI o troca por
|
|
279
|
+
# um token de upload de poucos minutos, restrito a este projeto.
|
|
280
|
+
#
|
|
281
|
+
# ⚠️ Os três valores precisam bater EXATAMENTE com o publisher cadastrado
|
|
282
|
+
# em PyPI → Manage → Publishing:
|
|
283
|
+
# owner/repo .... <owner>/<repo>
|
|
284
|
+
# workflow ...... release.yml (o NOME DO ARQUIVO, não o `name:` acima)
|
|
285
|
+
# environment ... pypi
|
|
286
|
+
# Errar qualquer um dá `invalid-publisher`, que é falha segura: acontece na
|
|
287
|
+
# troca do token, depois de construir tudo e antes de qualquer arquivo sair
|
|
288
|
+
# do runner.
|
|
289
|
+
environment: pypi
|
|
290
|
+
permissions:
|
|
291
|
+
id-token: write
|
|
292
|
+
|
|
293
|
+
steps:
|
|
294
|
+
- uses: actions/download-artifact@v4
|
|
295
|
+
with:
|
|
296
|
+
pattern: wheels-*
|
|
297
|
+
path: dist
|
|
298
|
+
merge-multiple: true
|
|
299
|
+
- uses: actions/setup-python@v5
|
|
300
|
+
with:
|
|
301
|
+
python-version: "3.12"
|
|
302
|
+
- name: Install the verification dependency
|
|
303
|
+
run: python -m pip install --upgrade requests
|
|
304
|
+
|
|
305
|
+
# Conferência antes de qualquer coisa sair do runner: publicar um
|
|
306
|
+
# conjunto incompleto é pior que não publicar, porque o PyPI nunca deixa
|
|
307
|
+
# reusar um nome de arquivo — uma versão meio-publicada só pode ser
|
|
308
|
+
# completada, nunca substituída. Aconteceu de verdade: `monitor` 0.4.0
|
|
309
|
+
# ficou com 37 de 45 arquivos e sem sdist (P1-9).
|
|
310
|
+
- name: Verify the artifact set is complete
|
|
311
|
+
run: |
|
|
312
|
+
set -euo pipefail
|
|
313
|
+
ls -la dist/
|
|
314
|
+
test -n "$(find dist -name '*.tar.gz' -print -quit)" \
|
|
315
|
+
|| { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
|
|
316
|
+
wheels=$(find dist -name '*.whl' | wc -l)
|
|
317
|
+
echo "wheels: $wheels (mínimo ${MIN_WHEELS})"
|
|
318
|
+
test "$wheels" -ge "${MIN_WHEELS}" \
|
|
319
|
+
|| { echo "::error::só $wheels wheels; esperado >= ${MIN_WHEELS}"; exit 1; }
|
|
320
|
+
|
|
321
|
+
# O PyPI devolve 500 transitório com frequência suficiente para importar,
|
|
322
|
+
# e o upload é arquivo por arquivo: uma falha no meio deixa a versão
|
|
323
|
+
# publicada pela metade. `skip-existing` torna a segunda tentativa segura
|
|
324
|
+
# — sem ele, ela morre nos arquivos que já subiram.
|
|
325
|
+
#
|
|
326
|
+
# Dois passos explícitos em vez de um wrapper de retry porque o trusted
|
|
327
|
+
# publishing exige a action oficial, que não dá para acionar por linha de
|
|
328
|
+
# comando de dentro do `nick-fields/retry`.
|
|
329
|
+
#
|
|
330
|
+
# ⚠️ Se a primeira tentativa falhar por `invalid-publisher`, a segunda vai
|
|
331
|
+
# falhar igual — é erro de configuração, não transitório. Custa 45s de
|
|
332
|
+
# espera inútil; melhoria conhecida e ainda não feita.
|
|
333
|
+
- name: Publish to PyPI (attempt 1)
|
|
334
|
+
id: publish1
|
|
335
|
+
continue-on-error: true
|
|
336
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
337
|
+
with:
|
|
338
|
+
packages-dir: dist
|
|
339
|
+
skip-existing: true
|
|
340
|
+
|
|
341
|
+
- name: Wait before retrying
|
|
342
|
+
if: steps.publish1.outcome == 'failure'
|
|
343
|
+
run: sleep 45
|
|
344
|
+
|
|
345
|
+
- name: Publish to PyPI (attempt 2)
|
|
346
|
+
if: steps.publish1.outcome == 'failure'
|
|
347
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
348
|
+
with:
|
|
349
|
+
packages-dir: dist
|
|
350
|
+
skip-existing: true
|
|
351
|
+
|
|
352
|
+
# Prova que a release está completa, em vez de confiar num código de
|
|
353
|
+
# saída que uma publicação parcial também produz.
|
|
354
|
+
- name: Verify every file reached PyPI
|
|
355
|
+
run: |
|
|
356
|
+
set -euo pipefail
|
|
357
|
+
python - <<'PY'
|
|
358
|
+
import os, sys, glob, time, requests
|
|
359
|
+
package = os.environ["PACKAGE_NAME"]
|
|
360
|
+
version = os.environ["GITHUB_REF_NAME"].lstrip("v")
|
|
361
|
+
# Só .whl e .tar.gz: os únicos tipos que o PyPI hospeda. O
|
|
362
|
+
# `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
|
|
363
|
+
# distribuição em dist/ DEPOIS de publicar (atestados PEP 740, que
|
|
364
|
+
# sobem junto do arquivo e não como arquivo próprio). Um glob amplo
|
|
365
|
+
# os conta como "faltando" e reprova uma release que deu certo — foi
|
|
366
|
+
# o alarme falso da rate_limit 0.2.1 (P1-9).
|
|
367
|
+
local = {
|
|
368
|
+
os.path.basename(p)
|
|
369
|
+
for pattern in ("dist/*.whl", "dist/*.tar.gz")
|
|
370
|
+
for p in glob.glob(pattern)
|
|
371
|
+
}
|
|
372
|
+
url = f"https://pypi.org/pypi/{package}/{version}/json"
|
|
373
|
+
for attempt in range(6): # a API JSON do PyPI é cacheada
|
|
374
|
+
response = requests.get(url, timeout=30)
|
|
375
|
+
if response.status_code == 200:
|
|
376
|
+
remote = {f["filename"] for f in response.json()["urls"]}
|
|
377
|
+
missing = local - remote
|
|
378
|
+
if not missing:
|
|
379
|
+
print(f"OK: {len(remote)} arquivos publicados na {version}")
|
|
380
|
+
sys.exit(0)
|
|
381
|
+
else:
|
|
382
|
+
missing = local
|
|
383
|
+
print(f"tentativa {attempt + 1}: faltam {len(missing)}")
|
|
384
|
+
time.sleep(20)
|
|
385
|
+
print("::error::faltaram arquivos no PyPI:")
|
|
386
|
+
for name in sorted(missing):
|
|
387
|
+
print(f" - {name}")
|
|
388
|
+
sys.exit(1)
|
|
389
|
+
PY
|
|
@@ -5,7 +5,78 @@ Todas as mudanças relevantes deste projeto são registradas aqui.
|
|
|
5
5
|
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
6
|
e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
|
7
7
|
|
|
8
|
-
## [0.
|
|
8
|
+
## [0.3.0] — não publicada
|
|
9
|
+
|
|
10
|
+
Evicção LRU com custo amortizado O(1) (**P2-2**). Inclui também tudo o que
|
|
11
|
+
estava na 0.2.4, que nunca chegou a ser publicada.
|
|
12
|
+
|
|
13
|
+
### Alterado
|
|
14
|
+
|
|
15
|
+
- **A evicção LRU passou a remover um lote, não uma entrada.** Até a 0.2.x,
|
|
16
|
+
achar a entrada menos recentemente usada varria o `DashMap` inteiro — e isso
|
|
17
|
+
acontecia a **cada** `set` de chave nova com o cache cheio, que é o regime
|
|
18
|
+
normal de um cache cheio sob escrita. Agora cada varredura remove
|
|
19
|
+
`max_size / 32` entradas (no mínimo 1), e a próxima só vem depois de outras
|
|
20
|
+
~n/32 inserções. Medido com `max_size=100_000`: **~620 µs → ~1,6 µs** por
|
|
21
|
+
`set` com o cache cheio (com 10 000: ~70 µs → ~1,5 µs). O custo deixou de
|
|
22
|
+
crescer com o tamanho do cache.
|
|
23
|
+
- A seleção acha o corte com `select_nth_unstable` sobre os carimbos de acesso
|
|
24
|
+
(sem clonar chaves nem ordenar) e remove com `remove_if`, que recheca o
|
|
25
|
+
carimbo: uma chave lida por outra thread no meio da varredura fica.
|
|
26
|
+
- Um `Mutex` cobre **só** o caminho de evicção, para várias threads que acham o
|
|
27
|
+
cache cheio ao mesmo tempo não varrerem em paralelo. `get`/`set` comuns não o
|
|
28
|
+
tocam.
|
|
29
|
+
|
|
30
|
+
### Compatibilidade
|
|
31
|
+
|
|
32
|
+
- Nenhuma API mudou. O que muda de observável, e por isso o minor sobe: logo
|
|
33
|
+
depois de uma evicção o cache fica ~3% abaixo de `max_size` (antes ficava em
|
|
34
|
+
`max_size - 1`), e `stats()["evicted"]` cresce em lotes. Com `max_size < 64`
|
|
35
|
+
o lote é de 1 entrada e o comportamento é idêntico ao anterior.
|
|
36
|
+
- Amostragem estilo Redis, que o backlog sugeria, foi descartada: exigiria
|
|
37
|
+
acesso aleatório às entradas, que o `DashMap` só expõe pela `raw-api` (tabela
|
|
38
|
+
interna do hashbrown, iteração `unsafe`).
|
|
39
|
+
|
|
40
|
+
## [0.2.4] — não publicada
|
|
41
|
+
|
|
42
|
+
Adoção do `release.yml` unificado do template (**P3-1**). Nada muda no
|
|
43
|
+
comportamento da biblioteca — mas muda, e bastante, o que precisa passar antes
|
|
44
|
+
de uma versão chegar ao PyPI.
|
|
45
|
+
|
|
46
|
+
### Corrigido
|
|
47
|
+
|
|
48
|
+
- **O release era barrado pela suíte mais fraca das duas.** O projeto tinha
|
|
49
|
+
dois workflows, e a divisão escondia uma inversão:
|
|
50
|
+
|
|
51
|
+
| | rodava | barrava o release? |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `ci.yml` | ubuntu + macOS × Python 3.10/3.12/3.13 | **não** |
|
|
54
|
+
| `release.yml` | ubuntu × Python 3.12 | sim |
|
|
55
|
+
|
|
56
|
+
Ou seja, a suíte boa não protegia nada e o portão do PyPI era a magra.
|
|
57
|
+
`needs` não atravessa workflow — a mesma lição do P1-1, numa segunda forma.
|
|
58
|
+
Os dois arquivos ainda mantinham o job `lint` duplicado.
|
|
59
|
+
|
|
60
|
+
Agora é um workflow só, e quem barra a publicação é
|
|
61
|
+
**ubuntu × Python 3.10/3.11/3.12/3.13 + macOS × 3.12**.
|
|
62
|
+
|
|
63
|
+
⚠️ O eixo de macOS foi reduzido de três versões de Python para uma. Rodar as
|
|
64
|
+
três dobrava a matriz para provar a mesma coisa; uma basta para pegar o que
|
|
65
|
+
o ubuntu não pega (link da extensão, caminhos, threads).
|
|
66
|
+
|
|
67
|
+
### Adicionado
|
|
68
|
+
|
|
69
|
+
- **Ensaio no TestPyPI** (`publish-testpypi`, por `workflow_dispatch`), que o
|
|
70
|
+
projeto não tinha. Valida a troca de token OIDC sem tocar no índice de
|
|
71
|
+
produção. ⚠️ Exige um publisher próprio cadastrado no TestPyPI.
|
|
72
|
+
- **`fail-fast: false`** nas matrizes: um alvo que cai deixava de cancelar os
|
|
73
|
+
irmãos e apagar o diagnóstico deles.
|
|
74
|
+
|
|
75
|
+
### Removido
|
|
76
|
+
|
|
77
|
+
- **`.github/workflows/ci.yml`**, fundido no `release.yml`.
|
|
78
|
+
|
|
79
|
+
## [0.2.3] - 2026-08-12
|
|
9
80
|
|
|
10
81
|
Versão de empacotamento e qualidade: nada muda no comportamento da biblioteca.
|
|
11
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-cache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
@@ -73,7 +73,7 @@ cache.stats() # {'hits','misses','sets','deletes','expired','evi
|
|
|
73
73
|
|
|
74
74
|
```python
|
|
75
75
|
# Cap the number of keys. When full and a new key arrives, evict the
|
|
76
|
-
# least-recently-used
|
|
76
|
+
# least-recently-used entries (a ~3% batch) instead of rejecting the write.
|
|
77
77
|
cache = Cache(max_size=1000, eviction_policy="lru")
|
|
78
78
|
cache.eviction_policy # "lru"
|
|
79
79
|
|
|
@@ -138,7 +138,12 @@ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=No
|
|
|
138
138
|
accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
|
|
139
139
|
also run a **background** sweeper thread that reclaims expired keys on its own.
|
|
140
140
|
- **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
|
|
141
|
-
least-recently-used
|
|
141
|
+
least-recently-used entries (recency updated on every `get` hit) to admit a new key.
|
|
142
|
+
It removes them in a **batch** of `max_size / 32` (at least 1) per pass, so the
|
|
143
|
+
O(n) scan that finds them runs once every ~n/32 inserts instead of on every one —
|
|
144
|
+
amortized O(1) per write, with no global lock on the `get`/`set` path. A `set` on
|
|
145
|
+
a full 100k-entry cache went from ~620 µs (one full scan per insert, up to 0.2.x)
|
|
146
|
+
to ~1.6 µs. Right after a pass the cache sits ~3% below `max_size`.
|
|
142
147
|
|
|
143
148
|
## Limitations
|
|
144
149
|
|
|
@@ -52,7 +52,7 @@ cache.stats() # {'hits','misses','sets','deletes','expired','evi
|
|
|
52
52
|
|
|
53
53
|
```python
|
|
54
54
|
# Cap the number of keys. When full and a new key arrives, evict the
|
|
55
|
-
# least-recently-used
|
|
55
|
+
# least-recently-used entries (a ~3% batch) instead of rejecting the write.
|
|
56
56
|
cache = Cache(max_size=1000, eviction_policy="lru")
|
|
57
57
|
cache.eviction_policy # "lru"
|
|
58
58
|
|
|
@@ -117,7 +117,12 @@ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=No
|
|
|
117
117
|
accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
|
|
118
118
|
also run a **background** sweeper thread that reclaims expired keys on its own.
|
|
119
119
|
- **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
|
|
120
|
-
least-recently-used
|
|
120
|
+
least-recently-used entries (recency updated on every `get` hit) to admit a new key.
|
|
121
|
+
It removes them in a **batch** of `max_size / 32` (at least 1) per pass, so the
|
|
122
|
+
O(n) scan that finds them runs once every ~n/32 inserts instead of on every one —
|
|
123
|
+
amortized O(1) per write, with no global lock on the `get`/`set` path. A `set` on
|
|
124
|
+
a full 100k-entry cache went from ~620 µs (one full scan per insert, up to 0.2.x)
|
|
125
|
+
to ~1.6 µs. Right after a pass the cache sits ~3% below `max_size`.
|
|
121
126
|
|
|
122
127
|
## Limitations
|
|
123
128
|
|
|
@@ -36,16 +36,21 @@ the stages already shipped and what comes next.
|
|
|
36
36
|
### LRU eviction
|
|
37
37
|
|
|
38
38
|
- ✅ `Cache(max_size=..., eviction_policy="lru")` evicts the **least-recently-used**
|
|
39
|
-
|
|
39
|
+
entries once the cap is hit (recency updated on every `get` hit). Default policy
|
|
40
40
|
is `"reject"` (backward compatible). Evictions are surfaced in `stats()` as an
|
|
41
41
|
`evicted` counter.
|
|
42
|
+
- ✅ **v0.3 — amortized O(1) eviction.** Evicts a batch of `max_size / 32` per
|
|
43
|
+
scan instead of one entry per scan, so a full cache under sustained writes no
|
|
44
|
+
longer pays an O(n) scan on every new key (~620 µs → ~1.6 µs per `set` at 100k
|
|
45
|
+
entries).
|
|
42
46
|
|
|
43
47
|
## Planned
|
|
44
48
|
|
|
45
|
-
### v0.
|
|
49
|
+
### v0.4 — LFU eviction
|
|
46
50
|
|
|
47
51
|
- 🔜 **Least-frequently-used** eviction policy as an alternative to LRU, selectable
|
|
48
|
-
at construction time (e.g. `Cache(max_size=..., policy="lfu")`).
|
|
52
|
+
at construction time (e.g. `Cache(max_size=..., policy="lfu")`). Reuses the
|
|
53
|
+
batch selection from v0.3, keyed on `hits` instead of `last_accessed_at`.
|
|
49
54
|
|
|
50
55
|
### v0.5 — Configurable serializer
|
|
51
56
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
//! de testar em Rust puro e para separar responsabilidades: `lib.rs` cuida da
|
|
5
5
|
//! ponte com o Python; `cache.rs` cuida do armazenamento.
|
|
6
6
|
|
|
7
|
+
use std::sync::Mutex;
|
|
8
|
+
|
|
7
9
|
use dashmap::DashMap;
|
|
8
10
|
|
|
9
11
|
use crate::entry::CacheEntry;
|
|
@@ -15,10 +17,14 @@ pub enum EvictionPolicy {
|
|
|
15
17
|
/// Rejeita a escrita (`set` devolve `false`). Comportamento padrão.
|
|
16
18
|
#[default]
|
|
17
19
|
Reject,
|
|
18
|
-
/// Remove
|
|
20
|
+
/// Remove as entradas menos recentemente usadas (LRU) para abrir espaço.
|
|
19
21
|
Lru,
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/// Fração de `max_size` removida de uma vez quando a LRU precisa abrir espaço
|
|
25
|
+
/// (1/32 ≈ 3%, no mínimo 1 entrada). Ver [`RustCache::evict_lru_batch`].
|
|
26
|
+
const EVICTION_BATCH_DIVISOR: usize = 32;
|
|
27
|
+
|
|
22
28
|
/// Núcleo concorrente do cache.
|
|
23
29
|
///
|
|
24
30
|
/// `DashMap` é um `HashMap` thread-safe com *sharding*: ele divide o mapa em
|
|
@@ -37,6 +43,11 @@ pub struct RustCache {
|
|
|
37
43
|
max_size: Option<usize>,
|
|
38
44
|
/// Política aplicada quando o cache enche e chega uma chave nova.
|
|
39
45
|
eviction_policy: EvictionPolicy,
|
|
46
|
+
/// Serializa só o caminho de evicção: quando várias threads encontram o
|
|
47
|
+
/// cache cheio ao mesmo tempo, uma varre e as outras, ao entrar, veem que
|
|
48
|
+
/// já há espaço e seguem — sem N varreduras simultâneas. Não é tocado no
|
|
49
|
+
/// caminho comum de `get`/`set`.
|
|
50
|
+
eviction_lock: Mutex<()>,
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
impl RustCache {
|
|
@@ -47,6 +58,7 @@ impl RustCache {
|
|
|
47
58
|
stats: CacheStats::new(),
|
|
48
59
|
max_size,
|
|
49
60
|
eviction_policy,
|
|
61
|
+
eviction_lock: Mutex::new(()),
|
|
50
62
|
}
|
|
51
63
|
}
|
|
52
64
|
|
|
@@ -55,17 +67,67 @@ impl RustCache {
|
|
|
55
67
|
self.eviction_policy
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
///
|
|
59
|
-
///
|
|
60
|
-
///
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
/// Abre espaço removendo, de uma vez, as `max / 32` (no mínimo 1) entradas
|
|
71
|
+
/// menos recentemente usadas. Conta cada uma em `evicted`.
|
|
72
|
+
///
|
|
73
|
+
/// ## Por que em lote
|
|
74
|
+
///
|
|
75
|
+
/// Achar a entrada mais antiga num `DashMap` exige varrer o mapa inteiro —
|
|
76
|
+
/// não há ordem nem acesso aleatório. Até a 0.2 isso acontecia a **cada**
|
|
77
|
+
/// `set` de chave nova com o cache cheio, que é o regime normal de um cache
|
|
78
|
+
/// cheio sob escrita: O(n) por inserção. Removendo ~3% por varredura, a
|
|
79
|
+
/// próxima varredura só vem depois de outras ~n/32 inserções — custo
|
|
80
|
+
/// amortizado O(1) (≈ 64 visitas por inserção), sem lock global no caminho
|
|
81
|
+
/// comum e sem `unsafe` para amostrar shards.
|
|
82
|
+
///
|
|
83
|
+
/// Uma lista LRU exata exigiria um lock global em todo `get`, que é o que o
|
|
84
|
+
/// `DashMap` existe para evitar. A amostragem estilo Redis exigiria acesso
|
|
85
|
+
/// aleatório às entradas, que o `DashMap` só oferece pela `raw-api`
|
|
86
|
+
/// (tabela interna do hashbrown, iteração `unsafe`).
|
|
87
|
+
///
|
|
88
|
+
/// ## Como
|
|
89
|
+
///
|
|
90
|
+
/// 1. Coleta só os `last_accessed_at` (um `Vec<u64>`, sem clonar chaves) e
|
|
91
|
+
/// acha o corte do lote com `select_nth_unstable` — O(n), sem ordenar.
|
|
92
|
+
/// 2. Coleta as chaves com acesso `<= corte` (até o tamanho do lote).
|
|
93
|
+
/// 3. Remove com `remove_if`, que **recheca** o carimbo: uma chave lida por
|
|
94
|
+
/// outra thread entre as fases deixou de ser LRU e fica.
|
|
95
|
+
fn evict_lru_batch(&self, max: usize) {
|
|
96
|
+
let _guard = self
|
|
97
|
+
.eviction_lock
|
|
98
|
+
.lock()
|
|
99
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
100
|
+
// Outra thread pode ter aberto espaço enquanto esperávamos o lock.
|
|
101
|
+
if self.store.len() < max {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let mut stamps: Vec<u64> = self
|
|
63
106
|
.store
|
|
64
107
|
.iter()
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
if
|
|
68
|
-
|
|
108
|
+
.map(|e| e.value().last_accessed_at)
|
|
109
|
+
.collect();
|
|
110
|
+
if stamps.is_empty() {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let batch = (max / EVICTION_BATCH_DIVISOR).clamp(1, stamps.len());
|
|
114
|
+
let (_, &mut cutoff, _) = stamps.select_nth_unstable(batch - 1);
|
|
115
|
+
drop(stamps);
|
|
116
|
+
|
|
117
|
+
let victims: Vec<String> = self
|
|
118
|
+
.store
|
|
119
|
+
.iter()
|
|
120
|
+
.filter(|e| e.value().last_accessed_at <= cutoff)
|
|
121
|
+
.take(batch)
|
|
122
|
+
.map(|e| e.key().clone())
|
|
123
|
+
.collect();
|
|
124
|
+
|
|
125
|
+
for key in victims {
|
|
126
|
+
if self
|
|
127
|
+
.store
|
|
128
|
+
.remove_if(&key, |_, v| v.last_accessed_at <= cutoff)
|
|
129
|
+
.is_some()
|
|
130
|
+
{
|
|
69
131
|
self.stats.record_evicted();
|
|
70
132
|
}
|
|
71
133
|
}
|
|
@@ -79,14 +141,15 @@ impl RustCache {
|
|
|
79
141
|
///
|
|
80
142
|
/// Sobrescrever uma chave existente sempre é permitido. Para uma chave
|
|
81
143
|
/// **nova** com o cache cheio (`max_size`): com `Reject` devolve `false` sem
|
|
82
|
-
/// inserir; com `Lru` remove
|
|
144
|
+
/// inserir; com `Lru` remove um lote das menos recentemente usadas (ver
|
|
145
|
+
/// [`Self::evict_lru_batch`]) e prossegue.
|
|
83
146
|
/// Devolve `true` quando a entrada foi (ou seria) gravada.
|
|
84
147
|
pub fn set(&self, key: String, value: Vec<u8>, expires_at: Option<u64>, now: u64) -> bool {
|
|
85
148
|
if let Some(max) = self.max_size {
|
|
86
149
|
if self.store.len() >= max && !self.store.contains_key(&key) {
|
|
87
150
|
match self.eviction_policy {
|
|
88
151
|
EvictionPolicy::Reject => return false,
|
|
89
|
-
EvictionPolicy::Lru => self.
|
|
152
|
+
EvictionPolicy::Lru => self.evict_lru_batch(max),
|
|
90
153
|
}
|
|
91
154
|
}
|
|
92
155
|
}
|
|
@@ -314,6 +377,45 @@ mod tests {
|
|
|
314
377
|
assert!(c.get("c", NOW + 11).is_some());
|
|
315
378
|
}
|
|
316
379
|
|
|
380
|
+
#[test]
|
|
381
|
+
fn lru_evicts_a_batch_of_the_oldest() {
|
|
382
|
+
// max 64 => lote de 64/32 = 2.
|
|
383
|
+
let c = RustCache::new(Some(64), EvictionPolicy::Lru);
|
|
384
|
+
for i in 0..64u64 {
|
|
385
|
+
c.set(format!("k{i}"), b"v".to_vec(), None, NOW + i);
|
|
386
|
+
}
|
|
387
|
+
// k0 e k1 são as mais antigas; tocar em k0 a torna a mais recente.
|
|
388
|
+
assert!(c.get("k0", NOW + 100).is_some());
|
|
389
|
+
assert!(c.set("new".into(), b"v".to_vec(), None, NOW + 101));
|
|
390
|
+
assert_eq!(c.stats().evicted, 2);
|
|
391
|
+
assert_eq!(c.len(), 63);
|
|
392
|
+
assert!(c.exists("k0", NOW + 102));
|
|
393
|
+
assert!(!c.exists("k1", NOW + 102));
|
|
394
|
+
assert!(!c.exists("k2", NOW + 102));
|
|
395
|
+
assert!(c.exists("k3", NOW + 102));
|
|
396
|
+
assert!(c.exists("new", NOW + 102));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn lru_amortizes_the_scan() {
|
|
401
|
+
// Escrita sustentada com o cache cheio: o número de varreduras é
|
|
402
|
+
// ~inserções / lote, não uma por inserção.
|
|
403
|
+
let max = 1_024;
|
|
404
|
+
let c = RustCache::new(Some(max), EvictionPolicy::Lru);
|
|
405
|
+
for i in 0..(max as u64 * 4) {
|
|
406
|
+
c.set(format!("k{i}"), b"v".to_vec(), None, NOW + i);
|
|
407
|
+
assert!(c.len() <= max);
|
|
408
|
+
}
|
|
409
|
+
let evicted = c.stats().evicted;
|
|
410
|
+
let batch = (max / EVICTION_BATCH_DIVISOR) as u64;
|
|
411
|
+
assert_eq!(evicted % batch, 0, "sempre um lote cheio por varredura");
|
|
412
|
+
// 3 * max inserções além da capacidade => ~3 * 32 varreduras.
|
|
413
|
+
assert!(evicted / batch <= 3 * EVICTION_BATCH_DIVISOR as u64 + 1);
|
|
414
|
+
// As sobreviventes são as mais recentes.
|
|
415
|
+
assert!(c.exists(&format!("k{}", max as u64 * 4 - 1), NOW));
|
|
416
|
+
assert!(!c.exists("k0", NOW));
|
|
417
|
+
}
|
|
418
|
+
|
|
317
419
|
#[test]
|
|
318
420
|
fn no_max_size_never_evicts() {
|
|
319
421
|
let c = RustCache::new(None, EvictionPolicy::Lru);
|
|
@@ -18,7 +18,7 @@ pub struct CacheEntry {
|
|
|
18
18
|
pub expires_at: Option<u64>,
|
|
19
19
|
/// Quando a entrada foi criada (epoch-ms).
|
|
20
20
|
pub created_at: u64,
|
|
21
|
-
/// Último acesso por `get` bem-sucedido (epoch-ms) — base
|
|
21
|
+
/// Último acesso por `get` bem-sucedido (epoch-ms) — base da evicção LRU.
|
|
22
22
|
pub last_accessed_at: u64,
|
|
23
23
|
/// Quantas vezes esta entrada foi lida — base para LFU futuro.
|
|
24
24
|
pub hits: u64,
|
|
@@ -48,6 +48,21 @@ def test_lru_evicts_to_make_room():
|
|
|
48
48
|
assert cache.stats()["evicted"] == 1
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_lru_evicts_in_batches_and_stays_bounded():
|
|
52
|
+
# max_size 320 => lote de 10 por varredura.
|
|
53
|
+
cache = Cache(max_size=320, eviction_policy="lru")
|
|
54
|
+
for i in range(320):
|
|
55
|
+
cache.set(f"k{i}", i)
|
|
56
|
+
cache.set("new", 1)
|
|
57
|
+
assert cache.stats()["evicted"] == 10
|
|
58
|
+
assert len(cache) == 311
|
|
59
|
+
assert cache.get("new") == 1
|
|
60
|
+
for i in range(5_000):
|
|
61
|
+
cache.set(f"x{i}", i)
|
|
62
|
+
assert len(cache) <= 320
|
|
63
|
+
assert cache.stats()["evicted"] % 10 == 0
|
|
64
|
+
|
|
65
|
+
|
|
51
66
|
def test_lru_keeps_recently_used():
|
|
52
67
|
cache = Cache(max_size=2, eviction_policy="lru")
|
|
53
68
|
cache.set("a", 1)
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
workflow_dispatch:
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
# O lint faz parte do portão de release, não de um workflow paralelo.
|
|
11
|
-
# Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
|
|
12
|
-
# publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
|
|
13
|
-
# inequívoca: vermelho significa que não publicou.
|
|
14
|
-
#
|
|
15
|
-
# A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
|
|
16
|
-
# no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
|
|
17
|
-
# conta do calendário. Subir este número vira uma mudança deliberada.
|
|
18
|
-
lint:
|
|
19
|
-
name: fmt + clippy
|
|
20
|
-
runs-on: ubuntu-latest
|
|
21
|
-
steps:
|
|
22
|
-
- uses: actions/checkout@v4
|
|
23
|
-
- uses: dtolnay/rust-toolchain@1.88.0
|
|
24
|
-
with:
|
|
25
|
-
components: rustfmt, clippy
|
|
26
|
-
- uses: Swatinem/rust-cache@v2
|
|
27
|
-
- name: cargo fmt
|
|
28
|
-
run: cargo fmt --all -- --check
|
|
29
|
-
# clippy só checa tipos, não linka, então --all-features é seguro aqui
|
|
30
|
-
# (diferente do `cargo test`, que precisa de --no-default-features).
|
|
31
|
-
- name: cargo clippy
|
|
32
|
-
run: cargo clippy --all-targets --all-features -- -D warnings
|
|
33
|
-
|
|
34
|
-
test:
|
|
35
|
-
name: cargo test + pytest
|
|
36
|
-
runs-on: ${{ matrix.os }}
|
|
37
|
-
strategy:
|
|
38
|
-
fail-fast: false
|
|
39
|
-
matrix:
|
|
40
|
-
os: [ubuntu-latest, macos-latest]
|
|
41
|
-
python-version: ["3.10", "3.12", "3.13"]
|
|
42
|
-
steps:
|
|
43
|
-
- uses: actions/checkout@v4
|
|
44
|
-
|
|
45
|
-
- name: Set up Python ${{ matrix.python-version }}
|
|
46
|
-
uses: actions/setup-python@v5
|
|
47
|
-
with:
|
|
48
|
-
python-version: ${{ matrix.python-version }}
|
|
49
|
-
|
|
50
|
-
- name: Set up Rust
|
|
51
|
-
uses: dtolnay/rust-toolchain@stable
|
|
52
|
-
|
|
53
|
-
- name: Cache cargo
|
|
54
|
-
uses: Swatinem/rust-cache@v2
|
|
55
|
-
|
|
56
|
-
# --no-default-features desliga extension-module, que manda o linker
|
|
57
|
-
# deixar os símbolos da libpython indefinidos. Isso é correto para a
|
|
58
|
-
# wheel, mas produz um binário de teste que não linka no Linux.
|
|
59
|
-
- name: cargo test
|
|
60
|
-
env:
|
|
61
|
-
PYO3_PYTHON: python
|
|
62
|
-
run: cargo test --no-default-features --lib
|
|
63
|
-
|
|
64
|
-
# `pip install` aciona o backend maturin, que compila a extensão e a
|
|
65
|
-
# instala no Python do runner — sem exigir virtualenv, ao contrário do
|
|
66
|
-
# `maturin develop`, que aborta quando não encontra um.
|
|
67
|
-
- name: Build, install the extension and run the Python tests
|
|
68
|
-
run: |
|
|
69
|
-
python -m pip install --upgrade pip
|
|
70
|
-
python -m pip install ".[dev]"
|
|
71
|
-
python -m pytest -q
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
name: Release
|
|
2
|
-
|
|
3
|
-
# Dispara ao publicar uma tag de versão (ex.: v0.1.0). Constrói as wheels para
|
|
4
|
-
# todas as plataformas + sdist e publica no PyPI via Trusted Publishing (OIDC),
|
|
5
|
-
# sem precisar guardar um token no repositório.
|
|
6
|
-
#
|
|
7
|
-
# O `ci.yml` roda em push/PR, mas é um workflow *separado*: um push de tag ia
|
|
8
|
-
# direto para build e publicação sem depender dele. O job `test` abaixo é o que
|
|
9
|
-
# torna a publicação condicionada a uma suíte verde — publicar no PyPI é
|
|
10
|
-
# irreversível, então não pode acontecer sobre código não verificado.
|
|
11
|
-
on:
|
|
12
|
-
push:
|
|
13
|
-
tags: ["v*"]
|
|
14
|
-
workflow_dispatch: {}
|
|
15
|
-
|
|
16
|
-
permissions:
|
|
17
|
-
contents: read
|
|
18
|
-
|
|
19
|
-
jobs:
|
|
20
|
-
# O lint faz parte do portão de release, não de um workflow paralelo.
|
|
21
|
-
# Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
|
|
22
|
-
# publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
|
|
23
|
-
# inequívoca: vermelho significa que não publicou.
|
|
24
|
-
#
|
|
25
|
-
# A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
|
|
26
|
-
# no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
|
|
27
|
-
# conta do calendário. Subir este número vira uma mudança deliberada.
|
|
28
|
-
lint:
|
|
29
|
-
name: fmt + clippy
|
|
30
|
-
runs-on: ubuntu-latest
|
|
31
|
-
steps:
|
|
32
|
-
- uses: actions/checkout@v4
|
|
33
|
-
- uses: dtolnay/rust-toolchain@1.88.0
|
|
34
|
-
with:
|
|
35
|
-
components: rustfmt, clippy
|
|
36
|
-
- uses: Swatinem/rust-cache@v2
|
|
37
|
-
- name: cargo fmt
|
|
38
|
-
run: cargo fmt --all -- --check
|
|
39
|
-
# clippy só checa tipos, não linka, então --all-features é seguro aqui
|
|
40
|
-
# (diferente do `cargo test`, que precisa de --no-default-features).
|
|
41
|
-
- name: cargo clippy
|
|
42
|
-
run: cargo clippy --all-targets --all-features -- -D warnings
|
|
43
|
-
|
|
44
|
-
test:
|
|
45
|
-
name: Test (Rust + Python)
|
|
46
|
-
runs-on: ubuntu-latest
|
|
47
|
-
steps:
|
|
48
|
-
- uses: actions/checkout@v4
|
|
49
|
-
- uses: dtolnay/rust-toolchain@stable
|
|
50
|
-
- uses: actions/setup-python@v5
|
|
51
|
-
with:
|
|
52
|
-
python-version: "3.12"
|
|
53
|
-
- name: cargo test
|
|
54
|
-
env:
|
|
55
|
-
PYO3_PYTHON: python
|
|
56
|
-
run: cargo test --no-default-features --lib
|
|
57
|
-
- name: Build, install the extension and run the Python tests
|
|
58
|
-
run: |
|
|
59
|
-
python -m pip install --upgrade pip
|
|
60
|
-
python -m pip install ".[dev]"
|
|
61
|
-
python -m pytest -q
|
|
62
|
-
|
|
63
|
-
# A matriz abaixo substitui um job `build` que rodava em ubuntu/macos/windows
|
|
64
|
-
# com `maturin build` puro. Aquilo publicava 3 wheels e o de Linux herdava a
|
|
65
|
-
# glibc do runner, em vez de passar pelo container manylinux. Quem estava em
|
|
66
|
-
# aarch64, em musl ou em Mac Intel caía no sdist — que exige toolchain Rust.
|
|
67
|
-
linux:
|
|
68
|
-
runs-on: ubuntu-latest
|
|
69
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
70
|
-
needs: [test, lint]
|
|
71
|
-
strategy:
|
|
72
|
-
matrix:
|
|
73
|
-
target: [x86_64, aarch64]
|
|
74
|
-
steps:
|
|
75
|
-
- uses: actions/checkout@v4
|
|
76
|
-
- uses: PyO3/maturin-action@v1
|
|
77
|
-
with:
|
|
78
|
-
target: ${{ matrix.target }}
|
|
79
|
-
args: --release --out dist
|
|
80
|
-
# `auto` constrói dentro do container manylinux, então a wheel
|
|
81
|
-
# declara um piso de glibc portável em vez do da máquina de build.
|
|
82
|
-
manylinux: auto
|
|
83
|
-
- uses: actions/upload-artifact@v4
|
|
84
|
-
with:
|
|
85
|
-
name: wheels-linux-${{ matrix.target }}
|
|
86
|
-
path: dist
|
|
87
|
-
|
|
88
|
-
musllinux:
|
|
89
|
-
runs-on: ubuntu-latest
|
|
90
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
91
|
-
needs: [test, lint]
|
|
92
|
-
strategy:
|
|
93
|
-
matrix:
|
|
94
|
-
target: [x86_64, aarch64]
|
|
95
|
-
steps:
|
|
96
|
-
- uses: actions/checkout@v4
|
|
97
|
-
- uses: PyO3/maturin-action@v1
|
|
98
|
-
with:
|
|
99
|
-
target: ${{ matrix.target }}
|
|
100
|
-
args: --release --out dist
|
|
101
|
-
manylinux: musllinux_1_2
|
|
102
|
-
- uses: actions/upload-artifact@v4
|
|
103
|
-
with:
|
|
104
|
-
name: wheels-musllinux-${{ matrix.target }}
|
|
105
|
-
path: dist
|
|
106
|
-
|
|
107
|
-
windows:
|
|
108
|
-
runs-on: windows-latest
|
|
109
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
110
|
-
needs: [test, lint]
|
|
111
|
-
steps:
|
|
112
|
-
- uses: actions/checkout@v4
|
|
113
|
-
- uses: actions/setup-python@v5
|
|
114
|
-
with:
|
|
115
|
-
architecture: x64
|
|
116
|
-
- uses: PyO3/maturin-action@v1
|
|
117
|
-
with:
|
|
118
|
-
target: x64
|
|
119
|
-
args: --release --out dist
|
|
120
|
-
- uses: actions/upload-artifact@v4
|
|
121
|
-
with:
|
|
122
|
-
name: wheels-windows-x64
|
|
123
|
-
path: dist
|
|
124
|
-
|
|
125
|
-
macos:
|
|
126
|
-
runs-on: macos-latest
|
|
127
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
128
|
-
needs: [test, lint]
|
|
129
|
-
strategy:
|
|
130
|
-
matrix:
|
|
131
|
-
target: [x86_64, aarch64]
|
|
132
|
-
steps:
|
|
133
|
-
- uses: actions/checkout@v4
|
|
134
|
-
- uses: PyO3/maturin-action@v1
|
|
135
|
-
with:
|
|
136
|
-
target: ${{ matrix.target }}
|
|
137
|
-
args: --release --out dist
|
|
138
|
-
- uses: actions/upload-artifact@v4
|
|
139
|
-
with:
|
|
140
|
-
name: wheels-macos-${{ matrix.target }}
|
|
141
|
-
path: dist
|
|
142
|
-
|
|
143
|
-
sdist:
|
|
144
|
-
runs-on: ubuntu-latest
|
|
145
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
146
|
-
needs: [test, lint]
|
|
147
|
-
steps:
|
|
148
|
-
- uses: actions/checkout@v4
|
|
149
|
-
- uses: PyO3/maturin-action@v1
|
|
150
|
-
with:
|
|
151
|
-
command: sdist
|
|
152
|
-
args: --out dist
|
|
153
|
-
- uses: actions/upload-artifact@v4
|
|
154
|
-
with:
|
|
155
|
-
name: wheels-sdist
|
|
156
|
-
path: dist
|
|
157
|
-
|
|
158
|
-
publish:
|
|
159
|
-
name: publish to PyPI
|
|
160
|
-
# Guarda além do gatilho: sem ela, um `workflow_dispatch` a partir de um
|
|
161
|
-
# branch publicaria no PyPI.
|
|
162
|
-
if: startsWith(github.ref, 'refs/tags/')
|
|
163
|
-
needs: [lint, test, linux, musllinux, windows, macos, sdist]
|
|
164
|
-
runs-on: ubuntu-latest
|
|
165
|
-
environment: pypi
|
|
166
|
-
permissions:
|
|
167
|
-
id-token: write # necessário para o Trusted Publishing (OIDC)
|
|
168
|
-
steps:
|
|
169
|
-
- uses: actions/download-artifact@v4
|
|
170
|
-
with:
|
|
171
|
-
pattern: wheels-*
|
|
172
|
-
merge-multiple: true
|
|
173
|
-
path: dist
|
|
174
|
-
- uses: actions/setup-python@v5
|
|
175
|
-
with:
|
|
176
|
-
python-version: "3.12"
|
|
177
|
-
|
|
178
|
-
# Conferência antes de qualquer coisa sair do runner: publicar um
|
|
179
|
-
# conjunto incompleto é pior que não publicar, porque o PyPI nunca deixa
|
|
180
|
-
# reusar um nome de arquivo — uma versão meio-publicada só pode ser
|
|
181
|
-
# completada, nunca substituída.
|
|
182
|
-
- name: Verify the artifact set is complete
|
|
183
|
-
run: |
|
|
184
|
-
set -euo pipefail
|
|
185
|
-
ls -la dist/
|
|
186
|
-
test -n "$(find dist -name '*.tar.gz' -print -quit)" \
|
|
187
|
-
|| { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
|
|
188
|
-
wheels=$(find dist -name '*.whl' | wc -l)
|
|
189
|
-
echo "wheels: $wheels"
|
|
190
|
-
test "$wheels" -ge 7 \
|
|
191
|
-
|| { echo "::error::só $wheels wheels; esperado >= 7 (2 linux, 2 musl, 1 win, 2 macos)"; exit 1; }
|
|
192
|
-
|
|
193
|
-
# O PyPI devolve 500 transitório com frequência suficiente para importar,
|
|
194
|
-
# e o upload é arquivo por arquivo: uma falha no meio deixa a versão
|
|
195
|
-
# publicada pela metade. `skip-existing` é o que torna a segunda
|
|
196
|
-
# tentativa segura — sem ele, ela morre nos arquivos que já subiram.
|
|
197
|
-
#
|
|
198
|
-
# São dois passos explícitos em vez de um wrapper de retry porque o
|
|
199
|
-
# trusted publishing (OIDC) exige a action oficial, que não dá para
|
|
200
|
-
# acionar por linha de comando.
|
|
201
|
-
- name: Publish to PyPI (attempt 1)
|
|
202
|
-
id: publish1
|
|
203
|
-
continue-on-error: true
|
|
204
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
205
|
-
with:
|
|
206
|
-
packages-dir: dist
|
|
207
|
-
skip-existing: true
|
|
208
|
-
|
|
209
|
-
- name: Wait before retrying
|
|
210
|
-
if: steps.publish1.outcome == 'failure'
|
|
211
|
-
run: sleep 45
|
|
212
|
-
|
|
213
|
-
- name: Publish to PyPI (attempt 2)
|
|
214
|
-
if: steps.publish1.outcome == 'failure'
|
|
215
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
216
|
-
with:
|
|
217
|
-
packages-dir: dist
|
|
218
|
-
skip-existing: true
|
|
219
|
-
|
|
220
|
-
# Prova que a release está completa, em vez de confiar num código de
|
|
221
|
-
# saída que uma publicação parcial também produz.
|
|
222
|
-
- name: Verify every file reached PyPI
|
|
223
|
-
run: |
|
|
224
|
-
set -euo pipefail
|
|
225
|
-
python -m pip install --quiet requests
|
|
226
|
-
python - <<'PY'
|
|
227
|
-
import os, sys, glob, time, requests
|
|
228
|
-
version = os.environ["GITHUB_REF_NAME"].lstrip("v")
|
|
229
|
-
# Só .whl e .tar.gz: são os únicos tipos de arquivo que o PyPI hospeda.
|
|
230
|
-
# `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
|
|
231
|
-
# distribuição em dist/ depois de publicar (atestados PEP 740, que
|
|
232
|
-
# sobem junto do arquivo, não como arquivo próprio). Um glob amplo os
|
|
233
|
-
# conta como "faltando no PyPI" e reprova uma release que deu certo.
|
|
234
|
-
local = {
|
|
235
|
-
os.path.basename(p)
|
|
236
|
-
for pattern in ("dist/*.whl", "dist/*.tar.gz")
|
|
237
|
-
for p in glob.glob(pattern)
|
|
238
|
-
}
|
|
239
|
-
url = f"https://pypi.org/pypi/rust-py-cache/{version}/json"
|
|
240
|
-
for attempt in range(6): # a API JSON do PyPI é cacheada
|
|
241
|
-
response = requests.get(url, timeout=30)
|
|
242
|
-
if response.status_code == 200:
|
|
243
|
-
remote = {f["filename"] for f in response.json()["urls"]}
|
|
244
|
-
missing = local - remote
|
|
245
|
-
if not missing:
|
|
246
|
-
print(f"OK: {len(remote)} arquivos publicados na {version}")
|
|
247
|
-
sys.exit(0)
|
|
248
|
-
else:
|
|
249
|
-
missing = local
|
|
250
|
-
print(f"tentativa {attempt + 1}: faltam {len(missing)}")
|
|
251
|
-
time.sleep(20)
|
|
252
|
-
print("::error::faltaram arquivos no PyPI:")
|
|
253
|
-
for name in sorted(missing):
|
|
254
|
-
print(f" - {name}")
|
|
255
|
-
sys.exit(1)
|
|
256
|
-
PY
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|