Incremental Polls
Carry a cursor such as `since_id` from one run of a poll source to the next, so the vendor returns only what is new
By default every run of a declarative poll source sends the same request, and upsert on the item's id absorbs the overlap. Many vendor APIs can do better: they return a marker with each response (X's meta.newest_id) and accept it on the next call (since_id), so each run fetches only what is new.
The cursor block on a poll's ingest declares that marker:
param— the query parameter the stored value is sent as.from— where the next value is read in the response.fromLink— instead offrom, for a vendor that answers with a link to its next request: where that link is in the response. The next value is the link'sparamquery parameter.morePath— optional: where the vendor says more results are waiting.
The platform stores the cursor per installed source and moves it only after the run's rows were written.
Declare a cursor
ingest: {
mode: 'poll',
intervalSec: 300,
request: {
url: 'https://api.x.com/2/tweets/search/recent',
query: { query: 'from:scrydon', max_results: '100' },
authRef: 'token',
},
response: { itemsPath: '$.data', format: 'json', itemsOptional: true },
mapping: {
id: { path: 'id' },
text: { path: 'text' },
createdAt: { path: 'created_at' },
},
writeMode: 'upsert',
cursor: { param: 'since_id', from: '$.meta.newest_id', morePath: '$.meta.next_token' },
}| Field | Meaning |
|---|---|
param | Query parameter name, 1–128 characters. Set after request.query and before the credential. |
from | Path into the decoded response, in the same syntax as itemsPath ($.meta.newest_id). |
fromLink | Instead of from: path to a link in the decoded response, whose param query parameter is the next value. See A cursor in a link. |
morePath | Optional path the vendor fills when it holds more results than this response carries. |
A cursor declares exactly one of from and fromLink. cursor is accepted on a poll source only. Every other ingest mode rejects the field.
A cursor in a link: fromLink
Some vendors hand back the next request as a whole link rather than a value. Microsoft Graph's delta queries end their answer with @odata.deltaLink, and the token to send next time is that link's $deltatoken parameter:
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users(displayName,mail)",
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/users/delta?$deltatoken=oEcOySpF_hWYmTIUZBOIfPzcwisr…",
"value": [{ "id": "87d349ed-…", "displayName": "Adele Vance", "mail": "adelev@contoso.com" }]
}fromLink points at the link; param names the query parameter read from it, and the same parameter is sent on the next run:
request: {
url: 'https://graph.microsoft.com/v1.0/users/delta',
query: { $select: 'displayName,mail' },
authRef: 'token',
},
response: { itemsPath: '$.value', format: 'json', itemsOptional: true },
writeMode: 'upsert',
cursor: {
param: '$deltatoken',
fromLink: "$['@odata.deltaLink']",
morePath: "$['@odata.nextLink']",
},- Graph's keys hold a
., so the path quotes them inside brackets:$['@odata.deltaLink']. A dotted path ($.@odata.deltaLink) would look fordeltaLinkinside@odataand find nothing. - The link must be an absolute URL that carries
param. The value is stored decoded and sent encoded, so a token with%3din the link is sent back as the same token. - Only
paramis taken from the link. The rest of the next request is the one you declared — its url,request.queryand credential — so declare the request the link points back to. - A delta round that does not fit in one response has no
@odata.deltaLinkon its first page, only@odata.nextLink. When that next-page link carriesparamtoo, the cursor moves to it, so each run reads the round's next page. When it carries another parameter, the cursor does not move and the next run starts the round again. See More pages. - A vendor can expire a token. Graph answers a request whose
$deltatokenhas expired with HTTP 410 Gone. That run fails and the cursor stays, so every later run fails the same way until an organization admin resets the cursor; the next run then starts a new round.
How a run uses it
- The first run has no stored cursor and sends the request exactly as declared — no
param. - A later run adds
param=<stored value>to the query string, as long as the stored value was taken under the same connection and request (see What resets a cursor). - After the response is decoded, the value at
frombecomes the next cursor if it is a non-empty string or a finite number. A number is sent as text. An id above 2^53 loses precision as a JSON number, so pointfromat the vendor's string form when it has one (X's ids are strings). - The next cursor is stored only after the run's rows were written, in the same update that records the run as successful.
Anything else leaves the cursor where it was:
fromresolves to nothing,null, an empty string, an object or a list. X omitsmeta.newest_idwhen nothing is new, so the next run asks from the same point.fromLinkresolves to anything but an absolute URL, or to one withoutparamor with it empty.- The value is longer than 2,048 bytes (UTF-8). The run logs
cursor_too_longand the cursor does not move. ForfromLinkthe bound is on the parameter's decoded value, not the whole link. - The fetch failed, the response could not be decoded, or the rows could not be written. A cursor is never stored past rows a failed run lost.
A run that succeeds still moves the cursor past rows it did not write: rows that fail the source's validation, and rows the organization's data policy blocks. Fetching them again would end the same way. If an upgrade fixes the mapping or validation that rejected them, the cursor is kept (see What resets a cursor): reset it so the next run starts as a first run.
Because the cursor moves only after a successful write, a run that fails part-way is fetched again from the same point. The write mode absorbs the rows that were already fetched — which is why a cursor needs upsert or changed-only.
Write mode
A cursor needs writeMode: 'upsert' (the default) or 'changed-only'. The other two are rejected at build time:
writeMode | Why it cannot carry a cursor |
|---|---|
append | A run fetched again after a failure would write the same rows twice. |
replace | Each run's response holds only what is new, so the table would keep only the last run's rows. |
The parameter must be its own
param may not collide with anything else that sets a query parameter: a key in request.query, a parameter already written into request.url, or the credential's query name when auth.placement is query. Either value would decide, or be decided by, the cursor the vendor sees. Rename one of them.
More pages: morePath
A run sends one request and reads one response. When morePath resolves to anything but nothing, null, an empty string or false, the vendor holds pages this run did not read. The run logs cursor_more_pages, writes the rows it read, and is not counted as complete: the source's sync state says more pages wait, and its health is not certified by that run. A run is complete only once it reads the last page.
What the cursor does depends on where it is read from:
from. The cursor does not move. The value atfromnames the newest item, and the pages behind it are older; moving to it would skip them for good. The next run asks from the same point again.fromLink. The cursor moves to theparamquery parameter of the next-page link atmorePath, so the next run reads the round's next page. A drive's delta (?token=) pages this way. When the next-page link carries a different parameter (Graph's users delta pages with$skiptokenbut resumes with$deltatoken), the cursor does not move, and each run starts the round again.
Raise the page size where the vendor allows it (Graph's Prefer: odata.maxpagesize=… header, in request.headers), so a round or a burst fits in one response.
If the log shows cursor_more_pages on most runs, the vendor produces more than one response holds per interval: shorten the interval, or raise the vendor's page size within the resource limits. With a from cursor this is required, not just faster: until a response holds the whole burst, the cursor cannot move.
What resets a cursor
A cursor belongs to the request it was taken under. The platform stores it with a scope: a hash of the connection the run resolved, that connection's admitted endpoints, the declared request, the definition's intervalSec and minIntervalSec, and the cursor block itself. A run whose scope differs does not send the stored value; it starts as a first run and logs cursor_reset. If that run cannot advance the cursor, the stored one is cleared.
| Event | Cursor |
|---|---|
| The source is pointed at another connection | Cleared in the same change |
| A source with no pinned connection is pinned to the connection it already reads | Kept |
| A source with no pinned connection is pinned to another connection | Not sent by the next run, which starts without it |
| An extra instance is relabelled on the same connection | Kept |
An administrator changes the connection's endpoint, or an upgrade changes the request, the cursor block, or the definition's intervalSec or minIntervalSec | Not sent by the next run, which starts without it |
| A person changes the source's schedule in Analytics | Kept — the override is not part of the scope |
| An upgrade that changes only the mapping or the table | Kept |
| The source is paused, then resumed | Kept |
| The source is removed | Dropped with it; a source added again starts without one |
| An organization admin resets it | Cleared; the next run starts without it |
Reset a cursor by hand
An organization admin can clear a source's cursor in Analytics → Data sources: open the source, go to the Schedule & retention tab and choose Reset cursor, then confirm. The action is shown only for a source that declares cursor, and only to organization admins. A reset that clears a stored cursor is recorded in the Audit Log as data_source.cursor.reset, naming the admin; a source with no stored cursor has nothing to clear: Analytics says Nothing to reset, and nothing is recorded.
Use it when the table has lost rows the vendor still holds — after deleting rows by mistake, for example. The next run sends no param and fetches what the vendor returns for a first request; upsert absorbs the rows already in the table.
A run already in progress is not stopped. If a run started before the reset, it can still store the cursor it advanced to when it finishes, and the reset then takes effect from the run after it. If a run was under way when you reset, reset again once the source's Activity tab shows it finished.
Validation summary
These are build-time errors, caught by extension build before an archive is ever uploaded:
| Rejected | Why |
|---|---|
cursor on any mode but poll | Only a scheduled HTTP poll carries a cursor from run to run |
writeMode append or replace with a cursor | A refetch would duplicate rows, or the table would keep only each run's new rows |
param equal to a request.query key, a parameter in request.url, or the credential's query name | The cursor would overwrite, or be overwritten by, another parameter |
An empty param, from, fromLink or morePath, or a param over 128 characters | Each names something the run must find |
Both from and fromLink, or neither | The next value is read from exactly one place |
Any unlisted field in cursor | The block is .strict() — a typo is an error, not a silent no-op |