pos gap fix
This commit is contained in:
249
middleware/posauthadoption.go
Normal file
249
middleware/posauthadoption.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
How much of the till fleet is carrying a session token.
|
||||
|
||||
── Why this exists ─────────────────────────────────────────────────────────
|
||||
|
||||
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
|
||||
switching it on is a number nobody has: how many terminals still call the POS
|
||||
routes with no token. Flipping the flag blind is the one action on this surface
|
||||
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
|
||||
bill is not a reversible inconvenience.
|
||||
|
||||
So this counts, and names the outlets that are still untokened, so the flag gets
|
||||
flipped on evidence rather than on hope.
|
||||
|
||||
── What it deliberately is not ─────────────────────────────────────────────
|
||||
|
||||
Not persisted. It lives in memory and resets on restart, which is honest about
|
||||
what it measures: adoption since this process started, not all time. A restart
|
||||
mid-observation means starting the week again, and that is a smaller cost than a
|
||||
migration and a table for a number that stops mattering the day the flag is on.
|
||||
|
||||
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
|
||||
here can change whether a request is served.
|
||||
|
||||
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
|
||||
name ten thousand outlets and grow this without bound. Past the cap new outlets
|
||||
are counted in the totals and not listed individually, which keeps the answer
|
||||
useful without making it a way to spend the server's memory.
|
||||
*/
|
||||
|
||||
// posAdoptionCap is how many distinct untokened outlets are named individually.
|
||||
// The real fleet is dozens; anything beyond this is noise or somebody probing.
|
||||
const posAdoptionCap = 200
|
||||
|
||||
type posOutletSeen struct {
|
||||
Locationid int
|
||||
Requests int64
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
var posAdoption = struct {
|
||||
sync.Mutex
|
||||
since time.Time
|
||||
tokened int64
|
||||
untokened int64
|
||||
// Untokened requests by the outlet they named, and by the route they hit.
|
||||
outlets map[int]*posOutletSeen
|
||||
paths map[string]int64
|
||||
// True once the cap was reached, so the report can say it is partial
|
||||
// rather than quietly under-reporting.
|
||||
truncated bool
|
||||
}{
|
||||
since: time.Now(),
|
||||
outlets: map[int]*posOutletSeen{},
|
||||
paths: map[string]int64{},
|
||||
}
|
||||
|
||||
// recordPosToken notes one request that arrived with a usable token.
|
||||
func recordPosToken() {
|
||||
posAdoption.Lock()
|
||||
posAdoption.tokened++
|
||||
posAdoption.Unlock()
|
||||
}
|
||||
|
||||
// recordPosUntokened notes one request that arrived with none, and where it
|
||||
// claimed to be. `locationid` is 0 when the route named no outlet.
|
||||
func recordPosUntokened(locationid int, path string) {
|
||||
now := time.Now()
|
||||
|
||||
posAdoption.Lock()
|
||||
defer posAdoption.Unlock()
|
||||
|
||||
posAdoption.untokened++
|
||||
posAdoption.paths[path]++
|
||||
|
||||
if locationid <= 0 {
|
||||
return
|
||||
}
|
||||
if seen, ok := posAdoption.outlets[locationid]; ok {
|
||||
seen.Requests++
|
||||
seen.LastSeen = now
|
||||
return
|
||||
}
|
||||
if len(posAdoption.outlets) >= posAdoptionCap {
|
||||
posAdoption.truncated = true
|
||||
return
|
||||
}
|
||||
posAdoption.outlets[locationid] = &posOutletSeen{
|
||||
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
|
||||
}
|
||||
}
|
||||
|
||||
// PosAdoptionOutlet is one outlet still calling without a token.
|
||||
type PosAdoptionOutlet struct {
|
||||
Locationid int `json:"locationid"`
|
||||
Requests int64 `json:"requests"`
|
||||
FirstSeen string `json:"firstseen"`
|
||||
LastSeen string `json:"lastseen"`
|
||||
}
|
||||
|
||||
// PosAdoptionPath is one route, and how often it was reached untokened.
|
||||
type PosAdoptionPath struct {
|
||||
Path string `json:"path"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
|
||||
type PosAdoption struct {
|
||||
// Whether an untokened request is currently refused.
|
||||
Enforced bool `json:"enforced"`
|
||||
// When counting started — process start, not all time.
|
||||
Since string `json:"since"`
|
||||
// Requests seen on the POS surface since then.
|
||||
Tokened int64 `json:"tokened"`
|
||||
Untokened int64 `json:"untokened"`
|
||||
// 0–100. 100 means every request in this window carried a token, which is
|
||||
// the condition for flipping the flag.
|
||||
AdoptedPercent float64 `json:"adoptedpercent"`
|
||||
// The outlets still calling without one, busiest first. These are the tills
|
||||
// that would stop working the moment enforcement is switched on.
|
||||
Outlets []PosAdoptionOutlet `json:"outlets"`
|
||||
// Which routes they are reaching, busiest first.
|
||||
Paths []PosAdoptionPath `json:"paths"`
|
||||
// True when more outlets were seen than are listed — see posAdoptionCap.
|
||||
Truncated bool `json:"truncated"`
|
||||
// Plain-language reading of the above, for whoever has to make the call.
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
// PosAdoptionReport is the snapshot, safe to call at any time.
|
||||
func PosAdoptionReport() PosAdoption {
|
||||
posAdoption.Lock()
|
||||
defer posAdoption.Unlock()
|
||||
|
||||
report := PosAdoption{
|
||||
Enforced: posAuthRequired(),
|
||||
Since: posAdoption.since.Format(time.RFC3339),
|
||||
Tokened: posAdoption.tokened,
|
||||
Untokened: posAdoption.untokened,
|
||||
Truncated: posAdoption.truncated,
|
||||
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
|
||||
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
|
||||
}
|
||||
|
||||
total := posAdoption.tokened + posAdoption.untokened
|
||||
if total > 0 {
|
||||
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
|
||||
}
|
||||
|
||||
for _, seen := range posAdoption.outlets {
|
||||
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
|
||||
Locationid: seen.Locationid,
|
||||
Requests: seen.Requests,
|
||||
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
|
||||
LastSeen: seen.LastSeen.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
// Busiest first: the outlet ringing the most bills is the one that hurts
|
||||
// most if enforcement switches on before it has adopted.
|
||||
sort.Slice(report.Outlets, func(i, j int) bool {
|
||||
return report.Outlets[i].Requests > report.Outlets[j].Requests
|
||||
})
|
||||
|
||||
for path, count := range posAdoption.paths {
|
||||
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
|
||||
}
|
||||
sort.Slice(report.Paths, func(i, j int) bool {
|
||||
return report.Paths[i].Requests > report.Paths[j].Requests
|
||||
})
|
||||
|
||||
report.Verdict = posAdoptionVerdict(report)
|
||||
return report
|
||||
}
|
||||
|
||||
// posAdoptionVerdict says what the numbers mean, because the number on its own
|
||||
// invites the wrong reading in both directions: a clean window that is only an
|
||||
// hour long proves nothing, and one stubborn outlet is not a reason to leave
|
||||
// the whole surface open.
|
||||
func posAdoptionVerdict(r PosAdoption) string {
|
||||
switch {
|
||||
case r.Enforced:
|
||||
return "Enforcement is already on: an untokened request is refused."
|
||||
case r.Tokened+r.Untokened == 0:
|
||||
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
|
||||
case r.Untokened == 0:
|
||||
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
|
||||
case len(r.Outlets) == 0:
|
||||
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
|
||||
default:
|
||||
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
|
||||
}
|
||||
}
|
||||
|
||||
// posAdoptionLogEvery is how often the summary reaches the log.
|
||||
//
|
||||
// Long, because this is a slow-moving fact — a fleet adopts over days, not
|
||||
// minutes — and a log line nobody needs every minute is a log line people learn
|
||||
// to scroll past.
|
||||
const posAdoptionLogEvery = 30 * time.Minute
|
||||
|
||||
// LogPosAdoption prints the summary on a timer until ctx is done.
|
||||
//
|
||||
// In the log as well as on the endpoint because the two get used by different
|
||||
// people at different moments: somebody already reading Dokploy's log because a
|
||||
// till is misbehaving should not have to know an endpoint exists.
|
||||
//
|
||||
// Outlet ids only, never names or counts of takings — a log is the one place
|
||||
// this data ends up somewhere nobody chose to put it.
|
||||
func LogPosAdoption(ctx context.Context) {
|
||||
ticker := time.NewTicker(posAdoptionLogEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
report := PosAdoptionReport()
|
||||
if report.Tokened+report.Untokened == 0 {
|
||||
continue // nothing happened; saying so every half hour is noise
|
||||
}
|
||||
if report.Untokened == 0 {
|
||||
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
|
||||
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
|
||||
continue
|
||||
}
|
||||
outlets := make([]string, 0, len(report.Outlets))
|
||||
for _, o := range report.Outlets {
|
||||
outlets = append(outlets, strconv.Itoa(o.Locationid))
|
||||
}
|
||||
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
|
||||
report.AdoptedPercent, report.Tokened+report.Untokened,
|
||||
report.Untokened, strings.Join(outlets, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user