Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/configs/bigbox-v1-cm-2.json
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,10 @@
}
},
"pipelines": {
"component_update_lifecycle": {
"protocol": "http",
"process": []
},
"wann_type": {
"template": "any_change"
},
Expand Down
2 changes: 1 addition & 1 deletion src/services/metrics.lua
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,9 @@ local function handle_metric(msg)
if not metric_name then return end

local pipe_cfg = State.pipelines_map[metric_name]
local payload = msg.payload
if not pipe_cfg then return end -- no matching pipeline, drop silently

local payload = msg.payload
if type(payload) ~= 'table' then return end

local value = payload.value
Expand Down
21 changes: 19 additions & 2 deletions src/services/update/component_watch.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ local fibers = require 'fibers'
local scoped_work = require 'devicecode.support.scoped_work'
local bus_cleanup = require 'devicecode.support.bus_cleanup'
local topics = require 'services.update.topics'
local queue = require 'devicecode.support.queue'

local M = {}

Expand Down Expand Up @@ -45,9 +46,22 @@ local function open_watches(scope, conn, components, queue_len)
return watches, nil
end

local function report_component_fact(params, component, ev)
if not params.events_tx then
return true, nil
end
return queue.try_admit_required(params.events_tx, {
kind = 'component_fact_changed',
component = component,
payload = ev.payload,
origin = ev.origin,
}, 'update_component_fact_changed_admission_failed')
end

local function watch_loop(scope, params)
local observer = assert(params.observer, 'component_watch observer required')
local watches, err = open_watches(scope, assert(params.conn, 'component_watch conn required'), params.components, params.queue_len)
local conn = assert(params.conn, 'component_watch conn required')
local watches, err = open_watches(scope, conn, params.components, params.queue_len)
if not watches then error(err or 'component_watch_open_failed', 0) end

while true do
Expand All @@ -62,7 +76,9 @@ local function watch_loop(scope, params)
return { role = 'update_component_watch', reason = recv_err or 'component_watch_closed' }
end
if ev.op == 'retain' or ev.event == 'retain' or ev.type == 'retain' or ev.kind == 'retain' then
observer:update_component(component, ev.payload, ev.origin)
local ok_update, update_err = observer:update_component(component, ev.payload, ev.origin)
if ok_update == nil then error(update_err or 'component_observer_update_failed', 0) end
report_component_fact(params, component, ev)
elseif ev.op == 'unretain' or ev.event == 'unretain' or ev.type == 'unretain' or ev.kind == 'unretain' then
observer:remove_component(component, 'component_unretained')
end
Expand All @@ -87,6 +103,7 @@ function M.start(scope, params)
observer = params.observer,
components = components,
queue_len = params.queue_len,
events_tx = params.events_tx,
})
end,
report = params.report,
Expand Down
108 changes: 108 additions & 0 deletions src/services/update/lifecycle_metrics.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
-- services/update/lifecycle_metrics.lua
--
-- Small publisher helper for component update lifecycle metrics. The Update
-- service derives lifecycle records from Device component facts, then calls
-- emit() with the phase it wants to publish.

local M = {}

M.METRIC_NAME = 'component_update_lifecycle'

local METRIC_TOPIC = { 'obs', 'v1', 'update', 'metric', M.METRIC_NAME }

-- Metric payload shape consumed by services.metrics:
-- {
-- value = 'started' | 'staged' | 'completed' | 'failed' | 'cancelled',
-- namespace = { '<component>', 'lifecycle', '<job_id>', '<phase>' },
-- job_id = '<original job id>', component = '<component id>', ...
-- }
-- The namespace is later used as a metrics topic suffix, so each segment must
-- avoid separators, whitespace, and dashes.

---Return a string safe to use as one metric namespace segment.
---This is a topic/namespace normaliser, not a security boundary: it preserves
---letters, digits, and '_', replaces every other run with '_', trims edge
---underscores, and falls back to 'unknown' if nothing usable remains.
---@param value any Original component or job identifier.
---@return string token Namespace-safe token.
local function safe_token(value)
local s = tostring(value or '')
s = s:gsub('[^%w_]', '_')
s = s:gsub('_+', '_')
s = s:gsub('^_+', ''):gsub('_+$', '')
if s == '' then return 'unknown' end
return s
end

---@param record table|nil Lifecycle record built from Device component facts.
---@return string|nil component Component id when present and non-empty.
local function component_of(record)
local component = type(record) == 'table' and record.component or nil
if type(component) ~= 'string' or component == '' then return nil end
return component
end

---Build the metric payload expected by services.metrics.
---@param record table Lifecycle record with job_id/component/state/error fields.
---@param phase string Lifecycle phase to publish.
---@param extra table|nil Additional local-observability fields.
---@return table|nil payload Metric payload, or nil on invalid input.
---@return string|nil err Error code when payload is nil.
local function payload(record, phase, extra)
local component = component_of(record)
if not component then return nil, 'component_required' end
if type(phase) ~= 'string' or phase == '' then return nil, 'phase_required' end

local out = {
value = phase,
namespace = { safe_token(component), 'lifecycle', safe_token(record.job_id), safe_token(phase) },
job_id = record.job_id,
component = component,
state = record.state,
error = record.error,
}
for k, v in pairs(extra or {}) do
out[k] = v
end
return out, nil
end

---@param conn table|nil Bus connection fallback, used when no service_base object exists.
---@param svc table|nil service_base-like object with obs_metric().
---@param p table Metric payload from payload().
---@return boolean|nil ok True when published.
---@return string|nil err Error code when no sink is available.
local function publish(conn, svc, p)
if svc and type(svc.obs_metric) == 'function' then
svc:obs_metric(M.METRIC_NAME, p)
return true, nil
end

if conn and type(conn.retain) == 'function' then
conn:retain(METRIC_TOPIC, p)
return true, nil
end

return nil, 'metric_sink_unavailable'
end

---@param value any
---@return string
function M.safe_token(value)
return safe_token(value)
end

---@param conn table|nil
---@param svc table|nil
---@param record table Lifecycle record derived from a Device component fact.
---@param phase string
---@param extra table|nil
---@return boolean|nil ok
---@return string|nil err
function M.emit(conn, svc, record, phase, extra)
local p, err = payload(record, phase, extra)
if not p then return nil, err end
return publish(conn, svc, p)
end

return M
Loading