nthlayer-workers 2.0.1__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/PKG-INFO +2 -2
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/pyproject.toml +6 -2
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/prometheus.py +5 -22
- nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/__init__.py +1 -0
- nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/phase.py +423 -0
- nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/sampler.py +231 -0
- nthlayer_workers-3.0.0/src/nthlayer_workers/measure/thresholds/state.py +124 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/worker.py +275 -49
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/worker.py +13 -6
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/PKG-INFO +2 -2
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/SOURCES.txt +4 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/requires.txt +1 -1
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_dependency_declarations.py +27 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/README.md +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/setup.cfg +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/cli_output.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/config.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/changes.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/dedup.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/engine.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/temporal.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/correlation/topology.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/severity.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/ingestion/webhook.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/notifications.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/prometheus.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/reasoning.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/session.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/generator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/model.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/snapshot/token.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/state.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/store/sqlite.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/summary.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/tempo.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/traces/topology.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/types.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/verdict_chain.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/correlate/worker.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/__main__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_apply.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_gh.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_interactive.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_interactive_app.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_preview.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_trigger.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/_yaml.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/recommendations.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/retrospective.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/trends.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/learn/worker.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/__main__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/_parsing.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/_util.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/devin.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/gastown.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/adapters/webhook.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/loop.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/slos.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/calibration/verdict_calibration.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/config.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/detector.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/detection/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/governance/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/governance/engine.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/manifest.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/notifications.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/evaluator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/pipeline/router.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/store/sqlite.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/telemetry.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/classifier.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/tiering/promotion.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/trends/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/trends/tracker.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/measure/types.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/__main__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/api/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/assessment.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/config.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/db/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/decision_records.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/discovery.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/backstage.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/base.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/consul.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/etcd.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/kubernetes.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/prometheus.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/dependencies/providers/zookeeper.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/deployments/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/classifier.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/client.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/discovery/models.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/analyzer.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/models.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/drift/patterns.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/explanation.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/conditions.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/correlator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/evaluator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate/policies.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/gate_adapter.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/incident.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/aggregator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/portfolio/scorer.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/collector.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/slo/spec_loader.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/sqlite_store.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/store.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/exporter_guidance.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/extractor.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/models.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/verification/verifier.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/observe/worker.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/__main__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/base.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/communication.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/investigation.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/remediation.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/response_models.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/agents/triage.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/cli.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/config.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/context_store.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/coordinator.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/metrics.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/ntfy_backend.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/protocol.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/slack_backend.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notification_backends/stdout_backend.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/notifications.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/escalation.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/runner.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/oncall/schedule.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/actions.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/registry.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/safe_actions/webhook.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/server.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/__init__.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/brief.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/delegation.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/post_incident.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/shift_report.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/sre/suppression.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/types.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/verdict_submission.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/respond/worker_helpers.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers/runner.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/dependency_links.txt +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/entry_points.txt +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/src/nthlayer_workers.egg-info/top_level.txt +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_cli_gate.py +0 -0
- {nthlayer_workers-2.0.1 → nthlayer_workers-3.0.0}/tests/test_runner.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nthlayer-workers
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: NthLayer workers — Tier 2 background computation (observe, measure, correlate, respond, learn)
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: nthlayer-common<
|
|
7
|
+
Requires-Dist: nthlayer-common<6.0.0,>=5.2.0
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
9
|
Requires-Dist: pyyaml>=6.0
|
|
10
10
|
Requires-Dist: structlog>=24.1.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nthlayer-workers"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0.0"
|
|
8
8
|
description = "NthLayer workers — Tier 2 background computation (observe, measure, correlate, respond, learn)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -47,7 +47,11 @@ dependencies = [
|
|
|
47
47
|
# green. Still missing, and tracked as opensrm-p62o: the EXPECTED_MAJORS
|
|
48
48
|
# half, which reads the BUILT wheel's own metadata in the release
|
|
49
49
|
# container, where deps come from PyPI instead of the sibling checkout.
|
|
50
|
-
|
|
50
|
+
# WHY 5.2.0 (opensrm-g32d): judgment thresholds are evaluated with
|
|
51
|
+
# nthlayer-common's evaluate_threshold (CORE §5.5.6) and read from core 3.0's
|
|
52
|
+
# /manifests via threshold_from_v2_entry; error magnitudes are no longer
|
|
53
|
+
# SLOs (decision 3c, common 4.0/5.0). See nthlayer-common/docs/upgrading-5.0.md.
|
|
54
|
+
"nthlayer-common>=5.2.0,<6.0.0",
|
|
51
55
|
"httpx>=0.27",
|
|
52
56
|
"pyyaml>=6.0",
|
|
53
57
|
"structlog>=24.1.0",
|
|
@@ -96,7 +96,7 @@ def load_specs(specs_dir: Path) -> LoadedSpecs:
|
|
|
96
96
|
|
|
97
97
|
Going through the parser also removes two reimplementations that had
|
|
98
98
|
drifted: a four-name judgment-type list that disagreed with
|
|
99
|
-
|
|
99
|
+
the judgment SLO types, and a target normalisation that duplicated the
|
|
100
100
|
0-100 convention nthlayer-common owns.
|
|
101
101
|
"""
|
|
102
102
|
slos: list[SLODefinition] = []
|
|
@@ -155,7 +155,7 @@ def _query_for(service: str, slo: ManifestSLO) -> tuple[str, str]:
|
|
|
155
155
|
name = slo.name
|
|
156
156
|
if slo.judgment_type:
|
|
157
157
|
# Keyed by judgment_type, not name: _JUDGMENT_SLO_QUERIES is a map of
|
|
158
|
-
# judgment KINDS. Only
|
|
158
|
+
# judgment KINDS. Only 2 of the 5 V2_JUDGMENT_SLO_TYPES have a builder;
|
|
159
159
|
# the rest fall through to the recording-rule convention below rather
|
|
160
160
|
# than yielding an empty query, which Prometheus 400s and which then
|
|
161
161
|
# reads as no-data (opensrm-fxln).
|
|
@@ -176,8 +176,7 @@ def _query_for(service: str, slo: ManifestSLO) -> tuple[str, str]:
|
|
|
176
176
|
|
|
177
177
|
# PromQL builders keyed by judgment TYPE (spec.judgment_type), not by the
|
|
178
178
|
# SLO's name — in v2 the two are independent. Lambdas take (service,
|
|
179
|
-
# window)
|
|
180
|
-
# queries are window-agnostic (the raw metric is already a gauge).
|
|
179
|
+
# window).
|
|
181
180
|
_JUDGMENT_SLO_QUERIES = {
|
|
182
181
|
"reversal_rate": lambda service, window: (
|
|
183
182
|
f'sum(increase(gen_ai_overrides_total{{service="{service}"}}[{window}]))'
|
|
@@ -189,8 +188,6 @@ _JUDGMENT_SLO_QUERIES = {
|
|
|
189
188
|
f' / '
|
|
190
189
|
f'sum(increase(gen_ai_decisions_total{{service="{service}",confidence_bucket="high"}}[{window}]))'
|
|
191
190
|
),
|
|
192
|
-
"calibration": lambda service, _window: f'gen_ai_calibration_error{{service="{service}"}}',
|
|
193
|
-
"feedback_latency": lambda service, _window: f'gen_ai_feedback_latency_seconds{{service="{service}"}}',
|
|
194
191
|
}
|
|
195
192
|
|
|
196
193
|
|
|
@@ -201,17 +198,6 @@ _JUDGMENT_SLO_QUERIES = {
|
|
|
201
198
|
_JUDGMENT_QUERY_KINDS = {
|
|
202
199
|
"reversal_rate": "judgment_rate",
|
|
203
200
|
"high_confidence_failure": "judgment_rate",
|
|
204
|
-
"calibration": "judgment_rate",
|
|
205
|
-
# Unreachable today: feedback_latency is in opensrm's v1 schema.json and
|
|
206
|
-
# in its CHANGELOG, but NOT in nthlayer-common's JUDGMENT_SLO_TYPES, and
|
|
207
|
-
# parser/v1.py sets judgment_type only on membership. So a manifest
|
|
208
|
-
# declaring it parses with judgment_type=None and lands on "ratio",
|
|
209
|
-
# which multiplies a seconds gauge by 100. Kept rather than deleted
|
|
210
|
-
# because the divergence is the bug (opensrm-vrpa) and this is the
|
|
211
|
-
# correct handling once it is fixed;
|
|
212
|
-
# test_feedback_latency_cannot_reach_its_own_branch is a strict xfail
|
|
213
|
-
# that fails the day it becomes reachable.
|
|
214
|
-
"feedback_latency": "judgment_duration",
|
|
215
201
|
}
|
|
216
202
|
|
|
217
203
|
|
|
@@ -415,8 +401,8 @@ async def evaluate_slos(
|
|
|
415
401
|
|
|
416
402
|
# Dispatch on the kind of number the query returns, decided by
|
|
417
403
|
# _query_for when it built the query. Never on the SLO's name or
|
|
418
|
-
# type: in v2 metadata.name is author-chosen, and
|
|
419
|
-
#
|
|
404
|
+
# type: in v2 metadata.name is author-chosen, and 3 of the 5
|
|
405
|
+
# V2_JUDGMENT_SLO_TYPES have no builder, so both are the wrong
|
|
420
406
|
# question to ask about a value's units (opensrm-fxln).
|
|
421
407
|
if slo.query_kind == "judgment_rate":
|
|
422
408
|
# Prometheus returns a 0.0-1.0 RATE; the target is a 0-100
|
|
@@ -429,9 +415,6 @@ async def evaluate_slos(
|
|
|
429
415
|
# overrides/decisions ratio, not to judgment SLOs at large.
|
|
430
416
|
sli_pct = (1.0 - current_value) * 100
|
|
431
417
|
raw_breach = sli_pct < slo.target
|
|
432
|
-
elif slo.query_kind == "judgment_duration":
|
|
433
|
-
# Seconds, and lower is better: no scaling, no inversion.
|
|
434
|
-
raw_breach = current_value > slo.target
|
|
435
418
|
elif slo.query_kind == "error_budget":
|
|
436
419
|
# slo:error_budget:ratio is signed — negative is overspent.
|
|
437
420
|
# The target is not consulted; it is baked into the rule.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Judgment-threshold evaluation for the measure worker (opensrm-g32d)."""
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""The judgment-threshold phase of the measure cycle (opensrm-g32d)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
from nthlayer_common.cloudevents import wrap_assessment, wrap_verdict
|
|
14
|
+
from nthlayer_common.manifest import (
|
|
15
|
+
JudgmentThreshold,
|
|
16
|
+
Missing,
|
|
17
|
+
OpenSRMV2ParseError,
|
|
18
|
+
ThresholdOutcome,
|
|
19
|
+
evaluate_threshold,
|
|
20
|
+
threshold_from_v2_entry,
|
|
21
|
+
threshold_to_v2_entry,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from nthlayer_workers.measure.thresholds.sampler import fetch_retention, needs_retention, sample
|
|
25
|
+
from nthlayer_workers.measure.thresholds.state import (
|
|
26
|
+
REARM_INTERVALS,
|
|
27
|
+
KeyState,
|
|
28
|
+
advance,
|
|
29
|
+
as_timestamp,
|
|
30
|
+
bounds_digest,
|
|
31
|
+
threshold_key,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger()
|
|
35
|
+
|
|
36
|
+
THRESHOLD_BREACH_SEVERITY = "high"
|
|
37
|
+
# Defaults for MeasureModule's MEASURE_THRESHOLD_INTERVAL / _HEARTBEAT.
|
|
38
|
+
DEFAULT_THRESHOLD_INTERVAL_SECONDS = 900.0
|
|
39
|
+
DEFAULT_THRESHOLD_HEARTBEAT_SECONDS = 3600.0
|
|
40
|
+
# MeasureModule bounds one threshold run by this (asyncio.wait_for), so a
|
|
41
|
+
# hanging Prometheus cannot hold the measure cycle (and its SLO phases).
|
|
42
|
+
THRESHOLD_PHASE_TIMEOUT_SECONDS = 120.0
|
|
43
|
+
|
|
44
|
+
_Entry = tuple[str, str, JudgmentThreshold] # (key, service, threshold)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _log_once(seen: set[str], token: str, event: str, **kw: Any) -> None:
|
|
48
|
+
if token not in seen:
|
|
49
|
+
seen.add(token)
|
|
50
|
+
logger.warning(event, **kw)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def outcome_signature(outcome: ThresholdOutcome) -> str:
|
|
54
|
+
"""The outcome without its measured values, as a JSON string with sorted
|
|
55
|
+
keys and sorted bound/segment lists: an assessment is re-emitted when
|
|
56
|
+
this changes (or on the heartbeat). A bound's declared limit is part of
|
|
57
|
+
it, so tightening a bound is reported even when no status changes."""
|
|
58
|
+
d = outcome.to_dict()
|
|
59
|
+
return json.dumps({
|
|
60
|
+
"type": d["type"],
|
|
61
|
+
"status": d["status"],
|
|
62
|
+
"bounds": sorted(([b["bound"], b["status"], b["reason"], b["limit"]] for b in d["bounds"]),
|
|
63
|
+
key=lambda row: row[0]),
|
|
64
|
+
"segments": sorted(([s["segment"], s["status"], s["reason"]] for s in d["segments"]),
|
|
65
|
+
key=lambda row: row[0]),
|
|
66
|
+
}, sort_keys=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def breach_readout(service: str, outcome: ThresholdOutcome) -> tuple[list[dict[str, Any]], str]:
|
|
70
|
+
"""What a breach verdict says to a reader that does not parse the outcome
|
|
71
|
+
(respond's briefing, bench): (breaching_bounds, summary).
|
|
72
|
+
|
|
73
|
+
breaching_bounds lists each breaching row as {"bound", "measured",
|
|
74
|
+
"limit"}; for a segments threshold, one row per breaching segment, with
|
|
75
|
+
"segment" added and the segments bound's name and limit. summary is one
|
|
76
|
+
line about the first such row. A row decided from a lower bound (reason
|
|
77
|
+
"lower_bound") measured the boundary, not the value, so it reads
|
|
78
|
+
">= limit (lower bound)". A Duration bound's measured value is seconds."""
|
|
79
|
+
rows: list[tuple[dict[str, Any], str | None]] = [] # (row, reason)
|
|
80
|
+
if outcome.type == "segments":
|
|
81
|
+
for b in outcome.bounds:
|
|
82
|
+
rows.extend(({"bound": b.bound, "segment": s.segment, "measured": s.measured,
|
|
83
|
+
"limit": b.limit}, s.reason)
|
|
84
|
+
for s in outcome.segments if s.status == "breach")
|
|
85
|
+
else:
|
|
86
|
+
rows.extend(({"bound": b.bound, "measured": b.measured, "limit": b.limit}, b.reason)
|
|
87
|
+
for b in outcome.bounds if b.status == "breach")
|
|
88
|
+
summary = f"{outcome.type} threshold breach on {service}"
|
|
89
|
+
if rows: # always, for an outcome whose status is breach
|
|
90
|
+
row, reason = rows[0]
|
|
91
|
+
# Segment names are author-chosen: escape control characters so the
|
|
92
|
+
# summary stays one line.
|
|
93
|
+
name = row["bound"] + (
|
|
94
|
+
f" (segment {row['segment'].encode('unicode_escape').decode('ascii')})"
|
|
95
|
+
if "segment" in row else "")
|
|
96
|
+
# A breach row always has a measured value (_decide sets it).
|
|
97
|
+
# 12 significant digits: a value just over its limit must not print as
|
|
98
|
+
# the limit (6-digit %g did), nor a long duration in exponent form.
|
|
99
|
+
value = f"{row['measured']:.12g}" + ("s" if isinstance(row["limit"], str) else "")
|
|
100
|
+
relation = (f">= {row['limit']} (lower bound)" if reason == "lower_bound"
|
|
101
|
+
else f"> {row['limit']}")
|
|
102
|
+
summary += f": {name} {value} {relation}"
|
|
103
|
+
return [row for row, _ in rows], summary
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _canonical(t: JudgmentThreshold) -> dict:
|
|
107
|
+
"""The entry's v2 form for the identical-entry check, with segment_values
|
|
108
|
+
sorted: their order is not part of an entry's meaning (or its key)."""
|
|
109
|
+
entry = threshold_to_v2_entry(t)
|
|
110
|
+
if isinstance(entry.get("segment_values"), list):
|
|
111
|
+
entry = {**entry, "segment_values": sorted(entry["segment_values"], key=str)}
|
|
112
|
+
return entry
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _entries(manifests: list[dict], seen_problems: set[str]) -> tuple[set[str], list[_Entry]]:
|
|
116
|
+
"""Parse and key every served threshold: (services present, entries).
|
|
117
|
+
|
|
118
|
+
"Present" means fully present, i.e. safe to retire against. A service is
|
|
119
|
+
left out, so every existing key of it is kept, when any of its manifests
|
|
120
|
+
has an entry that fails to parse, a judgment_thresholds that is not a
|
|
121
|
+
list, or no judgment_thresholds at all (a pre-3.0 core serves none: that
|
|
122
|
+
says nothing about which entries exist). An open breach must not retire
|
|
123
|
+
and re-raise because a response was briefly malformed or old. Cost: a
|
|
124
|
+
removed-and-broken entry lingers until the manifest parses cleanly or
|
|
125
|
+
the service disappears.
|
|
126
|
+
|
|
127
|
+
Identical entries are kept once. A threshold_key shared by several
|
|
128
|
+
distinct entries of a service (same identity, other bound values) is
|
|
129
|
+
suffixed for EVERY such entry with ~bounds_digest, so a key depends on
|
|
130
|
+
the entry, never on its position: reordering moves no state."""
|
|
131
|
+
present: set[str] = set()
|
|
132
|
+
partial: set[str] = set() # services with an entry that failed to parse
|
|
133
|
+
by_base: dict[str, list[tuple[dict, str, JudgmentThreshold]]] = {} # base -> distinct entries
|
|
134
|
+
for i, m in enumerate(manifests):
|
|
135
|
+
service = m.get("name") if isinstance(m, dict) else None
|
|
136
|
+
if not isinstance(service, str) or not service:
|
|
137
|
+
_log_once(seen_problems, f"manifest:{i}:{type(m).__name__}", "threshold_manifest_skipped",
|
|
138
|
+
where=f"/manifests[{i}]")
|
|
139
|
+
continue
|
|
140
|
+
items = m.get("judgment_thresholds")
|
|
141
|
+
if items is None:
|
|
142
|
+
# Absent (pre-3.0 core) or null: nothing to evaluate, and no
|
|
143
|
+
# evidence any entry was removed, so nothing of it retires.
|
|
144
|
+
partial.add(service)
|
|
145
|
+
continue
|
|
146
|
+
if not isinstance(items, list):
|
|
147
|
+
# Partial (not just "not present"): another manifest of the same
|
|
148
|
+
# name must not retire this service's keys either.
|
|
149
|
+
_log_once(seen_problems, f"thresholds:{service}:{type(items).__name__}",
|
|
150
|
+
"threshold_manifest_skipped", where=f"/manifests[{i}].judgment_thresholds")
|
|
151
|
+
partial.add(service)
|
|
152
|
+
continue
|
|
153
|
+
present.add(service)
|
|
154
|
+
for j, item in enumerate(items):
|
|
155
|
+
where = f"/manifests[{i}].judgment_thresholds[{j}]"
|
|
156
|
+
try:
|
|
157
|
+
t = threshold_from_v2_entry(item, where=where)
|
|
158
|
+
except OpenSRMV2ParseError as exc:
|
|
159
|
+
_log_once(seen_problems, f"parse:{service}:{item!r}", "threshold_parse_failed",
|
|
160
|
+
service=service, where=where, error=str(exc))
|
|
161
|
+
partial.add(service)
|
|
162
|
+
continue
|
|
163
|
+
base = threshold_key(service, t)
|
|
164
|
+
canonical = _canonical(t)
|
|
165
|
+
same_base = by_base.setdefault(base, [])
|
|
166
|
+
if any(c == canonical for c, _, _ in same_base): # identical: evaluate once
|
|
167
|
+
_log_once(seen_problems, f"dup:{base}", "threshold_duplicate", service=service, key=base)
|
|
168
|
+
continue
|
|
169
|
+
same_base.append((canonical, service, t))
|
|
170
|
+
entries: list[_Entry] = []
|
|
171
|
+
for base, distinct in by_base.items():
|
|
172
|
+
if len(distinct) == 1:
|
|
173
|
+
_, service, t = distinct[0]
|
|
174
|
+
entries.append((base, service, t))
|
|
175
|
+
continue
|
|
176
|
+
used: set[str] = set()
|
|
177
|
+
for _, service, t in distinct:
|
|
178
|
+
key = f"{base}~{bounds_digest(t)}"
|
|
179
|
+
if key in used: # a digest collision: keep the first, never merge state
|
|
180
|
+
_log_once(seen_problems, f"collision:{key}", "threshold_key_collision",
|
|
181
|
+
service=service, key=key)
|
|
182
|
+
continue
|
|
183
|
+
used.add(key)
|
|
184
|
+
entries.append((key, service, t))
|
|
185
|
+
return present - partial, entries
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _migrate(state: dict[str, dict], entries: list[_Entry]) -> dict[str, str]:
|
|
189
|
+
"""Carry state across a re-key; returns {old_key: new_key}.
|
|
190
|
+
|
|
191
|
+
Adding or removing a same-identity sibling moves an entry between the
|
|
192
|
+
bare key and key~bounds_digest (and editing a suffixed sibling's bound
|
|
193
|
+
changes its digest). Without this the old key retires and the new one
|
|
194
|
+
starts armed, so an open breach re-fires and, with autonomy on, steps
|
|
195
|
+
the one-way ratchet again.
|
|
196
|
+
|
|
197
|
+
Per service and base key, each live key with no state is matched to a
|
|
198
|
+
non-live state key of the same service and base: first by equal stored
|
|
199
|
+
bounds_digest (when exactly one such key has it); then, if exactly one
|
|
200
|
+
old and one new key remain unmatched, the two pair (a bound edited while
|
|
201
|
+
re-keyed). Anything else is ambiguous and starts fresh, the safe
|
|
202
|
+
direction (it may re-raise). State without a stored base and digest
|
|
203
|
+
(written before they existed) is never migrated. Runs before
|
|
204
|
+
evaluation, so a migrated key is evaluated from its carried state.
|
|
205
|
+
|
|
206
|
+
First, every live key that already has state gets its stored base and
|
|
207
|
+
digest refreshed from its current entry. A bound edited while the key
|
|
208
|
+
stays the same (a lone entry's key ignores bound values), on a cycle
|
|
209
|
+
that does not evaluate it, or whose evaluation keeps raising, would
|
|
210
|
+
otherwise leave a stale digest that a later split cannot match; this
|
|
211
|
+
also gives state restored from before these fields existed an identity."""
|
|
212
|
+
for key, service, t in entries:
|
|
213
|
+
v = state.get(key)
|
|
214
|
+
if isinstance(v, dict):
|
|
215
|
+
v["base"], v["bounds_digest"] = threshold_key(service, t), bounds_digest(t)
|
|
216
|
+
live = {key for key, _, _ in entries}
|
|
217
|
+
new: dict[tuple[str, str], list[tuple[str, str]]] = {} # (service, base) -> [(key, digest)]
|
|
218
|
+
for key, service, t in entries:
|
|
219
|
+
if key not in state:
|
|
220
|
+
new.setdefault((service, threshold_key(service, t)), []).append((key, bounds_digest(t)))
|
|
221
|
+
old: dict[tuple[str, str], list[tuple[str, str]]] = {} # (service, base) -> [(key, digest)]
|
|
222
|
+
for key, v in state.items():
|
|
223
|
+
if key in live or not isinstance(v, dict):
|
|
224
|
+
continue
|
|
225
|
+
service, base, digest = v.get("service"), v.get("base"), v.get("bounds_digest")
|
|
226
|
+
if isinstance(service, str) and isinstance(base, str) and isinstance(digest, str):
|
|
227
|
+
old.setdefault((service, base), []).append((key, digest))
|
|
228
|
+
renamed: dict[str, str] = {}
|
|
229
|
+
for group, new_keys in new.items():
|
|
230
|
+
olds = old.get(group, [])
|
|
231
|
+
if not olds:
|
|
232
|
+
continue
|
|
233
|
+
pairs: list[tuple[str, str, str]] = [] # (old_key, new_key, new_digest)
|
|
234
|
+
for new_key, digest in new_keys:
|
|
235
|
+
same = [k for k, d in olds if d == digest]
|
|
236
|
+
if len(same) == 1:
|
|
237
|
+
pairs.append((same[0], new_key, digest))
|
|
238
|
+
matched_old = {o for o, _, _ in pairs}
|
|
239
|
+
if len(matched_old) != len(pairs): # two new keys claimed one old key
|
|
240
|
+
pairs = []
|
|
241
|
+
matched_old = set()
|
|
242
|
+
matched_new = {n for _, n, _ in pairs}
|
|
243
|
+
rest_old = [k for k, _ in olds if k not in matched_old]
|
|
244
|
+
rest_new = [(k, d) for k, d in new_keys if k not in matched_new]
|
|
245
|
+
if len(rest_old) == 1 and len(rest_new) == 1:
|
|
246
|
+
pairs.append((rest_old[0], rest_new[0][0], rest_new[0][1]))
|
|
247
|
+
for old_key, new_key, digest in pairs:
|
|
248
|
+
state[new_key] = {**state.pop(old_key), "base": group[1], "bounds_digest": digest}
|
|
249
|
+
renamed[old_key] = new_key
|
|
250
|
+
logger.info("threshold_migrated", old_key=old_key, new_key=new_key)
|
|
251
|
+
return renamed
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _evaluate(
|
|
255
|
+
*, client: Any, provider: Any, key: str, service: str, t: JudgmentThreshold,
|
|
256
|
+
state: dict[str, dict], retention: float | Missing | None, retention_check: bool,
|
|
257
|
+
now: float, rearm_seconds: float, heartbeat_seconds: float,
|
|
258
|
+
) -> tuple[str, str, str] | None:
|
|
259
|
+
"""Sample, evaluate, report one entry; returns its new breach, if any.
|
|
260
|
+
state[key] changes only once everything this run owes is delivered."""
|
|
261
|
+
outcome = evaluate_threshold(
|
|
262
|
+
t, await sample(provider, service, t, now=now, retention=retention,
|
|
263
|
+
retention_check=retention_check)
|
|
264
|
+
)
|
|
265
|
+
prev = state.get(key) or {}
|
|
266
|
+
new_state, fire = advance(KeyState.from_dict(prev), outcome.status, now, rearm_seconds)
|
|
267
|
+
signature = outcome_signature(outcome)
|
|
268
|
+
last_signature = prev.get("last_signature")
|
|
269
|
+
last_emitted_at = as_timestamp(prev.get("last_emitted_at"))
|
|
270
|
+
# A firing breach always emits: its verdict's parent_ids name this assessment.
|
|
271
|
+
# A last emission in the future (the clock went back) emits too, so a
|
|
272
|
+
# backward clock cannot silence the heartbeat.
|
|
273
|
+
emit = (
|
|
274
|
+
fire
|
|
275
|
+
or signature != last_signature
|
|
276
|
+
or last_emitted_at is None
|
|
277
|
+
or last_emitted_at > now
|
|
278
|
+
or now - last_emitted_at >= heartbeat_seconds
|
|
279
|
+
)
|
|
280
|
+
assessment_id = f"jte-{service}-{t.type}-{uuid.uuid4().hex[:8]}"
|
|
281
|
+
if emit:
|
|
282
|
+
assessment = {
|
|
283
|
+
"id": assessment_id,
|
|
284
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
285
|
+
"kind": "judgment_threshold_evaluation",
|
|
286
|
+
"service": service,
|
|
287
|
+
"data": {"threshold_key": key, "outcome": outcome.to_dict()},
|
|
288
|
+
}
|
|
289
|
+
result = await client.submit_assessment(wrap_assessment(assessment, component="measure"))
|
|
290
|
+
if result.ok:
|
|
291
|
+
last_signature, last_emitted_at = signature, now
|
|
292
|
+
else:
|
|
293
|
+
# Not recorded as emitted, so the next run emits again. A breach
|
|
294
|
+
# still raises its verdict, whose parent_ids then name an
|
|
295
|
+
# assessment core did not store.
|
|
296
|
+
logger.warning("threshold_assessment_submit_failed", service=service, key=key,
|
|
297
|
+
assessment_id=assessment_id, status_code=result.status_code)
|
|
298
|
+
breach: tuple[str, str, str] | None = None
|
|
299
|
+
if fire:
|
|
300
|
+
breach_id = f"vrd-tbreach-{service}-{t.type}-{uuid.uuid4().hex[:8]}"
|
|
301
|
+
breaching_bounds, summary = breach_readout(service, outcome)
|
|
302
|
+
verdict = {
|
|
303
|
+
"id": breach_id,
|
|
304
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
305
|
+
"type": "quality_breach",
|
|
306
|
+
"service": service,
|
|
307
|
+
"parent_ids": [assessment_id],
|
|
308
|
+
"data": {"threshold_key": key, "threshold_type": t.type,
|
|
309
|
+
"severity": THRESHOLD_BREACH_SEVERITY, "outcome": outcome.to_dict(),
|
|
310
|
+
"breaching_bounds": breaching_bounds, "summary": summary},
|
|
311
|
+
}
|
|
312
|
+
sent = await client.submit_verdict(wrap_verdict(verdict, component="measure"))
|
|
313
|
+
if not sent.ok:
|
|
314
|
+
# Keep the previous state, so the next run sees the same
|
|
315
|
+
# transition and raises the verdict (and its assessment) again.
|
|
316
|
+
logger.warning("threshold_breach_submit_failed", service=service, key=key,
|
|
317
|
+
status_code=sent.status_code)
|
|
318
|
+
return None
|
|
319
|
+
breach = (key, service, breach_id)
|
|
320
|
+
state[key] = {**new_state.to_dict(), "service": service,
|
|
321
|
+
"base": threshold_key(service, t), "bounds_digest": bounds_digest(t),
|
|
322
|
+
"last_signature": last_signature, "last_emitted_at": last_emitted_at}
|
|
323
|
+
return breach
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def run_threshold_phase(
|
|
327
|
+
*, client: Any, provider: Any, manifests: list[dict], state: dict[str, dict],
|
|
328
|
+
seen_problems: set[str], eval_concurrency: int = 5, retention_check: bool = True,
|
|
329
|
+
now: float | None = None,
|
|
330
|
+
rearm_seconds: float = REARM_INTERVALS * DEFAULT_THRESHOLD_INTERVAL_SECONDS,
|
|
331
|
+
heartbeat_seconds: float = DEFAULT_THRESHOLD_HEARTBEAT_SECONDS,
|
|
332
|
+
renamed: dict[str, str] | None = None,
|
|
333
|
+
breaches_out: list[tuple[str, str, str]] | None = None,
|
|
334
|
+
unreached: set[str] | None = None,
|
|
335
|
+
rotation: int = 0,
|
|
336
|
+
) -> list[tuple[str, str, str]]:
|
|
337
|
+
"""Evaluate every served threshold; mutate *state*; return new breaches as
|
|
338
|
+
(key, service, breach_verdict_id). Caller guarantees *manifests* came from a
|
|
339
|
+
successful /manifests response. Entries are evaluated concurrently, at most
|
|
340
|
+
*eval_concurrency* at a time; one that raises is logged and skipped. An
|
|
341
|
+
assessment is emitted on first sight, when the outcome's signature
|
|
342
|
+
changes, after *heartbeat_seconds*, or whenever a breach fires.
|
|
343
|
+
|
|
344
|
+
Out-parameters, filled as the run goes so a caller that cancels it (the
|
|
345
|
+
worker's timeout) still sees them: *renamed* gets {old_key: new_key}
|
|
346
|
+
for state carried across a re-key (before any evaluation); each breach
|
|
347
|
+
whose verdict core accepted is appended to *breaches_out* at once, since
|
|
348
|
+
its state has advanced and it will never fire again. *unreached* is
|
|
349
|
+
filled with every key, and each is removed as its evaluation starts, so
|
|
350
|
+
after a cancellation it holds the keys never started.
|
|
351
|
+
|
|
352
|
+
Evaluation starts at entry *rotation* % len(entries) (the worker passes
|
|
353
|
+
a per-run counter), so a time budget that cuts a slow run short does not
|
|
354
|
+
always cut off the same tail. Order only: keys and state are unaffected."""
|
|
355
|
+
now = time.time() if now is None else now
|
|
356
|
+
present_services, entries = _entries(manifests, seen_problems)
|
|
357
|
+
moved = _migrate(state, entries)
|
|
358
|
+
if renamed is not None:
|
|
359
|
+
renamed.update(moved)
|
|
360
|
+
# Prometheus's retention, queried once per run and only if needed.
|
|
361
|
+
retention: float | Missing | None = None
|
|
362
|
+
if retention_check and any(needs_retention(t) for _, _, t in entries):
|
|
363
|
+
retention = await fetch_retention(provider)
|
|
364
|
+
sem = asyncio.Semaphore(eval_concurrency)
|
|
365
|
+
new_breaches = breaches_out if breaches_out is not None else []
|
|
366
|
+
start = rotation % len(entries) if entries else 0
|
|
367
|
+
ordered = entries[start:] + entries[:start]
|
|
368
|
+
if unreached is not None:
|
|
369
|
+
unreached.update(key for key, _, _ in ordered)
|
|
370
|
+
|
|
371
|
+
async def one(key: str, service: str, t: JudgmentThreshold) -> None:
|
|
372
|
+
async with sem:
|
|
373
|
+
if unreached is not None:
|
|
374
|
+
unreached.discard(key)
|
|
375
|
+
try:
|
|
376
|
+
breach = await _evaluate(client=client, provider=provider, key=key, service=service,
|
|
377
|
+
t=t, state=state, retention=retention,
|
|
378
|
+
retention_check=retention_check, now=now,
|
|
379
|
+
rearm_seconds=rearm_seconds,
|
|
380
|
+
heartbeat_seconds=heartbeat_seconds)
|
|
381
|
+
except Exception as exc: # one bad threshold affects only itself
|
|
382
|
+
_log_once(seen_problems, f"eval:{key}:{type(exc).__name__}", "threshold_eval_failed",
|
|
383
|
+
service=service, key=key, error=f"{type(exc).__name__}: {exc}")
|
|
384
|
+
return
|
|
385
|
+
# No await between the accepted verdict and this append, so a
|
|
386
|
+
# cancellation cannot drop an accepted breach.
|
|
387
|
+
if breach is not None:
|
|
388
|
+
new_breaches.append(breach)
|
|
389
|
+
|
|
390
|
+
# The semaphore admits waiters first-come, so gather's order is the
|
|
391
|
+
# evaluation order.
|
|
392
|
+
await asyncio.gather(*(one(*e) for e in ordered))
|
|
393
|
+
_retire(state, present_services, {key for key, _, _ in entries})
|
|
394
|
+
return list(new_breaches)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _retire(state: dict[str, dict], present_services: set[str], live_keys: set[str]) -> None:
|
|
398
|
+
"""Retire only keys of services present in a successful response whose
|
|
399
|
+
entry is gone. An entry that is not a dict, or has no str service
|
|
400
|
+
(corrupt state), can never match a manifest: drop it rather than let it
|
|
401
|
+
fail every run."""
|
|
402
|
+
for key in [k for k, v in state.items()
|
|
403
|
+
if not isinstance(v, dict) or not isinstance(v.get("service"), str)
|
|
404
|
+
or (v["service"] in present_services and k not in live_keys)]:
|
|
405
|
+
del state[key]
|
|
406
|
+
logger.info("threshold_retired", key=key)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def retire_thresholds(
|
|
410
|
+
*, manifests: list[dict], state: dict[str, dict], seen_problems: set[str],
|
|
411
|
+
renamed: dict[str, str] | None = None,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Garbage collection alone, for measure cycles on which the threshold
|
|
414
|
+
phase does not run: /manifests is fetched every cycle, so a removed
|
|
415
|
+
entry retires (and leaves governance) without waiting for the next
|
|
416
|
+
threshold run. Same rule as the phase: successful response, service
|
|
417
|
+
present, entry gone; and the same re-key migration first (*renamed*
|
|
418
|
+
gets {old_key: new_key}). Parses only; sends no query."""
|
|
419
|
+
present_services, entries = _entries(manifests, seen_problems)
|
|
420
|
+
moved = _migrate(state, entries)
|
|
421
|
+
if renamed is not None:
|
|
422
|
+
renamed.update(moved)
|
|
423
|
+
_retire(state, present_services, {key for key, _, _ in entries})
|