Commit 55196e8
authored
feat(fleet): accept a paired client's own earnings history (#256)
* feat(fleet): accept a paired client's own earnings history
A client that has been reading a provider account on its own -- CashPilot
Desktop before it was paired -- had no way to hand that history to the
server, so the fleet view began on the day of pairing and every earlier
day was simply lost from the total.
POST /api/workers/earnings-import takes those readings and stores them
under the importing client's own source, which the source-aware schema
added in the previous change makes possible. Separate series matter
here: earnings are clamped deltas between consecutive readings of the
same balance, so interleaving two samplers of one account makes every
apparent drop clamp to zero and understates the total. Each series is
differenced on its own and the results are summed.
Two properties carry the security of it:
* The source comes from the AUTHENTICATED worker, never the request
body, so no client can write into another's history or into the
server's own.
* Only a fully enrolled worker may import. A caller still presenting the
shared enrollment key gets 403 with instructions to heartbeat first --
every worker holds that key, and this writes durable money data.
Re-sending a day updates it rather than appending, so a retried or
repeated import is safe by construction.
Refs: CashPilot-Desktop-xjr
* fix(fleet): bound the import body and validate the reading date
Two ways the new endpoint could be handed input it would store without
complaint.
The date was free text. Both delta readers ORDER BY it, so a reading
dated 2026-1-2 or 01/02/2026 sorts into the wrong place in its own
series and the readings either side then difference against the wrong
neighbour. It fails silently, only for the client that sent it, and only
in the earned figure -- never in the balance the dashboard shows. It is
now required to be YYYY-MM-DD and a real calendar day, so 2026-02-30 is
refused rather than stored.
The readings list was unbounded. One authenticated client could hand the
server an arbitrarily large body to parse and then write row by row; a
single compromised worker is enough. Capped at 2000, comfortably above
an honest import (the server keeps 400 days and a client chunks at
1000).
Validation runs before authentication, so a malformed body cannot be
used to probe which client ids exist.
Refs: CashPilot-Desktop-xjr
* fix(compose): pin the examples to the 1.15 series
v1.15.0 released and the example compose files still pinned 1.14, so
anyone following the quickstart deployed a series behind. The pin test
caught it -- it exists because a stale pin is what gave issue #188 a
version with a first-run bug that had been fixed for months.
Unrelated to this branch's change, but it fails CI on every branch until
it is fixed.
* fix(fleet): a NaN balance was accepted, and rejecting one was a 500
Found by re-reading the endpoint rather than from a report.
JSON has no NaN or Infinity. Python's parser accepts them anyway, so
{"balance": NaN} was stored verbatim. One such reading poisons every
delta taken from that series -- NaN - x is NaN, and every comparison
against it is False, so the clamp silently misbehaves -- the account
total becomes NaN, and serialising that back out emits a bare NaN that
JSON.parse rejects. A single bad reading from one client breaks the
dashboard for everyone. Both float fields now refuse non-finite values.
That exposed a second problem underneath: FastAPI's 422 body echoes the
offending input, so the REJECTION could not be serialised either and the
client got a 500 for what is squarely a bad request. A
RequestValidationError handler now renders non-finite floats as their
names -- keeping the message diagnostic rather than dropping the field --
and fixes the whole class instead of the one endpoint that takes a float
today.
My first version of that handler broke every OTHER validation error: a
custom validator's error carries the raised ValueError OBJECT in ctx,
which is not serialisable, and skipping jsonable_encoder turned each one
into a 500. Caught by the date tests, and now pinned by its own
regression test.
Also: the skipped list is deduplicated. A client pushing 400 days of a
platform this server does not know got the same name back 400 times --
a response that grows with the request, echoing client-supplied strings,
and saying nothing the set does not.
A negative control showed the sanitiser's bool guard was dead (bool
subclasses int, not float), so it is gone along with the comment that
justified it incorrectly.
Refs: CashPilot-Desktop-xjr
* perf(fleet): import in one transaction, and roll back when it fails
A thousand-reading import committed a thousand times. Every commit is an
fsync that takes SQLite's write lock, so one import serialised a thousand
disk syncs against this server's own collector and request latency
tracked sync cost rather than row count. upsert_earnings_many does the
same upsert with executemany and one commit.
Only half the reported cause was real, and the distinction matters for
anyone reading this later: _get_db hands out a borrowed handle on a
SHARED per-loop connection whose close() is a documented no-op, so the
loop was never opening and closing a thousand connections. It was
committing a thousand times.
Batching then introduced a bug of its own, which the tests caught: the
connection is shared and outlives the request, so a failed batch left an
abandoned transaction holding the write lock and the NEXT write blocked
for twelve seconds before timing out. It now rolls back.
Two more from the same review:
* docs/fleet.md gains the Authorization header and every status the
endpoint can answer with. A reader integrating against that page alone
could not previously construct a valid request.
* Two assertions were tuple expressions -- `mock.assert_not_awaited(),
"why"` builds a tuple and discards the message, so a red build showed
the mock's generic text instead of the reason. Fixed here and in
test_optional_runtime.py.
The review said ruff's B018 catches that pattern when bugbear is enabled.
Bugbear IS enabled here and B018 is not ignored, and ruff 0.15.14 passes
it clean -- checked against a minimal probe rather than assumed. So
nothing in CI would have caught a recurrence, and there is now an
AST-based test that does. Structural, not a string search, because a
string search would match the pattern inside its own docstring.
One negative control PASSED, which meant the test was wrong: the
transactionality test raised while BUILDING the rows, before any SQL ran,
so it proved only that the row build validates first and it passed
against a writer that committed after every row. It now fails inside the
statement, and both it and the wedged-connection test fail under their
controls.
Reported by CodeRabbit on PR #256.1 parent 3c89898 commit 55196e8
5 files changed
Lines changed: 1046 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| 16 | + | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
| |||
921 | 923 | | |
922 | 924 | | |
923 | 925 | | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
924 | 998 | | |
925 | 999 | | |
926 | 1000 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| 27 | + | |
| 28 | + | |
26 | 29 | | |
27 | 30 | | |
28 | | - | |
| 31 | + | |
29 | 32 | | |
30 | 33 | | |
31 | 34 | | |
| |||
984 | 987 | | |
985 | 988 | | |
986 | 989 | | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
987 | 1035 | | |
988 | 1036 | | |
989 | 1037 | | |
| |||
3713 | 3761 | | |
3714 | 3762 | | |
3715 | 3763 | | |
| 3764 | + | |
| 3765 | + | |
| 3766 | + | |
| 3767 | + | |
| 3768 | + | |
| 3769 | + | |
| 3770 | + | |
| 3771 | + | |
| 3772 | + | |
| 3773 | + | |
| 3774 | + | |
| 3775 | + | |
| 3776 | + | |
| 3777 | + | |
| 3778 | + | |
| 3779 | + | |
| 3780 | + | |
| 3781 | + | |
| 3782 | + | |
| 3783 | + | |
| 3784 | + | |
| 3785 | + | |
| 3786 | + | |
| 3787 | + | |
| 3788 | + | |
| 3789 | + | |
| 3790 | + | |
| 3791 | + | |
| 3792 | + | |
| 3793 | + | |
| 3794 | + | |
| 3795 | + | |
| 3796 | + | |
| 3797 | + | |
| 3798 | + | |
| 3799 | + | |
| 3800 | + | |
| 3801 | + | |
| 3802 | + | |
| 3803 | + | |
| 3804 | + | |
| 3805 | + | |
| 3806 | + | |
| 3807 | + | |
| 3808 | + | |
| 3809 | + | |
| 3810 | + | |
| 3811 | + | |
| 3812 | + | |
| 3813 | + | |
| 3814 | + | |
| 3815 | + | |
| 3816 | + | |
| 3817 | + | |
| 3818 | + | |
| 3819 | + | |
| 3820 | + | |
| 3821 | + | |
| 3822 | + | |
| 3823 | + | |
| 3824 | + | |
| 3825 | + | |
| 3826 | + | |
| 3827 | + | |
| 3828 | + | |
| 3829 | + | |
| 3830 | + | |
| 3831 | + | |
| 3832 | + | |
| 3833 | + | |
| 3834 | + | |
| 3835 | + | |
| 3836 | + | |
| 3837 | + | |
| 3838 | + | |
| 3839 | + | |
| 3840 | + | |
| 3841 | + | |
| 3842 | + | |
| 3843 | + | |
| 3844 | + | |
| 3845 | + | |
| 3846 | + | |
| 3847 | + | |
| 3848 | + | |
| 3849 | + | |
| 3850 | + | |
| 3851 | + | |
| 3852 | + | |
| 3853 | + | |
| 3854 | + | |
| 3855 | + | |
| 3856 | + | |
| 3857 | + | |
| 3858 | + | |
| 3859 | + | |
| 3860 | + | |
| 3861 | + | |
| 3862 | + | |
| 3863 | + | |
| 3864 | + | |
| 3865 | + | |
| 3866 | + | |
| 3867 | + | |
| 3868 | + | |
| 3869 | + | |
| 3870 | + | |
| 3871 | + | |
| 3872 | + | |
| 3873 | + | |
| 3874 | + | |
| 3875 | + | |
| 3876 | + | |
| 3877 | + | |
| 3878 | + | |
| 3879 | + | |
| 3880 | + | |
| 3881 | + | |
| 3882 | + | |
| 3883 | + | |
| 3884 | + | |
| 3885 | + | |
| 3886 | + | |
| 3887 | + | |
| 3888 | + | |
| 3889 | + | |
| 3890 | + | |
| 3891 | + | |
| 3892 | + | |
| 3893 | + | |
| 3894 | + | |
| 3895 | + | |
| 3896 | + | |
3716 | 3897 | | |
3717 | 3898 | | |
3718 | 3899 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
37 | 100 | | |
38 | 101 | | |
39 | 102 | | |
| |||
0 commit comments