API tokens: last_used_at is written at most once a minute

c1a34d8 · Gabriel Morell · 2026-10-20 20:54

7 files +68 -8
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/accounts.ex
+25 −8
@@ -48,20 +48,21 @@ defmodule GitGud.Accounts do
48 48 :ok
49 49 end
50 50
51 + # `last_used_at` is to the minute: a client polling with one token
52 + # (the plasmoid, `gg ci watch`, the app) would otherwise queue every
53 + # request on that row's lock, and one slow write stalls them all.
54 + @pat_touch_every 60
55 +
51 56 @doc """
52 − Verify a presented API token. On a match, bumps `last_used_at` and
53 − returns `{:ok, %User{}, scopes}`; otherwise `:error`.
57 + Verify a presented API token. On a match, bumps `last_used_at` (at
58 + most once a minute) and returns `{:ok, %User{}, scopes}`; otherwise
59 + `:error`.
54 60 """
55 61 def verify_personal_access_token(@pat_prefix <> _ = plaintext) do
56 62 case Repo.get_by(PersonalAccessToken, token_hash: pat_hash(plaintext)) do
57 63 %PersonalAccessToken{} = token ->
58 64 token = Repo.preload(token, :user)
59 −
60 − Repo.update_all(
61 − from(t in PersonalAccessToken, where: t.id == ^token.id),
62 − set: [last_used_at: DateTime.utc_now(:second)]
63 − )
64 −
65 + touch_personal_access_token(token)
65 66 {:ok, token.user, token.scopes}
66 67
67 68 nil ->
@@ -71,6 +72,22 @@ defmodule GitGud.Accounts do
71 72
72 73 def verify_personal_access_token(_), do: :error
73 74
75 + # The `where` makes racing requests skip the row instead of waiting on
76 + # its lock: only one of them matches, the rest see a fresh value.
77 + defp touch_personal_access_token(%PersonalAccessToken{id: id, last_used_at: last}) do
78 + now = DateTime.utc_now(:second)
79 + since = DateTime.add(now, -@pat_touch_every, :second)
80 +
81 + if is_nil(last) or DateTime.compare(last, since) == :lt do
82 + from(t in PersonalAccessToken,
83 + where: t.id == ^id and (is_nil(t.last_used_at) or t.last_used_at < ^since)
84 + )
85 + |> Repo.update_all(set: [last_used_at: now])
86 + end
87 +
88 + :ok
89 + end
90 +
74 91 defp pat_hash(plaintext), do: :crypto.hash(:sha256, plaintext)
75 92
76 93 ## Database getters
added test/git_gud/accounts/personal_access_token_touch_test.exs
+43 −0
@@ -0,0 +1,43 @@
1 +defmodule GitGud.Accounts.PersonalAccessTokenTouchTest do
2 + @moduledoc """
3 + `last_used_at` is written at most once a minute, so a client polling
4 + with one token doesn't queue every request on that row's lock.
5 + """
6 + use GitGud.DataCase, async: true
7 +
8 + import GitGud.AccountsFixtures
9 +
10 + alias GitGud.Accounts
11 + alias GitGud.Accounts.PersonalAccessToken
12 +
13 + setup do
14 + user = user_fixture()
15 + {:ok, token, plaintext} = Accounts.create_personal_access_token(user, "poller", ["ci:read"])
16 + {:ok, plaintext: plaintext, token: token}
17 + end
18 +
19 + defp last_used(token), do: Repo.get!(PersonalAccessToken, token.id).last_used_at
20 +
21 + defp set_last_used(token, at),
22 + do: token |> Ecto.Changeset.change(last_used_at: at) |> Repo.update!()
23 +
24 + test "the first use is recorded", %{plaintext: plaintext, token: token} do
25 + assert {:ok, _user, ["ci:read"]} = Accounts.verify_personal_access_token(plaintext)
26 + assert DateTime.diff(DateTime.utc_now(), last_used(token)) < 5
27 + end
28 +
29 + test "a use within the minute writes nothing", %{plaintext: plaintext, token: token} do
30 + recent = DateTime.add(DateTime.utc_now(:second), -10, :second)
31 + set_last_used(token, recent)
32 +
33 + assert {:ok, _user, _scopes} = Accounts.verify_personal_access_token(plaintext)
34 + assert last_used(token) == recent
35 + end
36 +
37 + test "a use after the minute moves it on", %{plaintext: plaintext, token: token} do
38 + set_last_used(token, DateTime.add(DateTime.utc_now(:second), -120, :second))
39 +
40 + assert {:ok, _user, _scopes} = Accounts.verify_personal_access_token(plaintext)
41 + assert DateTime.diff(DateTime.utc_now(), last_used(token)) < 5
42 + end
43 +end

Parents: 845c94b