| 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. |
| 4 | package sheets |
| 5 | |
| 6 | import ( |
| 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. |
| 32 | const 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. |
| 41 | func (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. |
| 74 | func (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. |
| 100 | func 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. |
| 127 | func (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 | |