Skip to content
Merged
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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,45 @@
применяется первым, опции вызывающего дописываются следом и отнять его не
могут.

- **`Pager.Take`** — взять `n` строк и остановиться, не заплатив за страницу,
которую никто не прочитает.

Очевидная запись «взять 45 сделок» тратит лишний запрос молча. `break` внутри
цикла по `Rows()` выходит только из внутреннего цикла, после чего **заново
вычисляется условие внешнего** — а это вызов портала. Код компилируется,
выглядит правильно и отдаёт правильные строки; лишним оказывается только
токен лимита, потраченный на страницу, которую никто не читает. Верная форма —
отсечка в заголовке цикла (`for len(taken) < want && p.Next(ctx)`), но её надо
знать; `Take` — это она же, упакованная.

Меньше `n` строк `Take` возвращает только в конце списка или при ошибке, и
ошибку отдаёт **вместе** с уже прочитанными строками: обход, упавший на
третьей странице, первые две всё-таки прочитал.

Прочитанные, но не отданные строки не выбрасываются: следующий `Take` или
`Next` отдаёт их **без обращения к порталу**, поэтому `Take` и `Next`
смешиваются свободно. Курсор при этом идёт по **прочитанному**, а не по
отданному — иначе `Scan`, остановленный на 45-й строке страницы из 50, попросил
бы следующую страницу с 46-й и выдал бы пять строк дважды.

`Count()` считает **отданные** строки: после `Take(ctx, 45)` это 45, а не 50.
Чего он посчитать не может — что вызывающий сделал со строками дальше: `Next`
отдаёт страницу целиком, и `break` внутри чужого цикла до `Pager` не доходит.
Это сказано в godoc `Count` прямо, вместе с советом брать строки через `Take`,
если важно именно их число.

Страница без строк, когда сервер ещё сообщает о продолжении, **заканчивает**
`Take` с `ErrCursorStalled`, а не запрашивается снова. `Next` такую страницу
отдаёт и оставляет решение вызывающему — цикл там принадлежит ему; внутри
`Take` выходить не из чего, и бесконечная выдача пустых страниц тратила бы
лимит клиента, пока не кончится квота. Детектор застрявшего курсора тут не
спасает: курсор **двигается**, просто ничего не отдаёт.

Проверено на живом портале (54 сделки): `Take(ctx, 45)` — **1** HTTP-запрос и
`Count() == 45`; та же выборка циклом с `break` — **2** запроса и
`Count() == 54`; `Take(ctx, 45)` + `Next(ctx)` — по-прежнему 1 запрос, `Next`
отдаёт оставшиеся 5 строк той же страницы.

- **`Params`** — псевдоним `map[string]any`. Параметры Битрикс24 вложенные, и
каждый уровень вложенности — ещё один `map[string]any` целиком. Именно
**псевдоним**, а не отдельный тип: отдельный тип отверг бы `map[string]any`,
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,38 @@ return p.Err() // ПРОВЕРЯТЬ ВСЕГДА
`Next` возвращает `false` и в конце списка, и при ошибке, поэтому оборвавшийся
обход выглядит как завершившийся — `Err()` после цикла отличает одно от другого.

**Нужно N строк — `Take`.** Очевидная запись «взять 45 сделок» молча тратит
лишний запрос:

```go
for p.Next(ctx) { // 46-я строка лежит на первой странице,
for _, row := range p.Rows() { // но условие внешнего цикла — это вызов
if len(taken) == want { break } // портала, и после break оно проверяется
taken = append(taken, row) // заново
}
}
```

`break` выходит из внутреннего цикла, внешнее условие вычисляется ещё раз — а это
запрос за страницей, которую никто не прочитает. Компилируется, выглядит верно,
строки отдаёт правильные. Правильная форма — отсечка в заголовке цикла
(`for len(taken) < want && p.Next(ctx)`), но её надо знать. `Take` — это она же,
упакованная:

```go
rows, err := p.Take(ctx, 45)
```

Меньше `n` строк `Take` вернёт только в конце списка или при ошибке, и ошибку
отдаст **вместе** с уже прочитанными строками. Строки, прочитанные, но не
отданные, не теряются: следующий `Take` или `Next` отдаст их **без обращения к
порталу** — так что `Take` и `Next` свободно смешиваются.

`Count()` считает **отданные** строки, а не пройденные страницы: после
`Take(ctx, 45)` это 45, а не 50. Чего он посчитать не может — что вы сделали с
ними дальше: `Next` отдаёт страницу целиком, и `break` внутри вашего цикла до
`Pager` не доходит. Если важно число строк — берите их через `Take`.

**Для больших выгрузок — `Scan`.** Постраничный обход заставляет сервер
отсчитывать все пропущенные строки, поэтому последние страницы длинного списка
идут всё медленнее. `Scan` идёт по идентификатору: `start=-1` (отключает подсчёт),
Expand Down
4 changes: 4 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
// }
// return p.Err()
//
// Pager.Take bounds a walk by a row count. It is not sugar: a break inside the
// inner loop leaves the outer condition — a call to the portal — to be evaluated
// again, so "the first 45" written by hand fetches a page it never reads.
//
// # Reading a result
//
// Unwrap strips the single-key object many methods wrap their payload in;
Expand Down
32 changes: 32 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,38 @@ func ExampleWithDescending() {
}
}

// The first n rows, without paying for a page nobody reads.
//
// Breaking out of the inner range over Rows does not stop the walk: the outer
// condition is evaluated again after the break, and that condition is a call to
// the portal. Take stops where it was asked to.
func ExamplePager_Take() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))

p, err := client.Core().Scan("crm.deal.list", b24.Params{
"select": []any{"ID", "TITLE"},
}, b24.WithDescending()) // newest first
if err != nil {
log.Fatal(err)
}

rows, err := p.Take(context.Background(), 45)
if err != nil { // fewer than 45 rows come back with it, not instead of it
log.Fatal(err)
}
for _, row := range rows {
var deal struct {
ID b24.ID `json:"ID"`
Title string `json:"TITLE"`
}
if err := json.Unmarshal(row, &deal); err != nil {
log.Fatal(err)
}
fmt.Println(deal.ID, deal.Title)
}
fmt.Println(p.Count(), "rows taken") // 45, not the 50 that were fetched
}

func storeTokens(access, refresh string) {}
func storeApplicationToken(memberID, token string) {}
func savedApplicationToken(memberID string) string { return "" }
17 changes: 16 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ likely to get wrong.
5. **Use `Pages`/`Scan` instead of hand-rolling the `next` loop.** Hand-rolling
invites three bugs the SDK already handles: `next:0` is a legitimate first
offset (only its ABSENCE ends a list), a method that ignores the cursor loops
forever, and an error inside the loop is easy to swallow.
forever, and an error inside the loop is easy to swallow. For "the first n
rows" use `Take` — a `break` in the inner loop does NOT stop a walk; see
"Walking a list" below.

6. **Use batch for many calls.** 50 calls in one request cost ONE rate-limit
token instead of fifty.
Expand Down Expand Up @@ -96,6 +98,9 @@ Walking a list:
}
return p.Err() // ALWAYS: Next returns false both at the end AND on error

// The first n rows: Take, NOT a break inside the inner loop (see below).
rows, err := p.Take(ctx, 45)

// For a big export use Scan — it pages by id, so a deep page costs the same
// as the first one:
p, err = client.Core().Scan("crm.deal.list", nil)
Expand All @@ -107,6 +112,16 @@ Walking a list:
b24.WithDescending(),
b24.WithCallOptions(b24.WithTimeout(30*time.Second)))

A `break` inside the range over `Rows()` does NOT stop a walk: it leaves the
OUTER condition to be evaluated again, and that condition is a call to the
portal. So the rows come back right and a rate-limit token is spent on a page
nobody reads — and since nothing stopped the loop, on a long list it keeps going
to the end. `Take` stops where you asked, keeps the rows it fetched but did not
hand over (a following `Take`/`Next` gets them with NO request), and returns
short only at the end of the list or on error — with the rows it did read,
alongside the error. `Count()` counts rows HANDED OVER, so it is 45 after
`Take(ctx, 45)`; it cannot see a `break` in your own loop.

Do NOT reverse a Scan by writing `order` yourself. A descending scan has TWO
halves — order DESC AND filter `<id` — and flipping only the order asks for rows
above the newest one already seen. There are none, so the walk stops after page
Expand Down
139 changes: 127 additions & 12 deletions pager.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ var ErrNoRows = errors.New("b24gosdk: no row array in result")
// }
// return p.Err() // ALWAYS check this: a walk that stops early stops quietly
//
// Take is the same walk bounded by a row count, for when the answer is "the
// first n" rather than "all of them".
//
// A Pager is NOT safe for concurrent use.
type Pager struct {
core *Core
Expand All @@ -62,11 +65,16 @@ type Pager struct {
lastID string
hasCursor bool

page *CallResult
rows []json.RawMessage
count int
done bool
err error
page *CallResult
rows []json.RawMessage
// pending holds rows that have been FETCHED but not yet handed over, which
// is what Take leaves behind when it stops in the middle of a page. They are
// handed over by the next Take or Next, so stopping early costs no rows and
// no extra request.
pending []json.RawMessage
count int
done bool
err error
}

type pageMode uint8
Expand Down Expand Up @@ -226,11 +234,91 @@ func (c *Core) newPager(method string, params any, mode pageMode, opts []PageOpt

// Next fetches the following page. It reports false at the end of the list and
// on error; check Err after the loop to tell those apart.
//
// It requests nothing while rows a Take left behind are still waiting: those
// rows are the next rows, and they are handed over first.
func (p *Pager) Next(ctx context.Context) bool {
if p.err != nil || p.done {
return false
if len(p.pending) == 0 {
if p.err != nil || p.done {
return false
}
if !p.fetch(ctx) {
return false
}
}
p.deliver(len(p.pending))
return true
}

// Take walks until it holds n rows, and stops there.
//
// # Why this exists rather than a break inside the loop
//
// The obvious way to write "the 45 newest deals" wastes a request, silently:
//
// for p.Next(ctx) { // <- the 46th row is on page 1,
// for _, row := range p.Rows() { // but the loop asks for page 2
// if len(taken) == want { break } // before it re-tests this
// taken = append(taken, row)
// }
// }
//
// The inner break leaves the OUTER condition to be evaluated again, and that
// condition is a call to the portal. It compiles, it looks right, it returns the
// right rows, and it spends a rate-limit token on a page nobody reads. The form
// that does not is the bound in the loop header — for len(taken) < want &&
// p.Next(ctx) — which one has to know. Take is that, packaged:
//
// rows, err := p.Take(ctx, 45)
//
// Take returns fewer than n rows only at the end of the list or on error, and
// the error is returned ALONGSIDE the rows it did collect: a walk that failed on
// page three still read pages one and two, and throwing them away only buys the
// caller a second trip over the same rows.
//
// Rows fetched but not returned are kept, so a Take that stops in the middle of
// a page costs neither those rows nor another request: the next Take or Next
// hands them over without going to the portal. Take and Next therefore mix
// freely — Take the first n, then walk the rest.
//
// n <= 0 returns nothing and asks the portal for nothing.
//
// # A page that adds nothing ends the take
//
// If a page comes back with no rows while the server still reports more, Take
// stops with ErrCursorStalled instead of asking again. Next hands that page
// back and lets the caller decide, because the caller owns that loop; inside
// Take there is no loop to break out of, so an endless supply of empty pages
// would spend the customer's rate limit with nothing able to stop it. Use Next
// for a list that really does answer with empty pages.
func (p *Pager) Take(ctx context.Context, n int) ([]json.RawMessage, error) {
if n <= 0 {
p.rows = nil
return nil, p.err
}
out := make([]json.RawMessage, 0, n)
for len(out) < n {
if len(p.pending) == 0 {
if p.err != nil || p.done {
break
}
if !p.fetch(ctx) {
break
}
if len(p.pending) == 0 && !p.done {
p.err = fmt.Errorf("b24gosdk: Take(%s): %w: the page held no rows while the server still reported more; walk it with Next, which hands an empty page back instead of asking again",
p.method, ErrCursorStalled)
break
}
}
out = append(out, p.deliver(min(n-len(out), len(p.pending)))...)
}
p.rows = out
return out, p.err
}

// fetch reads ONE page into pending, or records why it could not.
func (p *Pager) fetch(ctx context.Context) bool {
params, err := p.pageParams()
if err != nil {
p.err = err
Expand All @@ -246,15 +334,29 @@ func (p *Pager) Next(ctx context.Context) bool {
p.err = fmt.Errorf("b24gosdk: %s: %w%s", p.method, ErrNoRows, p.pathNote())
return false
}
// The cursor follows what was FETCHED, not what was handed over. A Take that
// stopped at row 45 of a page of 50 must not make the next page start at 45:
// rows 46-50 are already in hand, and asking for them again would deliver
// them twice.
if err := p.advance(res, rows); err != nil {
p.err = err
return false
}
p.page, p.rows = res, rows
p.count += len(rows)
p.page, p.pending = res, rows
return true
}

// deliver hands the first n pending rows to the caller and counts them.
func (p *Pager) deliver(n int) []json.RawMessage {
// A full slice expression, so a caller appending to what it was given cannot
// write over the rows still waiting behind it.
out := p.pending[:n:n]
p.pending = p.pending[n:]
p.rows = out
p.count += n
return out
}

// callOptions is what every page request is sent with.
//
// WithIdempotent comes FIRST and unconditionally: a list walk is a read, so a
Expand Down Expand Up @@ -386,13 +488,26 @@ func (p *Pager) pathNote() string {
return " at [" + strings.Join(p.rowPath, "][") + "]"
}

// Rows returns the rows of the page just read.
// Rows returns the rows handed over by the last Next — or by the last Take,
// which may have collected them from more than one page.
func (p *Pager) Rows() []json.RawMessage { return p.rows }

// Page returns the whole envelope of the page just read, for Total and Time.
// Page returns the whole envelope of the page last FETCHED, for Total and Time.
//
// After a Take that stopped mid-page this is still that page: the rows waiting
// behind the ones handed over came from it.
func (p *Pager) Page() *CallResult { return p.page }

// Count reports how many rows have been walked so far.
// Count reports how many rows the walk has HANDED OVER: the rows returned by
// Next and Take, and not the rows still waiting inside a page Take stopped in.
//
// # What it cannot count
//
// It cannot count what your loop did with them. Next hands over a whole page, so
// a break inside the inner range leaves Count reporting the page — 50, when 45
// were used — because nothing about that break reaches the Pager. If the number
// that matters is "how many rows do I have", take them with Take, which stops
// where you asked and counts what it gave you.
func (p *Pager) Count() int { return p.count }

// Err returns the error that stopped the walk, if any.
Expand Down
Loading
Loading