Skip to content

Commit f80bca7

Browse files
frahlgclaude
andauthored
fix(appproto): a plan on the wire is read as one plan (#1841)
* fix(appproto): a plan on the wire is read as one plan writePlan read the plan, its rev and the chargers' windows in three calls. cmd/ftw's Rev reads the planner again, and PlanChargers takes its own snapshot, so a replan between them mixed two plans in one message: the new plan's windows under the old plan's rev (run 40115146270, TestAPlugInReachesAStreamingPhoneAsAPlan: "pushed rev 1 after 1"), or the old slots under the new rev. The box now reads the plan again when it moved during the read, at most twice more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(appproto): name the shadow swap in readPlan; test reports empty slots Review: the planner also swaps in a copy of the plan that carries its shadow result, so a read can move twice. The test now reports a plan without slots instead of panicking on it, and the changeset no longer says always. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 03a24a6 commit f80bca7

3 files changed

Lines changed: 88 additions & 8 deletions

File tree

‎.changeset/plan-push-one-plan.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
A plan sent to the app no longer mixes a new plan with the one before it. A replan that finished while the box read the plan could send the new plan's charging windows under the old plan's number, or the old slots under the new number.

‎go/internal/appproto/handler.go‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,20 +1316,38 @@ func (h *Handler) onPlanGet(id *uint32) error {
13161316

13171317
func (h *Handler) sendPlan(id *uint32) error { return h.writePlan(id, false) }
13181318

1319+
// readPlan reads the plan, its rev and each charger's share as one plan. The
1320+
// three are separate reads, and a replan that lands between them would mix
1321+
// two plans in one message, under either one's rev. A plan that moved while
1322+
// it was read is read again. A replan moves it once, and once more when the
1323+
// planner swaps in the copy that carries its shadow result, so the third
1324+
// read is sent as it is.
1325+
func (h *Handler) readPlan() Plan {
1326+
for attempt := 1; ; attempt++ {
1327+
plan := h.cfg.Plans.Latest()
1328+
body := planFrom(
1329+
plan,
1330+
h.cfg.Plans.Rev(),
1331+
h.cfg.Plans.CeilingW(),
1332+
h.cfg.Clock.UptimeMs(),
1333+
h.cfg.Clock.Now(),
1334+
)
1335+
if h.cfg.PlanChargers != nil {
1336+
body.Chargers = h.cfg.PlanChargers.PlanChargers(h.cfg.Clock.Now())
1337+
}
1338+
if attempt == 3 || h.cfg.Plans.Latest() == plan {
1339+
return body
1340+
}
1341+
}
1342+
}
1343+
13191344
// writePlan reads the plan and sends it. With skipSame it sends nothing when
13201345
// this session already has this plan.
13211346
func (h *Handler) writePlan(id *uint32, skipSame bool) error {
13221347
h.planMu.Lock()
13231348
defer h.planMu.Unlock()
1324-
body := planFrom(
1325-
h.cfg.Plans.Latest(),
1326-
h.cfg.Plans.Rev(),
1327-
h.cfg.Plans.CeilingW(),
1328-
h.cfg.Clock.UptimeMs(),
1329-
h.cfg.Clock.Now(),
1330-
)
1349+
body := h.readPlan()
13311350
if h.cfg.PlanChargers != nil {
1332-
body.Chargers = h.cfg.PlanChargers.PlanChargers(h.cfg.Clock.Now())
13331351
if h.isAgent() {
13341352
// An agent knows chargers and cars by the ids loadpoints.get
13351353
// and cars.get gave it, never by the box's names. A copy: the

‎go/internal/appproto/plan_feed_test.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,60 @@ func TestThePlanWithChargersVector(t *testing.T) {
337337
t.Fatalf("plan bytes moved:\n got %s\nwant %s", got, planChargersVector)
338338
}
339339
}
340+
341+
// replanningChargers publishes a new plan on the box just before its first
342+
// read, as a replan can land while the box reads a plan for the wire.
343+
type replanningChargers struct {
344+
box *fakeBox
345+
reads int
346+
}
347+
348+
func (c *replanningChargers) PlanChargers(time.Time) []PlanCharger {
349+
c.reads++
350+
if c.reads == 1 {
351+
c.box.replan(2, c.box.plan.GeneratedAtMs+60_000)
352+
}
353+
if c.box.planRev == 2 {
354+
return []PlanCharger{{ID: "easee", PluggedIn: true, Windows: []PlanChargerWindow{{StartMs: 1, EndMs: 2, Wh: 7400}}, TotalWh: 7400}}
355+
}
356+
return []PlanCharger{{ID: "easee", Windows: []PlanChargerWindow{}}}
357+
}
358+
359+
// A plan on the wire is one plan. Its rev, slots and chargers once came from
360+
// separate reads, and a replan between them sent the new plan's windows
361+
// under the old plan's rev (cmd/ftw TestAPlugInReachesAStreamingPhoneAsAPlan,
362+
// run 40115146270).
363+
func TestAReplanDuringTheReadSendsOnePlan(t *testing.T) {
364+
clock := &fakeClock{uptimeMs: 60_000, now: time.UnixMilli(1_760_000_000_000)}
365+
box := &fakeBox{identity: Identity{ID: "ftw-0001", Build: "test", TZ: "Europe/Stockholm"}}
366+
box.replan(1, clock.now.UnixMilli())
367+
chargers := &replanningChargers{box: box}
368+
rec := &recorder{}
369+
h, err := New(Config{
370+
Clock: clock, Site: box, Info: box, Modes: box, Plans: box,
371+
PlanChargers: chargers, Codec: testCodec{}, Sender: rec, Caller: ownerCaller(),
372+
Grants: newGrants(), Commands: NewCmdLog(),
373+
})
374+
if err != nil {
375+
t.Fatalf("New: %v", err)
376+
}
377+
t.Cleanup(h.Close)
378+
379+
if err := h.sendPlan(nil); err != nil {
380+
t.Fatal(err)
381+
}
382+
got := plans(rec)
383+
if len(got) != 1 {
384+
t.Fatalf("want one plan, got %d (%s)", len(got), rec.types())
385+
}
386+
p := body[Plan](t, got[0])
387+
if len(p.Slots) == 0 {
388+
t.Fatalf("plan rev %d has no slots", p.Rev)
389+
}
390+
if p.Rev != 2 || p.Slots[0].StartMs != box.plan.GeneratedAtMs {
391+
t.Fatalf("plan rev %d with slots from %d, want rev 2 from %d", p.Rev, p.Slots[0].StartMs, box.plan.GeneratedAtMs)
392+
}
393+
if len(p.Chargers) != 1 || len(p.Chargers[0].Windows) != 1 {
394+
t.Fatalf("chargers %+v, want the new plan's window", p.Chargers)
395+
}
396+
}

0 commit comments

Comments
 (0)