docs(api): add API conventions, migrations, and README#231
Conversation
Document API conventions including RSQL query syntax, bulk operations, jobs/batch definitions, and migration notes under dev-docs/api/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
Overall the v1 direction here is solid: plural nouns, correct method semantics, RFC 9457 errors, split request/response schemas, jobs for async work, RSQL replacing the old boolean-DSL magic chars. All good moves, agree with merging that as the baseline. Where I think this needs another pass is the batch/bulk section, specifically re how it's meant to actually serve RecCeiver. batch vs reconcile
Proposal: add an actual Reconcile doesn't get to skip jobs/per-item results just because it's declarative. Still large-N (thousands of channels per IOC per our own README numbers), still partially-failable, and a failed item must not get tombstoned (it didn't disappear, it just failed validation), so reconcile needs per-item results plus sync-under-threshold/async-over-threshold, same machinery batch has today, just wrapped around a "desired state" body instead of an op-list. generic (non-reconcile) bulk, leaving this open Once reconcile absorbs RecCeiver: is there still a real need for generic bulk create/update/delete (admin tooling, migration scripts, whatever)? If yes, and it's small-N/interactive (our own actor table already has admin/curator as "occasional, deliberate"), I don't think that needs jobs or per-item results at all. Validate-then-write, atomic, single response and status. Simpler for callers than today's 200-with-embedded-422s. If there's actually a consumer doing large/failure-prone homogeneous bulk writes outside RecCeiver, that path needs per-item handling too, same as reconcile, just without tombstoning. Need to know if that consumer exists before deciding. 200 vs 207 vs job, the doc contradicts itself here Hard rules say "never 200 with an error body." Async section says "never... a 207 Multi-Status." Sync "mirrors ES Non-atomicity and the 200-status choice for batch are both justified by "follows Elasticsearch native concepts needed for reconcile For tombstoning to be safe I think we need, minimum:
Don't think we need IOC / RecCeiver / session to become their own CF resources though, scope just needs to be whatever unit appears/disappears together. RecCeiver owns getting that scoping right. CF just needs the opaque key plus generation guard. one more thing this forces us to decide now, not later: can one channel have multiple hosts? Separately from all of the above, PVs can be hosted by more than one IOC at once. Today CF pretends this can't happen, so when more than one recceiver/IOC reports the same name, whichever write lands last just silently clobbers the previous metadata. No visibility that it happened. This lands directly on the I think existence and metadata split cleanly here. Existence can aggregate now: channel exists if ≥1 source claims it, tombstone only when the last claim drops, that's just reference counting, solvable today. Metadata conflict is harder: two sources reporting different properties/tags for the same name doesn't aggregate cleanly, and I don't think we need to solve that now (whether we warn, reject, or just surface both isn't decided). But the identity question, keyed by name alone vs. some (name, ioc_id)-style composite, exact keying TBD, needs to at least be flagged as open now, because retrofitting single-owner -> multi-claim later is a lot more expensive than leaving room for it in the reconcile primitive from the start. |
I can swap to reconcile.
I still don't understand tombstoning, is this a feature inside of channelfinder? Or is this a client is pushing some extra metadata? Can you give an example of using just the normal update a single channel example?
urgl, I don't particularly want to tackle this as it requires doing an elastic migration. The channel name is the key in the channels 'table'. Adding a source_id (which I think would have to be Which completely changes the scope of this PR. Adding support for a source_id, which I think is pretty easily added on top instead, we modify the Channel Response and Request object with an optional field source_id. This would not break a v1 api. I think it does not need to be added now for this reason. |
| - **properties** — a name-value definition (`name`, `owner`) attachable to channels with a per-channel `value`. | ||
| - **tags** — a name-only label (`name`, `owner`) attachable to channels. | ||
|
|
||
| Two operational resources sit alongside the domain — addressable resources that |
There was a problem hiding this comment.
Typo: shouldn't this be domain-addressable?
|
|
||
| Access is split by HTTP method, not by path: | ||
|
|
||
| - **Reads (`GET`) are unauthenticated.** |
There was a problem hiding this comment.
Should this perhaps be optional? Given that we seem to be moving towards all services requiring authentication here at ESS, should we not consider this configurable?
|
|
||
| ## Naming | ||
|
|
||
| - **Lowercase kebab-case** for literal path segments — `/channels`, `/channels/{channel_name}/properties`. |
There was a problem hiding this comment.
This is a bad example. There are no kebabs here.
| Channel search uses plain, named, documented query parameters: | ||
|
|
||
| - Name glob, page size, and sort: `GET /channels?name=SR*&size=50&sort=channel_name:asc`. | ||
| - Tag presence and property values are named parameters too — `tag=active`, `cell=2`. |
There was a problem hiding this comment.
There is some ambiguity here: What if I have a property named tag?
|
|
||
| ```jsonc | ||
| { "results": [ | ||
| { "name": "SR:C01", "method": "post", "status": 201 }, |
There was a problem hiding this comment.
If we go this direction (I share @anderslindho 's concerns) shouldn't this return the entire object instead of just a 201?
| "patch": [ { "name": "SR:C03", "properties": { "cell": "3" } } ], | ||
| "delete": [ "SR:C99" ] | ||
| } | ||
| ``` |
There was a problem hiding this comment.
What is the use case for this instead of allowing
POST /channels:batch
{
[ { "name": "SR:C01", "properties": { "cell": "1" }, "tags": ["active"] } ]
}vs
PUT /channels:batch
{
[ { "name": "SR:C02", "properties": { "cell": "2" }, "tags": [] } ]
}etc.? Why do we need to create, update, update, and delete all at once?
There was a problem hiding this comment.
Well, my thought was that recceiver would be the one deleting channels, but this is really the crux between 'resolve' vs 'batch'. Does the recceiver calculate the sync or does channelfinder.
| addressable resource, not a hidden session id. Jobs are retained for a bounded | ||
| window, then reaped. |
There was a problem hiding this comment.
Do we need to reap these? I think of awx where the jobs are there in perpetuity.
That said, maybe we don't care to revisit job logs years after the fact 🙃
There was a problem hiding this comment.
Process would run after every single ioc restart. So thats a lot of jobs.
There was a problem hiding this comment.
Eh... it's not like we will run out of integers. And looking at other utilties, we have typically one or more jobs per IOC start, stop, deploy, undeploy, which is of the same order....
| **Idempotent submission.** A submission may carry an **`Idempotency-Key`** header; | ||
| a repeat with the same key returns the same job instead of starting a second. |
There was a problem hiding this comment.
Depends how long you keep the jobs for!
From what I read (e.g. https://en.wikipedia.org/wiki/Tombstone_(data_store)) it seems not obviously relevant in our case as we do not have a distributed database to deal with, but perhaps it is the correct approach regardless? |
ChannelFinder - recCeiver always just pushes what it has. Else it needs state/db. Good that you're up for swapping to reconcile. @simon-ess's "why do we need to create, update, update, and delete all at once?" on the batch example is the same question from the other end I think.
TBH I am not 100% sure if it is the correct term, but I think it covers it. What I mean by it is that CF alone is responsible for changing channels from active to inactive. So my intention is basically soft delete + retention/purge policy, and the important part is who owns that (CF). To answer the either/or directly: today it is a client pushing extra metadata. That's Example Say Today: recCeiver reads CF, works out that Proposed: recCeiver sends its current set for its scope,
I think we should separate API design from business logic design. What I think is important is that the API already now accommodates for this design. How exactly that is implemented and/or how dbs are migrated to support it is a separate issue. Not asking for the migration, and definitely not in this PR. But "add it on top later" only holds for the field, not for the semantics. Schema-wise you're right, optional And it's not really future work, it's load-bearing for reconcile being correct. With a single Doesn't mean solving it now. But reconcile's rule needs to be "deactivate when nothing claims it" rather than "deactivate when the recorded source dropped it", and v1 can implement that with one claim per channel as a documented limitation. Free to write down now, expensive once clients have built on one-source-per-channel being the actual rule. |
During the meeting we agreed that I will be adding a "preprocessor" SPI similar to the "processor" one that exists. So each site can add their own validation for channels... one of these can be a validator that detects if existing/active channels are there in channelfinder which have different hosts, it can then log or notify the appropriate entities to resolve the issue. I don't think we should allow multiple properties with the same name. |
|
Overall I really like this. RSQL queries would be great. I'm also thinking more about the duplicate PV names. I agree with @anderslindho that it is good to think about even if we don't address/support it fully in v1. I'm not saying this is practical but if PV name is the key and a PV can have multiple {
"name": "SR12C:SQSF1:Setpoint",
"properties": {
"Acc": "SR",
"Family": "SQSF",
"Field": "Setpoint",
"Sector": "12",
"Device": "1",
"Position": "187.277343",
"hostName": "ioc-host-a.example.local",
"iocName": "skewquads2",
"iocid": "192.168.20.11:45334",
"iocIP": "192.168.20.11",
"time": "2026-07-15 09:12:00.123456",
"caPort": "45502",
"recordType": "ao",
"recordDesc": "Current setpoint",
"archive": "Slow",
"Engineer": "jdoe",
"Location": "SoftIOC"
},
"pvStatus": "Active",
"conflict": {
"duplicateActiveSources": true,
"activeSourceIds": [
"recceiver-1.example.local:192.168.20.11:45334",
"recceiver-2.example.local:192.168.20.12:52110"
],
"note": "flattened properties above reflect one of these sources arbitrarily; do not treat as authoritative until resolved"
},
"tags": ["MML", "PS"],
"sources": [
{
"source_id": "recceiver-1.example.local:192.168.20.11:45334",
"pvStatus": "Active",
"last_seen": "2026-07-15T09:12:00Z",
"properties": {
"recceiverID": "recceiver-1.example.local",
"iocName": "skewquads2",
"hostName": "ioc-host-a.example.local",
"iocid": "192.168.20.11:45334",
"iocIP": "192.168.20.11",
"time": "2026-07-15 09:12:00.123456",
"caPort": "45502",
"recordType": "ao",
"recordDesc": "Current setpoint",
"archive": "Slow",
"Engineer": "jdoe",
"Location": "SoftIOC"
}
},
{
"source_id": "recceiver-2.example.local:192.168.20.12:52110",
"pvStatus": "Active",
"last_seen": "2026-07-15T09:10:45Z",
"properties": {
"recceiverID": "recceiver-2.example.local",
"iocName": "skewquads2-test",
"hostName": "ioc-host-b.example.local",
"iocid": "192.168.20.12:52110",
"iocIP": "192.168.20.12",
"time": "2026-07-15 09:10:45.987654",
"caPort": "45519",
"recordType": "ao",
"recordDesc": "Current setpoint (test IOC)",
"archive": "Slow",
"Engineer": "jdoe",
"Location": "SoftIOC"
}
},
{
"source_id": "recceiver-1.example.local:192.168.20.13:38821",
"pvStatus": "Inactive",
"last_seen": "2026-06-20T14:22:03Z",
"properties": {
"recceiverID": "recceiver-1.example.local",
"iocName": "skewquads2",
"hostName": "ioc-host-old.example.local",
"iocid": "192.168.20.13:38821",
"iocIP": "192.168.20.13",
"time": "2026-06-20 14:22:03.456789",
"caPort": "45506",
"recordType": "ao",
"recordDesc": "Current setpoint",
"archive": "Slow",
"Engineer": "jdoe",
"Location": "SoftIOC"
}
}
]
}Each source in this case is an IOC connection to a specific recceiver instance and includes all the properties that IOC reported. The top level properties is the flattened view for normal clients and includes properties manually added by non-recceiver clients. The top level pvStatus is an OR of all the source pvStatus fields and there is a conflict flag in case there are multiple active sources. There are some problems with this idea (including an IOC restart adding a new source every time) but throwing it out there to brainstorm. |
|
oh that json blob reminded me of an old complaint about CF here: all the property values are strings so things like position in the example above are hard to sort on unless you make all positions the same number of characters. We added leading zeros in those cases. Not sure if it can be addressed or makes sense but did come up here |



Document API conventions including RSQL query syntax, bulk operations, jobs/batch definitions, and migration notes under dev-docs/api/.