AlinsRan opened a new pull request, #13772:
URL: https://github.com/apache/apisix/pull/13772

   Fixes #12167
   
   **Makes the `compacted` recovery reload cheap instead of trying to avoid 
it.** A reload that changes nothing now reuses the existing items, leaves 
`conf_version` alone and rebuilds no routers.
   
   Companion to #13721, which removes the unsafe way of avoiding the reload. 
Together they close #12167 and #13067; each stands on its own and they can be 
reviewed independently.
   
   ## The problem
   
   #12167 reports periodic CPU spikes correlated with this log line:
   
   ```
   waitdir [/proxygw/services] err: compacted, will read the configuration 
again via readdir
   ```
   
   The reporter's ask is specific — *"APISIX CPU usage fluctuates when 
'compacted' errors occur. I want to avoid this problem."* Not the log line: the 
CPU.
   
   Recovery from `compacted` is a full reload, and today it rebuilds everything 
unconditionally:
   
   - every item is re-validated through `check_schema`, the `checker` and the 
`filter`
   - `load_full_data` sets `changed` as soon as **any** item is valid, so 
`conf_version` always moves and every router rebuilds its radixtree
   - the item tables are new objects, so downstream caches keyed on them all 
miss
   
   And it happens for **every resource type** — routes, services, upstreams, 
consumers, ssls, global_rules, plugin_configs … — in **every worker**, because 
`need_reload` is per config instance and `produce_res(nil, "compacted")` 
broadcasts to all of them.
   
   None of that work is necessary when nothing actually changed — which is 
precisely the case for the deployment that suffers from this. A prefix idle 
enough to fall behind compaction is a prefix whose configuration did not change.
   
   ## The fix
   
   Compare each key against the previous snapshot and reuse the item when 
`modifiedIndex` matches:
   
   ```lua
   local prev_item = get_prev_item(prev_values, prev_values_hash, key)
   if prev_item and prev_item.modifiedIndex == item.modifiedIndex then
       insert_tab(self.values, prev_item)
       self.values_hash[key] = #self.values
       self:upgrade_version(item.modifiedIndex)
       goto continue                       -- note: `changed` is left alone
   end
   ```
   
   etcd increments `mod_revision` on every write, so an equal `modifiedIndex` 
means equal content.
   
   The `prev_values` / `get_prev_item` plumbing already exists — it was added 
so an item whose new data fails validation can keep serving its last valid 
value. This reuses it.
   
   ### Why skipping the filter is safe
   
   The incremental watch path already works this way: `sync_data` re-runs the 
checker and filter only for the keys that changed, and never touches the other 
items. So this is not new semantics, it aligns the reload path with the watch 
path.
   
   Checked every filter individually:
   
   | config | what its filter does | only touches its own item |
   |---|---|---|
   | `/routes` | `has_domain`, `set_plugins_meta_parent`, host lowercasing, 
`filter_upstream` | ✅ |
   | `/services` | same | ✅ |
   | `/upstreams` | `has_domain`, `filter_upstream` | ✅ |
   | `/consumers`, `/consumer_groups`, `/global_rules`, `/plugin_configs` | 
`set_plugins_meta_parent` | ✅ |
   | `/ssls` | sni lowercasing, trailing-dot strip | ✅ |
   | `/plugins` | **`plugin.load(item)` — global effects** | ❌ |
   
   The first eight are idempotent and only mutate fields of the item they are 
handed, so an item that was filtered once is already in its filtered state. 
`/plugins` is the exception, and it is `single_item` — one item, negligible 
gain — so the `single_item` branch is left out of the optimisation entirely.
   
   ### Deletions need an explicit check
   
   This is the trap. Keys that vanished while we were not watching leave *every 
surviving key untouched*, so `changed` would stay `false`, `conf_version` would 
not move, and the routers would go on serving the deleted items:
   
   ```lua
   if prev_values_hash and matched_prev < nkeys(prev_values_hash) then
       changed = true
   end
   ```
   
   ## Tests
   
   Both are verified to be discriminating — a test that passes either way 
proves nothing.
   
   **TEST 19** — a reload with nothing changed. Two independent probes: a tag 
on the `values` array (a reload always allocates a fresh one, so losing it 
proves the reload really ran) and a tag on the item inside it (which must 
survive). Asserts `conf_version` moved once for the incremental write that 
wakes the watcher, not twice.
   
   Against unpatched `master`:
   
   ```
    reload ran: true
   -item reused: true
   +item reused: false
   -conf_version bumped once, not twice: true
   +conf_version bumped once, not twice: false
   ```
   
   **TEST 20** — a reload whose only change is a deletion. Passes on unpatched 
`master` (which bumps unconditionally), so it was verified against the variant 
that matters: the reuse optimisation *with the deletion check disabled*:
   
   ```
    reload ran: true
    ghost dropped: true
   -conf_version bumped for the deletion: true
   +conf_version bumped for the deletion: false
   ```
   
   Full file run locally: the failure set is identical before and after this 
change (TEST 3/4/5/9, which need a TLS etcd on :12379 that this machine does 
not have), and TEST 16/17/18 — the existing full-reload tests — still pass.
   
   ## What this does not do
   
   The `readdir` itself still happens on every `compacted`: without reading the 
full snapshot there is no way to know what was missed. The transfer and JSON 
parse remain. What goes away is the rebuild on top of it, which is the part 
that scales with configuration size and shows up as the spike.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to