Add tracking of counter across script invocations

Write the sample counter value corresponding with the last recorded data point into the database along with the rest of the sample data so that it can be read out on next invocation of the script and data collection resumed where it left off.

Switch default sample count to all samples when in bulk mode, which now really means all samples since the last one recorded already.

Switch the time precision to be 1 second. Data points are only written one per second, anyway, and this way if there is any overlap due to counter tracking failure, the existing data will just get overwritten instead of creating duplicates.

Add a maximum queue length, so the script doesn't just keep using more memory if it persistently (>10 days) fails writing to the InfluxDB server.

Hack around some issues I ran into with the influxdb-python client library, especially with respect to running queries against InfluxDB 2.0 servers.

This concludes the functionality related to bulk collection of history data discussed on issue #5
This commit is contained in:
sparky8512 2021-01-21 20:39:37 -08:00
parent ab335e9227
commit 2e045ade16
2 changed files with 108 additions and 19 deletions

View file

@ -17,6 +17,8 @@ The scripts that use [MQTT](https://mqtt.org/) for output require the `paho-mqtt
The scripts that use [InfluxDB](https://www.influxdata.com/products/influxdb/) for output require the `influxdb` Python package. Information about how to install that can be found at https://github.com/influxdata/influxdb-python. Note that this is the (slightly) older version of the InfluxDB client Python module, not the InfluxDB 2.0 client. It can still be made to work with an InfluxDB 2.0 server, but doing so requires using `influx v1` [CLI commands](https://docs.influxdata.com/influxdb/v2.0/reference/cli/influx/v1/) on the server to map the 1.x username, password, and database names to their 2.0 equivalents. The scripts that use [InfluxDB](https://www.influxdata.com/products/influxdb/) for output require the `influxdb` Python package. Information about how to install that can be found at https://github.com/influxdata/influxdb-python. Note that this is the (slightly) older version of the InfluxDB client Python module, not the InfluxDB 2.0 client. It can still be made to work with an InfluxDB 2.0 server, but doing so requires using `influx v1` [CLI commands](https://docs.influxdata.com/influxdb/v2.0/reference/cli/influx/v1/) on the server to map the 1.x username, password, and database names to their 2.0 equivalents.
Note that the Python package versions available from various Linux distributions (ie: installed via `apt-get` or similar) tend to run a bit behind those available to install via `pip`. While the distro packages should work OK as long as they aren't extremely old, they may not work as well as the later versions.
Running the scripts within a [Docker](https://www.docker.com/) container requires Docker to be installed. Information about how to install that can be found at https://docs.docker.com/engine/install/ Running the scripts within a [Docker](https://www.docker.com/) container requires Docker to be installed. Information about how to install that can be found at https://docs.docker.com/engine/install/
## Usage ## Usage
@ -78,6 +80,10 @@ python3 dishStatusInflux.py -t 30 [... probably other args to specify server opt
Some of the scripts (currently only the InfluxDB ones) also support specifying options through environment variables. See details in the scripts for the environment variables that map to options. Some of the scripts (currently only the InfluxDB ones) also support specifying options through environment variables. See details in the scripts for the environment variables that map to options.
#### Bulk history data collection
`dishStatusInflux.py` also supports a bulk mode that collects and writes the full second-by-second data to the server instead of summary stats. To select bulk mode, use the `-b` option. You'll probably also want to use the `-t` option to have it run in a loop.
### Other scripts ### Other scripts
`dishDumpStatus.py` is a simple example of how to use the grpc modules (the ones generated by protoc, not `starlink_grpc.py`) directly. Just run it as: `dishDumpStatus.py` is a simple example of how to use the grpc modules (the ones generated by protoc, not `starlink_grpc.py`) directly. Just run it as:

View file

@ -31,6 +31,10 @@ from influxdb import InfluxDBClient
import starlink_grpc import starlink_grpc
BULK_MEASUREMENT = "spacex.starlink.user_terminal.history"
PING_MEASUREMENT = "spacex.starlink.user_terminal.ping_stats"
MAX_QUEUE_LENGTH = 864000
class Terminated(Exception): class Terminated(Exception):
pass pass
@ -45,7 +49,7 @@ def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "abhn:p:rs:t:vC:D:IP:R:SU:") opts, args = getopt.getopt(sys.argv[1:], "abhkn:p:rs:t:vC:D:IP:R:SU:")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
@ -58,6 +62,7 @@ def main():
default_loop_time = 0 default_loop_time = 0
loop_time = default_loop_time loop_time = default_loop_time
bulk_mode = False bulk_mode = False
bulk_skip_query = False
run_lengths = False run_lengths = False
host_default = "localhost" host_default = "localhost"
database_default = "starlinkstats" database_default = "starlinkstats"
@ -106,6 +111,8 @@ def main():
bulk_mode = True bulk_mode = True
elif opt == "-h": elif opt == "-h":
print_usage = True print_usage = True
elif opt == "-k":
bulk_skip_query = True
elif opt == "-n": elif opt == "-n":
icargs["host"] = arg icargs["host"] = arg
elif opt == "-p": elif opt == "-p":
@ -146,12 +153,13 @@ def main():
print(" -a: Parse all valid samples") print(" -a: Parse all valid samples")
print(" -b: Bulk mode: write individual sample data instead of summary stats") print(" -b: Bulk mode: write individual sample data instead of summary stats")
print(" -h: Be helpful") print(" -h: Be helpful")
print(" -k: Skip querying for prior sample write point in bulk mode")
print(" -n <name>: Hostname of InfluxDB server, default: " + host_default) print(" -n <name>: Hostname of InfluxDB server, default: " + host_default)
print(" -p <num>: Port number to use on InfluxDB server") print(" -p <num>: Port number to use on InfluxDB server")
print(" -r: Include ping drop run length stats") print(" -r: Include ping drop run length stats")
print(" -s <num>: Number of data samples to parse; in bulk mode, applies to first") print(" -s <num>: Number of data samples to parse; in bulk mode, applies to first")
print(" loop iteration only, default: loop interval, if set, else " + print(" loop iteration only, default: -1 in bulk mode, loop interval if")
str(samples_default)) print(" loop interval set, else " + str(samples_default))
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " + print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time)) str(default_loop_time))
print(" -v: Be verbose") print(" -v: Be verbose")
@ -165,7 +173,7 @@ def main():
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
if samples is None: if samples is None:
samples = int(loop_time) if loop_time > 0 else samples_default samples = -1 if bulk_mode else int(loop_time) if loop_time > 0 else samples_default
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
@ -177,6 +185,7 @@ def main():
gstate.points = [] gstate.points = []
gstate.counter = None gstate.counter = None
gstate.timestamp = None gstate.timestamp = None
gstate.query_done = bulk_skip_query
def conn_error(msg, *args): def conn_error(msg, *args):
# Connection errors that happen in an interval loop are not critical # Connection errors that happen in an interval loop are not critical
@ -187,24 +196,62 @@ def main():
logging.error(msg, *args) logging.error(msg, *args)
def flush_points(client): def flush_points(client):
# Don't flush points to server if the counter query failed, since some
# may be discarded later. Write would probably fail, too, anyway.
if bulk_mode and not gstate.query_done:
return 1
try: try:
while len(gstate.points) > max_batch: while len(gstate.points) > max_batch:
client.write_points(gstate.points[:max_batch], retention_policy=rp) client.write_points(gstate.points[:max_batch],
time_precision="s",
retention_policy=rp)
if verbose: if verbose:
print("Data points written: " + str(max_batch)) print("Data points written: " + str(max_batch))
del gstate.points[:max_batch] del gstate.points[:max_batch]
if gstate.points: if gstate.points:
client.write_points(gstate.points, retention_policy=rp) client.write_points(gstate.points, time_precision="s", retention_policy=rp)
if verbose: if verbose:
print("Data points written: " + str(len(gstate.points))) print("Data points written: " + str(len(gstate.points)))
gstate.points.clear() gstate.points.clear()
except Exception as e: except Exception as e:
conn_error("Failed writing to InfluxDB database: %s", str(e)) conn_error("Failed writing to InfluxDB database: %s", str(e))
# If failures persist, don't just use infinite memory. Max queue
# is currently 10 days of bulk data, so something is very wrong
# if it's ever exceeded.
if len(gstate.points) > MAX_QUEUE_LENGTH:
logging.error("Max write queue exceeded, discarding data.")
del gstate.points[:-MAX_QUEUE_LENGTH]
return 1 return 1
return 0 return 0
def process_bulk_data(): def query_counter(client, now, len_points):
try:
# fetch the latest point where counter field was recorded
result = client.query("SELECT counter FROM \"{0}\" "
"WHERE time>={1}s AND time<{2}s AND id=$id "
"ORDER by time DESC LIMIT 1;".format(
BULK_MEASUREMENT, now - len_points, now),
bind_params={"id": gstate.dish_id},
epoch="s")
rpoints = list(result.get_points())
if rpoints:
counter = rpoints[0].get("counter", None)
timestamp = rpoints[0].get("time", 0)
if counter and timestamp:
return int(counter), int(timestamp)
except TypeError as e:
# bind_params was added in influxdb-python v5.2.3. That would be
# easy enough to work around, but older versions had other problems
# with query(), so just skip this functionality.
logging.error(
"Failed running query, probably due to influxdb-python version too old. "
"Skipping resumption from prior counter value. Reported error was: %s", str(e))
return None, 0
def process_bulk_data(client):
before = time.time() before = time.time()
start = gstate.counter start = gstate.counter
@ -215,7 +262,7 @@ def main():
parsed_samples = general["samples"] parsed_samples = general["samples"]
new_counter = general["current"] new_counter = general["current"]
timestamp = gstate.timestamp timestamp = gstate.timestamp
# Check this first, so it doesn't report as lost time sync # check this first, so it doesn't report as lost time sync
if gstate.counter is not None and new_counter != gstate.counter + parsed_samples: if gstate.counter is not None and new_counter != gstate.counter + parsed_samples:
timestamp = None timestamp = None
# Allow up to 2 seconds of time drift before forcibly re-syncing, since # Allow up to 2 seconds of time drift before forcibly re-syncing, since
@ -226,26 +273,57 @@ def main():
str(datetime.fromtimestamp(timestamp + parsed_samples, tz=timezone.utc))) str(datetime.fromtimestamp(timestamp + parsed_samples, tz=timezone.utc)))
timestamp = None timestamp = None
if timestamp is None: if timestamp is None:
timestamp = before timestamp = int(before)
if verbose: if verbose and gstate.query_done:
print("Establishing new time base: " + str(new_counter) + " -> " + print("Establishing new time base: {0} -> {1}".format(
str(datetime.fromtimestamp(timestamp, tz=timezone.utc))) new_counter, datetime.fromtimestamp(timestamp, tz=timezone.utc)))
timestamp -= parsed_samples timestamp -= parsed_samples
for i in range(parsed_samples): for i in range(parsed_samples):
timestamp += 1
gstate.points.append({ gstate.points.append({
"measurement": "spacex.starlink.user_terminal.history", "measurement": BULK_MEASUREMENT,
"tags": { "tags": {
"id": gstate.dish_id "id": gstate.dish_id
}, },
"time": datetime.utcfromtimestamp(timestamp), "time": timestamp,
"fields": {k: v[i] for k, v in bulk.items() if v[i] is not None}, "fields": {k: v[i] for k, v in bulk.items() if v[i] is not None},
}) })
timestamp += 1
# save off counter value for script restart
if parsed_samples:
gstate.points[-1]["fields"]["counter"] = new_counter
gstate.counter = new_counter gstate.counter = new_counter
gstate.timestamp = timestamp gstate.timestamp = timestamp
# This is here and not before the points being processed because if the
# query previously failed, there will be points that were processed in
# a prior loop. This avoids having to handle that as a special case.
if not gstate.query_done:
try:
db_counter, db_timestamp = query_counter(client, timestamp, len(gstate.points))
except Exception as e:
# could be temporary outage, so try again next time
conn_error("Failed querying InfluxDB for prior count: %s", str(e))
return
gstate.query_done = True
start_counter = new_counter - len(gstate.points)
if db_counter and start_counter <= db_counter < new_counter:
del gstate.points[:db_counter - start_counter]
if before - 2.0 <= db_timestamp + len(gstate.points) <= after + 2.0:
if verbose:
print("Using existing time base: {0} -> {1}".format(
db_counter, datetime.fromtimestamp(db_timestamp, tz=timezone.utc)))
for point in gstate.points:
db_timestamp += 1
point["time"] = db_timestamp
gstate.timestamp = db_timestamp
return
if verbose:
print("Establishing new time base: {0} -> {1}".format(
new_counter, datetime.fromtimestamp(timestamp, tz=timezone.utc)))
def process_ping_stats(): def process_ping_stats():
timestamp = time.time() timestamp = time.time()
@ -262,11 +340,11 @@ def main():
all_stats[k] = v all_stats[k] = v
gstate.points.append({ gstate.points.append({
"measurement": "spacex.starlink.user_terminal.ping_stats", "measurement": PING_MEASUREMENT,
"tags": { "tags": {
"id": gstate.dish_id "id": gstate.dish_id
}, },
"time": datetime.utcfromtimestamp(timestamp), "time": int(timestamp),
"fields": all_stats, "fields": all_stats,
}) })
@ -282,7 +360,7 @@ def main():
if bulk_mode: if bulk_mode:
try: try:
process_bulk_data() process_bulk_data(client)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
conn_error("Failure getting history: %s", str(e)) conn_error("Failure getting history: %s", str(e))
return 1 return 1
@ -306,6 +384,11 @@ def main():
warnings.filterwarnings("ignore", message="Unverified HTTPS request") warnings.filterwarnings("ignore", message="Unverified HTTPS request")
signal.signal(signal.SIGTERM, handle_sigterm) signal.signal(signal.SIGTERM, handle_sigterm)
try:
# attempt to hack around breakage between influxdb-python client and 2.0 server:
influx_client = InfluxDBClient(**icargs, headers={"Accept": "application/json"})
except TypeError:
# ...unless influxdb-python package version is too old
influx_client = InfluxDBClient(**icargs) influx_client = InfluxDBClient(**icargs)
try: try:
next_loop = time.monotonic() next_loop = time.monotonic()