HTTP reference
Where to look a thing up: every route, the filters of the query string, the headers that hold a write or mark a read, the statuses a refusal answers with, subscriptions, polling and the change stream. A first request is on HTTP endpoint; tokens and policies are on HTTP security and scoped access.
Routes
GET / version and collection list
GET /collections schemas
GET /<name>?<filter> rows
GET /<name>?<filter>&count number of matching rows
GET /<name>?select=status,count(*),sum(total)&group=status&order=sum(total).desc
aggregates, whole or per group
GET /<name>?<filter>&facet=brand%20top%205,color
rows, and each value's count beside them
POST /<name> body: {...} or [{...}] -- an id that is taken: 409
POST /<name>?returning=id,title the rows made, ids and all (* every field), not their count
PATCH /<name>?<filter> body: {...}
DELETE /<name>?<filter>
PATCH /<name>/all no filter — deliberately explicit
DELETE /<name>/all
POST /<name>/near body: {"vector":[...], "limit":10}
GET /<name>/changes?since=N subscription (SSE): shape plus incremental diff
POST /query body: {"query":"<FenecQL>","params":[...]}
POST /batch one query body per line (NDJSON)
GET /_metrics counters and gauges, Prometheus's text format
GET /_changes?since=N every write on disk after N, a JSON line each (--cdc)
GET /_changes?consumer=C from where consumer C committed
POST /_changes/consumers/C make C (201), at {"since": N} or the last write; one already there stays put (200; a since ahead of it: 409)
PATCH /_changes/consumers/C body: {"since": N} -- move C: commit what it read, or go back
GET /_changes/consumers each consumer, where it is and how far behind
DELETE /_changes/consumers/C
GET /_whoami who the server takes the token for, and the rules that apply to it
GET /_schema the schema as a description (Schema in code); ?as=fenecql: as its statements
POST /_schema/plan body: a description -- what an apply would do; ?mode=follow: what the server lacks
POST /_schema/apply body: a description -- migrations, then what only adds; the server's token
POST /mcp MCP for agents (--mcp): one JSON-RPC message a requestThe _replication, _schema and _metrics routes
With --replication-token, /_replication is taken
as well: the stream a replica is fed, its status, and a replica's promotion —
see Replication. It asks for that token
rather than --http-token, and a collection named
_replication is then out of HTTP's reach.
/_schema compares a schema declared in code with the
server's, in the engine every SDK's check goes through — see Schema in code. Its body is the
description, {"format": 1, "fenecql": "create collection ...",
"migrations": [...]} or its collections as JSON. A plan answers
{"applied", "ran", "migrations", "statements", "refusals"}, and
writes nothing; with ?mode=follow it says only what the code
declares and the server lacks, which a scoped token may ask of the
collections it reads. An apply takes the server's token, runs the
migrations not yet recorded and the additions under the write lock as one
block, and answers 409 with the refusals when anything is refused, having
written nothing.
With --mcp, /_mcp is the Model Context
Protocol's endpoint: an agent's schema, search,
query and get tools, and write and a
memory where asked for, each a statement held to the request's token as
/query is — see Agents and MCP.
Without the flag it answers 404.
/_metrics is the server's statements, latency and data in
Prometheus's text format, readable with --http-token or the
admin token — see Monitoring. The underscore
keeps it out of the way of a collection called metrics; a
collection called _metrics cannot be read over
GET.
Filters
Filter in the query string as field=operator.value, for example
?year=gte.2024. For anything the pattern cannot say, use
?where= with FenecQL.
| Operators | eq neq lt lte gt gte like has in is |
| Negation | the not. prefix — ?summary=not.is.null, ?year=not.eq.1999 |
| Shorthand | an unrecognised prefix makes the whole value an equality: ?year=2024 |
| List | ?year=in.(1999,2023) |
| Paths | a path into a json field where a field goes: ?meta.lang=tr, ?meta.source.rank=gte.2, ?select=title,meta.source.site, ?order=meta.source.rank.desc; the value is read as JSON reads it — a number, true, null, a quoted string — and as the text it is otherwise. A body's object is a json field's value, every number in it as written, and PATCH with {"meta.lang": "en"} sets one key inside the field (Objects and paths) |
| Clauses | select order limit offset count where lookup |
| Ordering | ?order=year.desc,title.asc; a collation among the modifiers puts text in its order — ?order=name.und.desc is order name collate und desc, name.tr Turkish (Collation) |
| Free expression | ?where=year >= 2024 and tags has "rust" — full FenecQL |
How a filter value is typed
Values are parsed by the field's type: ?year=gte.2024 is an
int, ?published=gte.2024-01-01 is a timestamp. An unknown field
returns 404. where= exists for what the pattern cannot hold —
or groups and function calls.
Vector search
Send the vector in a POST body to
/<name>/near, with an optional filter. Add
"match" to mix in keyword search.
curl -X POST http://127.0.0.1:8080/articles/near -d '{
"vector": [0.1, 0.2, 0.3],
"ef": 128, "limit": 5,
"select": ["title"],
"where": "year >= 2024"
}'
# [{"title":"the rust book","_score":0.97}]fieldcan be left out when the collection has one vector field.- Query-string filters are combined with the body's
where.
Why near is a POST
near is a POST rather than a query parameter
because a 768-dimension embedding does not fit in a query string. Squeezing it
into the URL — base64, truncation — is unreadable and runs into proxy and
server URL ceilings. PostgREST's model does not cover fenecdb's headline
feature, so near gets its own endpoint, and it takes a filter in
the body.
If the collection has a single vector field, field can be left
out. Filters in the query string are anded with the
where in the body.
A vector in an answer, and a _score, is its f32s
written as the shortest decimals that read back as them — 0.1
as it was written, the text pgvector gives — rather than the
0.10000000149011612 of the f64 each widens to. A
reader that takes a number as an f64 and rounds it to
f32, as a Float32Array does, gets every one back:
the one f32 an f64 would round away,
7.038531e-26, is written as its f64.
A "match" makes it hybrid: BM25 over the collection's
@text field ranks the same request, and the two rankings are
fused by reciprocal rank (fuse).
match_field names the text field when there are several;
match_fuzzy (true, or 0 to 2 edits) and
match_prefix read its words as match ... fuzzy and
prefix do; fuse_k and candidates
are the rank offset and how many candidates each side ranks.
curl -X POST http://127.0.0.1:8080/articles/near -d '{
"vector": [0.1, 0.2, 0.3],
"match": "borrow checker lifetimes",
"limit": 10
}'Embedding a collection
Use lookup to nest matching rows of another collection under
each row, such as each product's latest reviews.
curl 'http://127.0.0.1:8080/products?limit=20&select=name\
&lookup=reviews&reviews.on=product_id&reviews.stars=gte.4\
&reviews.order=created.desc&reviews.limit=3'
# [{"name":"Kahve","reviews":[{...},{...},{...}]}, ...]- Prefix the child's options with its name:
reviews.on,reviews.limit. - Chain levels with a comma:
lookup=orders,lines. - A key over several fields lists a field of each side:
lines.on=org,order&lines.parent=org,id, FenecQL'son (org, order) = (org, id). - A level that folds aggregates in its
select, as the query's own does:orders.select=count(*),sum(total), each parent's children folded, andorders.group=statusa row a group, itsordernaming columns (orders.order=count(*).desc). Off a query's groups,<name>.parentnames a column of its list:/orders?select=courier,count(*)&group=courier&lookup=couriers&couriers.on=user&couriers.parent=courier.
How lookup parameters are prefixed
lookup=<name> names the collection once; every key prefixed
with it configures the clause — on (required, the child's field
holding the key), parent, select,
order, limit, offset,
required, where, and any field name as a condition,
in the same op.value form the parent's filters use. Prefixing is
what keeps the two sides apart in a query string, the way the clause's
position does in FenecQL.
curl 'http://127.0.0.1:8080/shops?limit=20&select=name\
&lookup=orders,lines&orders.on=shop_id&orders.limit=3\
&lines.on=order_id&lines.limit=5'
# [{"name":"Merkez","orders":[{"code":"A","lines":[{...}]}, ...]}, ...]A comma-separated list chains, read left to right, each name binding to the one before it and each level keeping its own prefix. A query string has no position to scope by, so the order is written out; the meaning is the same one FenecQL gets from position, up to the same 8 levels.
lookup is a reserved key here, so a field named
lookup cannot be filtered over the REST path — the FenecQL and
fenec-server paths are unaffected, as with the other reserved
names.
A query with a lookup answers with the children nested under
the collection's name — one object per parent, with an empty array where
nothing matched, and a chain nests all the way down.
curl -X POST http://127.0.0.1:8080/query \
-H 'content-type: application/json' \
-d '{"query":"get products limit 20 lookup reviews on product_id limit 3"}'
# [{"id":1,"name":"Kahve","reviews":[{"id":1,"stars":5,"body":"guzel"}]}, ...]Holding a write until it can write
Send Fenec-Wait: <ms> with a set or a
del on /query or /batch, and while it
would write nothing the server holds it, answering the moment it writes —
or at the end of the wait, 30 seconds at most, as it would have answered
at once. A job queue's claim waits for a job that way (Waiting for a job).
curl -X POST http://127.0.0.1:8080/query -H 'Fenec-Wait: 30000' \
-d '{"query":"del tasks where run_at <= now() order run_at limit 1 returning *"}'- What wakes it: a write to a collection it reads, and the time
reaching a row's moment — a field compared with
now(), a@ttl. A filter that reads the time any other way is refused (400), as is a request holding aput. - Wrote nothing means no row written, or a
requirenot met; any other refusal is answered at once. - With
Fenec-Afterthe header is that wait's, as below, and holds no write.
Marking a read
Send Fenec-Read: replica with a request that only reads, and
a router standing in a region where the tenant has a read replica answers it
there rather than from the tenant's home (Read replicas); with
Fenec-After and Fenec-History, as an answer from home
named them, it is answered from nothing older than that change of that
history (Reading your own
writes), a subscription and a /_changes page as much as
a read: each waits for it before its head. Every server runs a marked
request read-only on every route —
home, a replica, a single file; a statement, REST, /batch, an
MCP tool, a change-stream consumer's commit — so a write sent with the mark
is refused with 403 and words that say why, never landed; a
value but replica is a 400. A copy that has not
caught up with its primary yet answers it 503.
curl -X POST https://ap.example.com/t/acme/query -H 'Fenec-Read: replica' \
-H 'Fenec-After: 1043' -H 'Fenec-History: 2-6c1f0a9e44d7b2c8' \
-d '{"query":"get orders where customer = $1","params":["c-7"]}'The data ceiling
With --max-memory, writes that would grow the data past the
limit are refused with 507. Reads, deletes and
compact still work.
What --max-memory refuses and allows
Inside fenec-server started with --max-memory, the
listener holds its writes to that ceiling: above
it, a statement that grows the data — a put, a patch, a create
index — is refused with 507, a batch stops there as at an
error, and reads, deletes and compact go on, since they are the
way out. The rule and why it is there are under
Server.
Refusals and their statuses
A refusal is a status and {"error": "..."}. The status says
what to do about it; a /batch that stopped adds where
(at) and what stayed applied (completed), a
tenant's quota names itself (quota, and the
Fenec-Quota header), and one to come back from says when
(retry_after, the seconds of its Retry-After
header: a page on another origin, or an app's replica, reads the body; a
quota's 429 says it to the millisecond as well, retry_after_ms,
which is the one to wait). A write whose outcome the server cannot know
says so ("outcome": "unknown", a 500), with where it left the
database in the body (seq, history) rather than in
Fenec-Seq, which a client takes as its own position. Every
SDK's error carries the rest; the outcome and its position are the Go,
.NET, JavaScript and Python clients' (Go's and .NET's code
outcome_unknown, JavaScript's and Python's
outcome).
| Status | Means | What to do |
|---|---|---|
| 400 | a statement that does not parse or run, a bad parameter | fix the request |
| 401 | no token, or a wrong one | send the right token |
| 403 | outside the token's rules — a row, a field, a change no transition names, a returning row it may not read, the write put back whole — a write to a replica or a read-only server, or one marked Fenec-Read | write where it is taken |
| 404 | a collection, field or tenant that is not there | |
| 409 | an id or a @unique value taken, a collection that exists | a lost race for a value |
| 410 | a /_changes cursor the server no longer reaches | start again from the since it names |
| 412 | a require not met, its batch put back | read again, decide again |
| 413 | a body past the ceiling, 64 MB | send less at once |
| 422 | an Idempotency-Key sent with another request | a new key for a new request |
| 429 | a tenant's quota of streams, held, requests, writes or write_bytes | come back after retry_after_ms |
| 500 | outcome unknown ("outcome": "unknown"): a write a tenant's node landed and answered after its lease from the router lapsed — its tenant may have been failed over without it, or still be there with it; a /batch that ran a statement at a time says how far it got, its seq the last landed statement's | send it again with the same Idempotency-Key; without one, wait a lease and a tenth (--auto-failover × 1.1) after the answer — until the tenant settles the node answers reads with the write in them, one sent with Fenec-After too — then read it back, or send a read with Fenec-After and Fenec-History set to its seq and history, which a primary without it answers 504 (for a batch, a 504 does not say which earlier statements survived: read those back) |
| 500 | an fsync that failed under --sync always: the write may or may not be on the disk, and the server takes no write until its file is opened again | restart the server, then read the write back, or send it again with the same Idempotency-Key |
| 503 | too many connections or subscriptions; a connection the server could make no thread for, at its thread limit; a tenant being moved or failed over; a node going down. One written as the connection was taken, before the request was read, says so in Fenec-Unserved (ceiling or no_thread): the request never ran | come back after Retry-After seconds |
| 504 | a Fenec-After change, sent with a read, a subscription or a /_changes page, that did not reach a replica in the wait, or one a primary's history does not hold — lost in a failover; seq and history say where it stands | ask again, or ask the primary; take the primary's position as yours |
| 507 | past --max-memory, or a tenant's quota of bytes or rows; the write put back whole | delete, then write again: waiting does not help |
HTTP/1.1 429 Too Many Requests
Fenec-Quota: writes
Retry-After: 1
{"error":"the tenant writes past its quota of 100 rows a second","quota":"writes","retry_after":1,"retry_after_ms":10}
HTTP/1.1 503 Service Unavailable
Retry-After: 1
{"error":"the tenant is being moved; retry shortly","retry_after":1}
HTTP/1.1 500 Internal Server Error
{"error":"the write landed on this node, but by the time it was answered ...","outcome":"unknown","seq":4182,"history":"2-6d1f0c2a9b3e4471"}A quota is a tenant node's (fenec-server --dir): what each
bounds is under Sharding:
reference, how it is set under
Sharding: tenants and why the statuses
are these under Sharding:
design. 507 is the storage
the request needs and the tenant has not got — the request is fine, and a
smaller one may fit, so not 413, which is about the body; 429 is a rate or
a share the tenant comes back within.
Subscriptions
GET /<name>/changes is a server-sent events stream: first
the rows matching your filter, then each change to them as it happens.
curl -N 'http://127.0.0.1:8080/articles/changes?year=gte.2024'
# event: seed
# data: {"seq":12,"rows":[...]}- Limited separately from ordinary requests:
--http-max-streams, 64 by default, a database's -- on a tenant node each tenant's own -- and each one of--max-connectionstoo, which a node's tenants share (a tenant's share). - A row reaching its
@ttlis told as a deletion at its time, as a write would be, not when the sweep deletes it a minute and its grace later. - Sent after a write (
Fenec-After, andFenec-Historyas the write's answer named it), a stream waits for it before its head, as a read does: its seed holds the write, or it is answered504with where the server stands (Reading your own writes). One resumed withsincewaits the same way:sinceis where the stream left off, not a write it must see. - Its head says where the server stood as it opened:
Fenec-Seq, the change its seed is at or after, andFenec-History, the history that change and every event'sseqare of, as a write's answer names it (none on a database that never had a replica). A read sent after an event'sseqto another copy goes with both (Reading your own writes): a number alone names no write across a failover.FenecHttp.liveon a client reading near sends its runs so. - In the browser, Sync builds on this stream.
Why a subscription does not poll
GET /<name>/changes is a server-sent events stream: a seed
carrying the current shape, then incremental diffs. No driver needed.
A subscription does not poll. fenec-core has no waiting
primitive at all — there are no threads in wasm — it only reports that the
change counter has reached a point, and fenec-http ties that to a
Condvar. A 50 ms polling loop would take 20 read locks per second
per subscriber and still add 50 ms of latency.
Every subscription is a connection and a thread, so they are counted
separately from ordinary requests as well (--http-max-streams,
default 64, a database's: on a tenant node each tenant's). Sharing one
ceiling would let 100 subscribers shut the server to plain requests. A
stream is also one of the node's --max-connections, the bound
every tenant of a node shares, of which each tenant's streams take a quarter
at most unless its quota says otherwise. The client-side layer built on this
stream is Sync.
A row past its @ttl leaves every read at once and the file at
the sweep, a minute and its grace later; a stream of its collection is told
of it at its time. It works out when the next row of the collection reaches
its time -- a step of the field's ordered index, again after each write to
the collection -- sleeps until then if no write comes first, and reads the
rows that reached it since it last looked, the shape ANDed in, telling each
as a deletion as the sweep's delete would be told: a scoped stream of the
rows it was sent alone, a shape that holds nothing (what
FenecHttp.live opens) that something changed. Heard at the
sweep, a courier whose phone went quiet stayed on a dispatcher's map 73 s
after it had gone from every read; at its time, it is told about 10 ms after
it. The sweep's delete comes after, a deletion of what is gone already.
What a stream costs, measured on an M1 with streams of a shape that holds
nothing (what FenecHttp.live opens): 45 to 57 KB of resident
memory and a thread each, and every write wakes every stream, which reads
what it changed: a write's p50 went from 0.26 ms with none to 0.94 with 60
and 3.1 with 500. A page thousands view should not hold one a viewer; it
polls (below).
Under a scoped token, a stream of a shape that holds nothing
(where=false) is told of the writes to rows the token may
read, and of nothing else: the server follows the token's own filter, keeps
the ids it selects and sends none, and a write to one of them, or one of
them leaving the token's rows, is a change holding no rows.
ANDed with the token's filter, the shape matched nothing and
FenecHttp.live never ran again; told of every write, the
stream would hand the token the moments of other users' writes.
Polling a read
Send If-None-Match with a /query read and the
answer carries an ETag: the change the database stood at. Sent
back, the tag is answered 304, with no body and the query not
run, while no write has touched a collection the read reads.
curl -si http://127.0.0.1:8080/query -H 'If-None-Match: "0"' \
-d '{"query":"get inventory select available where sku = $1","params":["TENT-1"]}'
# HTTP/1.1 200 OK
# ETag: "48211"
curl -si http://127.0.0.1:8080/query -H 'If-None-Match: "48211"' -d '...the same...'
# HTTP/1.1 304 Not Modified- Nothing is held between polls: no stream, no thread, no wake-up
at a write.
FenecHttp.live(query, cb, { poll: 5000 })polls so. - Untagged, and run each time: a read of a collection whose rows
expire (
@ttl), one callingnow(), one holding an innerget— answers that move with no write. - A scoped token's tag is its answer's (
"c…", a digest of the rows): its query runs every time and is answered 304 when the rows are the ones it had. Tagged by the change counter, 304 against 200 -- and a 304's speed -- told it when anyone wrote to what it reads, other users' rows among them, and the tag how many writes the database had taken.
How a 304 is decided, and timed
Whether a collection was written since a tag is the change ring's answer
(Database::changed_collections_since): a tag older than the ring
reaches, or a collection dropped since, counts as a change and the query runs.
A read without the header is answered as before, no tag worked out. Over one
keep-alive connection a 304 took 54 us at the median, against 55 for a read
by key and 95 for a page of 24 run; the round trip is most of either. A
browser keeps its connection between polls, so a public page polls through
the site's own server or a proxy, which keeps few connections to
fenec-server however many viewers it has.
A tag is a change of the copy that answered. A read replica holding that
change of its history (Fenec-After) judges it as home does, but
only up to where it stands: behind home it answers 304 to a write home holds,
so FenecHttp.live polls home on a client reading near (Read replicas).
Change data capture
To see every write exactly as it happened — to feed a search index, Kafka
or a read model — start the server with --cdc and read
GET /_changes: one JSON line per write.
fenec-server --file data.fenec --http 127.0.0.1:8080 --sync 250 --cdc
curl -i 'http://127.0.0.1:8080/_changes?since=0&limit=1000&wait=10000'
# Content-Type: application/x-ndjson
# Fenec-Next: 5
# Fenec-Seq: 5
#
# {"seq":1,"at":1790784252138,"collection":"a","op":"create"}
# {"seq":2,"at":1790784252158,"collection":"a","op":"put","id":1,"doc":{"id":1,"n":1,"t":"x"}}
# {"seq":3,"at":1790784252158,"collection":"a","op":"put","id":2,"doc":{"id":2,"n":2,"t":"y"}}
# {"seq":4,"at":1790784252173,"collection":"a","op":"put","id":1,"doc":{"id":1,"n":1,"t":"z"}}
# {"seq":5,"at":1790784252187,"collection":"a","op":"del","id":2}- Send
Fenec-Nextback as the nextsinceand nothing is missed or seen twice. - Only writes already on disk are handed out.
Fenec-Seqis the last write the database holds: whenFenec-Nexthas reached it, you have every write.- Sent with
Fenec-After, a page waits until the server holds that change, as a read does, and itsFenec-Seqis never behind it; a write not yet on the server's disk comes on a later page.
How /_changes pages through the writes on disk
A subscription keeps a query's rows up to date, and a client that falls
behind its ring of ids is sent the rows again. A service that must see every
write — to index it elsewhere, send it on to Kafka, or keep a read model —
reads GET /_changes instead: the writes on disk, documents and
all, one JSON object a line, from any write the server still keeps.
seqis the change counter's number for the write, andFenec-Nextthe last one in the answer: sent back as the nextsince, nothing is missed or had twice. Keep it where your consumer keeps its own state, and commit the two together.Fenec-Seqon every answer is the last write the database holds, on disk or not yet, the writes the stream leaves out (a consumer's cursor, an idempotency key) among them. An answer whoseFenec-Nextis that has every write there is: a worker that catches up before it serves knows it at once, where an empty page alone could not tell it from one whose writes are not on disk yet under--sync 250, and waited out pages to be sure. AFenec-Nextbehind it has writes to come: read again withwait. A client that notesFenec-SeqforFenec-Afterreads a replica from there on.- A batch's writes are one record on disk and come together, numbered one by one; a cursor inside one goes on after the write it names.
- Only what an fsync has covered is handed over, as a replica is fed: a write the server lost in a crash never reached a consumer.
sinceleft out is the last write on disk: what is written from now on.limitis 1 000 unless given, 10 000 at most;waitis how long an answer with nothing in it waits for a write first, in milliseconds, 30 000 at most.- The server keeps as many writes as
--replication-bufferholds (64 MiB unless given), written to disk or not yet. A cursor further back is answered 410 with the firstsinceit does serve — which may be a write not on disk yet, whose read waits for it — never with writes missing from the middle; one past the last write — another database's cursor, or this one restored — 409. The feed starts at the change counter when the server starts. opisput,del,create,alterordrop; a put carries the document as it was written, as JSON writes a row.- It needs
--http-tokenor no token at all: a scoped token's filter could not hold back the deletion of a row it never saw, and is refused (403).
--cdc keeps the feed a primary keeps for its replicas and
feeds none; with --replication-token it is kept already, and a
tenant node's tenants have theirs at /t/<tenant>/_changes.
It needs --file and a --sync other than
off. Keeping it cost nothing measurable: 16 400 single puts a
second over HTTP either way, and 9 000 rows of 128 dimensions read back at
283 000 a second.
Consumers
A consumer with no storage of its own can let the server remember its place, like a Kafka consumer group: create it, read from it, then commit how far it got.
curl -X POST http://127.0.0.1:8080/_changes/consumers/search-index
curl -i 'http://127.0.0.1:8080/_changes?consumer=search-index&wait=10000'
# Fenec-Next: 42 -- index the lines, then:
curl -X PATCH http://127.0.0.1:8080/_changes/consumers/search-index -d '{"since": 42}'- At least once: a crash between reading and committing means those writes come again.
- Safe to create at every start: a
POSTcreates a consumer that is not there (201) and leaves one that is where it stands (200, with itssince), so a relay that creates its consumer each time it starts goes on from where it stopped. - Moved only by a
PATCH: forward to commit, or back to read again. APOSTwhosesinceis ahead of an existing consumer is a commit sent the old way, and is refused with 409 naming where the consumer stands.
How a stored consumer cursor works
A consumer that keeps no state of its own can have the server keep where
it is, as a Kafka consumer group keeps its committed offset. Made with a
POST — at the last write on disk unless the body names a
since — it reads with ?consumer= from where it
stands, and moves with a PATCH of since once it
has done with what it read, or back to a since it wants to
read from again.
A POST to a consumer that is there already changes nothing
and answers 200 with where it stands; only a consumer it makes is 201. The
since in its body is where a new consumer starts, as
Kafka's auto.offset.reset applies only to a group with no
committed offset, so a program can make its consumer with
{"since": 0} every time it starts and still go on from its
last commit. A since ahead of where an existing consumer
stands is 409, {"error": …, "since": <where it stands>}:
it is a commit sent as a POST, and answered 200 its client
would read the same page forever. A program that must know it made the
consumer reads the status. A PATCH of a consumer that is not
there is 404 rather than making it: the POST is the one way to
make one. Both answer with where the consumer stands afterwards and with
Fenec-Seq, as a write does, so a read sent after it
(Fenec-After), to a replica too, finds it there. A commit past
nothing but other commits writes nothing and leaves the consumer where it
was, which reads the same writes; its answer says so. A
since past the last write the database holds is 409 for
either.
So a consumer has each write at least once, and a write again after a
crash between reading it and committing. The cursors are rows of a
_consumers collection, written as any write is — on disk
before the answer under --sync always, and on the replicas —
and their own writes are left out of the stream, the cursor going past
them. A replica reads from where the primary's consumer stands, refuses to
make, move or delete one (403), and once promoted goes on from the same
place. On a tenant node they are the tenant's writes: while it is being
moved, or its node's lease is out, each is answered 503 to come back. A
read with wait waits on past them too, so a consumer's
own commit does not wake its next read, and a commit past nothing but
cursors writes nothing: a consumer that commits after every answer, an
empty one too, sits idle while nothing else is written. An unknown consumer is 404 rather than read from "now" each time,
which would miss what is written between two reads. A consumer further
behind than the server keeps is 410 with the first since it
does keep, as a cursor is; a PATCH there goes on from it. The
server keeps writes by their size, written to disk or not yet, so that
since can be ahead of the last write on disk: a
PATCH takes it, and the read from there waits for the disk.
GET /_changes/consumers lists each
consumer's since and how many writes it is
behind.