Skip to content

Race: concurrent POSTs to /api/vehicles/{id}/plan/strategy lose updates #29922

@mkshb

Description

@mkshb

Describe the bug

Concurrent POSTs to /api/vehicles/{id}/plan/strategy are all accepted (HTTP 200) but committed in non-deterministic order — the earlier request can become the final persisted state.

Reproduction: a Python script fires two parallel POSTs with distinct precondition values and reads back effectivePlanStrategy.precondition from /api/state. 10 iterations, two runs against the same evcc instance:

Run 1: 2nd-write-wins 9/10,  1st-write-wins 1/10
Run 2: 2nd-write-wins 7/10,  1st-write-wins 3/10

20/20 POSTs return HTTP 200. In 1-3 of 10 iterations the earlier request's value persists instead of the later one.

Any client that emits parallel writes to this endpoint can observe lost updates.

Steps to reproduce

#!/usr/bin/env python3
import json
import os
import sys
import time
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from http.cookiejar import CookieJar
from collections import Counter
EVCC_HOST     = os.environ.get("EVCC_HOST",    "https://evcc:port")
VEHICLE_NAME  = os.environ.get("VEHICLE_NAME", "db:18")
EVCC_PASSWORD = os.environ.get("EVCC_PASSWORD", "")
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar()))
def http(method, url, payload=None):
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        url, data=data, method=method,
        headers={"Content-Type": "application/json"} if data else {},
    )
    try:
        with opener.open(req, timeout=10) as r:
            return r.status, r.read().decode(errors="replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode(errors="replace")
    except Exception as e:
        return None, f"ERR: {type(e).__name__}: {e}"
def get_state():
    s, b = http("GET", f"{EVCC_HOST}/api/state")
    if s != 200:
        print(f"  /api/state -> HTTP {s}: {b[:200]}")
        return None
    try:
        d = json.loads(b)
    except Exception as e:
        print(f"  JSON parse error: {e}")
        return None
    return d.get("result", d)
def precondition_from(state):
    if not state:
        return None
    for lp in state.get("loadpoints", []):
        if lp.get("vehicleName") == VEHICLE_NAME:
            return lp.get("effectivePlanStrategy", {}).get("precondition")
    lps = state.get("loadpoints", [])
    return lps[0].get("effectivePlanStrategy", {}).get("precondition") if lps else None
def login():
    if not EVCC_PASSWORD:
        return True
    s, b = http("POST", f"{EVCC_HOST}/api/auth/login", {"password": EVCC_PASSWORD})
    print(f"Login -> HTTP {s} ({b[:120]})")
    return s == 200
def main():
    print(f"EVCC_HOST     = {EVCC_HOST}")
    print(f"VEHICLE_NAME  = {VEHICLE_NAME}")
    print(f"PASSWORD set  = {bool(EVCC_PASSWORD)}")
    print()
    print("=== Sanity ===")
    state = get_state()
    if not state:
        sys.exit(1)
    print(f"Top-level keys ({len(state)}): {sorted(state.keys())[:10]} ...")
    print(f"Vehicle '{VEHICLE_NAME}' present: {VEHICLE_NAME in state.get('vehicles', {})}")
    lps = state.get("loadpoints", [])
    print(f"Loadpoint count: {len(lps)}")
    for i, lp in enumerate(lps):
        print(f"  LP{i+1}: vehicleName={lp.get('vehicleName')!r}, "
              f"effectivePlanStrategy={lp.get('effectivePlanStrategy')}")
    print()
    if EVCC_PASSWORD:
        print("=== Login ===")
        if not login():
            print("Login failed. Abort.")
            sys.exit(1)
        print()
    print("=== C: Single write probe ===")
    url = f"{EVCC_HOST}/api/vehicles/{VEHICLE_NAME}/plan/strategy"
    print(f"URL: {url}")
    s, b = http("POST", url, {"continuous": False, "precondition": 900})
    print(f"POST -> HTTP {s}, body[:200]={b[:200]!r}")
    if s not in (200, 201, 204):
        print("Write probe failed. Check auth / endpoint. Abort.")
        sys.exit(1)
    print()
    print("=== Race test (10 iterations) ===")
    results = []
    with ThreadPoolExecutor(max_workers=2) as ex:
        for i in range(1, 11):
            http("POST", url, {"continuous": False, "precondition": 900})
            time.sleep(0.1)
            f1 = ex.submit(http, "POST", url, {"continuous": False, "precondition": 1800})
            f2 = ex.submit(http, "POST", url, {"continuous": False, "precondition": 3600})
            s1, _ = f1.result()
            s2, _ = f2.result()
            time.sleep(1.0)
            final = precondition_from(get_state())
            results.append(final)
            print(f"Run {i:2d}: w1=HTTP{s1}, w2=HTTP{s2}, final={final}")
    print()
    print("=== Summary ===")
    for val, count in Counter(results).most_common():
        print(f"  {val!s:>10}: {count}/10")
if __name__ == "__main__":
    main()

Configuration details

.

Log details

.

What type of operating system or environment does evcc run on?

Linux

External automation

  • I have made sure that no external automation like HomeAssistant or Node-RED is active or accessing any of the mentioned devices when this issue occurs.

Nightly build

  • I have verified that the issue is reproducible with the latest nightly build

Version

v0.306.3

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionRather clarification than issue

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions