oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1// The life of an imported sheet after the upload: revealed, discarded,
2// or reclaimed later by whoever notices it was abandoned. Split from
3// handlers_import.go only because that file reached the 300-line cap.
4package sheets
5
6import (
7 "errors"
8 "time"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11 "amadan.net/oficina/sheets/internal/sheets/xlsx"
12)
13
14// staleImportAge is how long a sheet must have been filling before a
15// sweep will touch it.
16//
17// It is twice rastrillo's own jobTimeout (15 minutes), after which the
18// jobs registry marks a running job Failed and its context expires. So
19// an import older than this has no live job behind it under any
20// scheduling, and the doubling is slack for a machine that was asleep
21// or a clock that moved.
22//
23// An age check rather than "everything, at startup". The first version
24// of this swept unconditionally on the reasoning that a filling sheet
25// at process start belongs to a process that is gone — which is true of
26// a clean restart and false of every case where two instances overlap:
27// a rolling replacement where the old process is still draining, a
28// duplicate start, or a test that builds a second App over one
29// database. In those, an unconditional sweep deletes an import that is
30// still running, and the importer's own sheet vanishes from under them
31// mid-write.
32const staleImportAge = 30 * time.Minute
33
34// sweepStaleImports discards sheets left mid-import by a process that is
35// no longer coming back.
36//
37// Hidden was already the safe state, so this is reclamation rather than
38// correctness: without it a deploy mid-import leaves the row, its
39// grants and its sealed cells behind for good. That is also why erring
40// towards leaving one alone is the right way to be wrong.
41func (a *App) sweepStaleImports() {
42 if a.db == nil {
43 return
44 }
45 cutoff := time.Now().UTC().Add(-staleImportAge)
46 var stale []store.Sheet
47 if err := a.db.Where(
48 "filling_since IS NOT NULL AND filling_since < ? AND deleted_at IS NULL",
49 cutoff).Find(&stale).Error; err != nil {
50 return
51 }
52 for _, s := range stale {
53 a.discardSheet(s.ID)
54 }
55}
56
57// markFilled reveals a sheet that finished filling, unless it has been
58// discarded in the meantime.
59//
60// The deleted_at condition is what makes this safe against the one race
61// the age threshold narrows but cannot close: a job's goroutine cannot
62// be killed (rastrillo's jobs package says so itself — the timeout
63// stops the job counting, not running), so an import pathologically
64// slower than staleImportAge can still be writing when another
65// instance's sweep discards its sheet. Without the condition this would
66// then un-hide a deleted row and send the importer to a sheet no read
67// path will serve. With it, the sweep wins, the import reports failure,
68// and the writes the goroutine goes on making land in a row nothing can
69// reach.
70//
71// Its error is NOT dropped, unlike touchSheet's: a sheet whose flag
72// will not clear is invisible, so swallowing this would report a
73// successful import and then 404 the person on the sheet it made.
74func (a *App) markFilled(id uint64) error {
75 if a.db == nil {
76 return errNoDB
77 }
78 res := a.db.Model(&store.Sheet{}).Where("id = ? AND deleted_at IS NULL", id).
79 Update("filling_since", nil)
80 if res.Error != nil {
81 return res.Error
82 }
83 if res.RowsAffected == 0 {
84 return errors.New("sheets: that sheet was discarded before its import finished")
85 }
86 return nil
87}
88
89// importFailure turns an import error into text safe to show and safe
90// to log.
91//
92// Safe to LOG is the binding half and the easy one to miss: rastrillo's
93// job runner slogs the error this function's result becomes
94// (jobs.run's "jobs: failed" line), and `carlos logs` is platform-held
95// storage this app cannot grep or redact. So this never wraps the
96// original — it replaces it. A coordinate and a code are what an error
97// here is allowed to carry (AGENTS.md), and xlsx.CellError is what
98// makes the coordinate available without reading it back out of a
99// string.
100func importFailure(err error) error {
101 if errors.Is(err, store.ErrTabTooLarge) {
102 return errors.New("that workbook holds more cells than one sheet can take")
103 }
104 var cell *xlsx.CellError
105 if errors.As(err, &cell) {
106 return errors.New("the import stopped at cell " + cell.Ref)
107 }
108 return errors.New("that file could not be read as a spreadsheet")
109}
110
111// discardSheet throws away a sheet whose import did not finish: it
112// soft-deletes the row and removes the grants that were minted with it.
113//
114// The grants matter on their own. Admits counts a member's grants to
115// decide whether an address may sign in at all, so an owner grant left
116// behind by a failed import keeps admitting a guest whose every real
117// grant has since been revoked. Deleting them here keeps that from
118// being something a failed upload can create. (The wider gap — Admits
119// not excluding grants to already-deleted sheets — is not this
120// function's to close, and is reported separately.)
121//
122// Soft-deleting the sheet is belt and braces rather than the mechanism:
123// the sheet is still marked FillingSince, so it is already invisible on
124// every read path, and an error here therefore cannot expose anything.
125// That is why this returns nothing — there is no failure mode left
126// worth reporting over the import failure that caused it.
127func (a *App) discardSheet(id uint64) {
128 if a.db == nil {
129 return
130 }
131 now := time.Now().UTC()
132 a.db.Model(&store.Sheet{}).Where("id = ?", id).Update("deleted_at", now)
133 a.db.Where("sheet_id = ?", id).Delete(&Grant{})
134}
135