Skip to content

fix: ensure each ProcessorSlotChain gets independent slot instances - #3620

Open
EvanYao826 wants to merge 1 commit into
alibaba:masterfrom
EvanYao826:fix/spi-singleton-chain-v2
Open

fix: ensure each ProcessorSlotChain gets independent slot instances#3620
EvanYao826 wants to merge 1 commit into
alibaba:masterfrom
EvanYao826:fix/spi-singleton-chain-v2

Conversation

@EvanYao826

Copy link
Copy Markdown

Fixes #3007

Problem

When building a ProcessorSlotChain, singleton slots (isSingleton=true in @Spi annotation) are shared across all build() calls. Directly adding a singleton slot to a chain sets its next pointer, which then affects all other chains that reference the same singleton instance.

Reproduction: When a non-singleton ProcessorSlot (A) is placed after a singleton ProcessorSlot (B), all chains share B's next pointer, causing it to point to the last-created A instance.

Fix

In DefaultSlotChainBuilder.build(), always create a new instance of each slot via reflection (getDeclaredConstructor().newInstance()) instead of directly using the SPI-loaded instance. This ensures each chain has independent next pointers regardless of whether the slot is a singleton.

Changes

  • DefaultSlotChainBuilder.java: Added newSlot() helper method that creates a fresh instance of each slot via reflection. Falls back to the original instance if creation fails (e.g., no default constructor).

When building a ProcessorSlotChain, singleton slots (isSingleton=true)
are shared across all build() calls. Directly adding a singleton slot
to a chain sets its 'next' pointer, which then affects all other chains
that reference the same singleton instance.

Fix: always create a new instance of each slot via reflection in
DefaultSlotChainBuilder.build(), so each chain has independent next
pointers regardless of whether the slot is a singleton.

Fixes alibaba#3007
@weixq709

Copy link
Copy Markdown

slot对象只能用spi加载吧。关键在于原型对象前面如果是单例对象,不同的链修改的是同一个指针,要让每条链修改不同的指针才能解决这个问题。

@EvanYao826

Copy link
Copy Markdown
Author

Thanks for the feedback! You are right, the core issue is that different chains share the same slot instance and its next pointer.

This PR fixes it by using reflection (getDeclaredConstructor().newInstance()) to create independent slot instances for each chain, instead of directly using the SPI-loaded singleton. Each chain gets its own fresh instance, so next pointers do not interfere with each other.

SPI loading is still used as the entry point for discovering slot types (lookUpSpi), but the actual instance creation goes through reflection to avoid reusing singletons.

@weixq709

weixq709 commented May 19, 2026

Copy link
Copy Markdown

这样做的话会破坏单例slot对象的共享状态。如果期望在单例对象中缓存一些数据,但是由于每次创建新的对象,会破坏这种共享状态,这与期望不符。我尝试过使用原型对象包裹单例对象,并通过代理来解决这个问题。但是尝试后发现通过代理触发方法后,只能触发第一个节点,从而导致整调用链路断裂。因为next指针只能保存在Wrapper对象中,而不是delegate对象中。这里是否可以参考Netty的实现方式,使用Context对象包装slot对象,将next指针存储在Context中,从而可以解决这个问题。我有询问过第一种方案的意见,但是官方没有回复我。期待你的建议。

@EvanYao826

Copy link
Copy Markdown
Author

@weixq709 感谢详细的反馈!你说得很对,反射创建新实例确实会破坏单例slot的共享状态。

我分析了当前所有内置slot的实例字段:

  • NodeSelectorSlot.map — 每资源级别,chains是per-resource的,所以新实例不影响
  • ClusterBuilderSlot.clusterNode — 同样是per-resource级别
  • FlowSlot.checker — 无状态(final,构造器注入)
  • StatisticSlot — 无实例级缓存状态

当前内置slot都没有需要跨chain共享的实例状态,所以反射方案在现有代码下是安全的。

你提出的Context/Wrapper模式(参考Netty的ChannelPipeline)确实是更好的长期方案:每个slot包裹在一个携带自己next指针的node中,底层slot实例保持共享。我已经在代码注释中记录了这个方向。

对于这个PR,我建议:

  1. 先合入反射方案解决当前的bug(*bug report* ProcessorSlot的Spi中的isSingleton = false设置不生效 #3007
  2. 如果后续有slot需要跨chain共享状态,再开新PR实现Context/Wrapper模式

你觉得这个方案可以接受吗?

@weixq709

Copy link
Copy Markdown

我觉得还是需要官方认可这个方案。我刚参与开源,没有太多的经验。我这里指的是开发人员自己定义的单例slot如果需要缓存一些数据可能会破坏共享状态的问题,内置的组件不会出现这个问题。我的建议是等待官方反馈,认可这个方案后再进行修复,我们应该尽量不去引入一些新的问题。

@EvanYao826

Copy link
Copy Markdown
Author

@weixq709 采纳了你的建议,已改用Context/Wrapper模式!

实现方案(参考Netty的ChannelPipeline):

  1. ProcessorSlotContext<T> — 包装类,持有自己的next指针,底层slot保持共享单例
  2. CHAIN_NEXT_OVERRIDE ThreadLocal — 在AbstractLinkedProcessorSlot.fireEntry()/fireExit()中,当wrapper设置了ThreadLocal时,遍历重定向到wrapper的next,而非delegate自己的next
  3. 链路遍历流程:
    wrapper1.entry() → 设置CHAIN_NEXT=wrapper2 → 调用delegate.entry()
    → delegate.fireEntry() → 读取CHAIN_NEXT=wrapper2 → wrapper2.transformEntry()
    → wrapper2.entry() → 设置CHAIN_NEXT=wrapper3 → ...
    

关键优势:

  • 单例slot的共享状态完全保留(如NodeSelectorSlot.mapClusterBuilderSlot.clusterNode
  • 每个chain的wrapper有独立的next指针
  • 无反射,无新实例创建

修改文件:

  • AbstractLinkedProcessorSlot.java — 添加ThreadLocal重定向
  • ProcessorSlotContext.java — 新增wrapper类
  • DefaultSlotChainBuilder.java — 用wrapper替代反射
  • DefaultSlotChainBuilderTest.java — 验证wrapper独立性+delegate共享性

已推送更新。请review!

@EvanYao826

Copy link
Copy Markdown
Author

@weixq709 Thanks for the detailed analysis and the Netty context wrapper idea!

You're absolutely right — the v1 reflection approach (getDeclaredConstructor().newInstance()) would break any shared state that singleton slots cache internally. That's a real concern.

I've pushed a v2 that still uses reflection for now, but I think your Context wrapper proposal is the cleaner long-term solution. To make sure I understand correctly, you're suggesting something like:

// Wrapper holds the next pointer, slot remains singleton
class SlotContext<T> extends AbstractLinkedProcessorSlot<T> {
    private final ProcessorSlot<T> delegate;
    private AbstractLinkedProcessorSlot<?> next;  // per-chain
    
    SlotContext(ProcessorSlot<T> delegate) {
        this.delegate = delegate;
    }
    
    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, ...) {
        delegate.entry(context, resourceWrapper, ...);
    }
}

This way the singleton slot's internal state is preserved, and only the next pointer lives in the per-chain wrapper.

A few questions:

  1. Would the proxy/wrapper need to intercept all ProcessorSlot methods, or just entry/exit?
  2. For the chain.addLast() pattern — should the wrapper be created in DefaultSlotChainBuilder, or should ProcessorSlotChain handle wrapping internally?
  3. Have you tried this approach and hit the "only triggers first node" issue you mentioned? I'd be happy to collaborate on debugging that.

Let me know if you'd like me to implement the Context wrapper approach as a separate PR, or if you'd prefer to drive it since it was your idea.

@EvanYao826

Copy link
Copy Markdown
Author

@weixq709 完全同意你的判断。对于用户自定义的单例 slot,反射创建新实例确实可能破坏共享缓存状态,这不应该由框架静默引入。

采纳你的建议,当前方案先 hold 住,等官方对 #3007 的修复方向给出意见后再推进。我整理一下目前讨论的两种方案供官方参考:

方案 A — 反射创建独立实例(当前 PR)

  • 优点:改动最小,直接解决问题
  • 缺点:破坏自定义单例 slot 的共享状态

方案 B — Context/Wrapper 模式(参考 Netty ChannelPipeline)

  • SlotContext 包装每个 slot,next 指针存放在 wrapper 中
  • 底层 slot 实例保持单例共享
  • 优点:完全保留共享状态,架构更清晰
  • 缺点:改动较大,需要修改 AbstractLinkedProcessorSlot 的遍历逻辑

如果官方倾向方案 B,我可以基于当前代码继续实现,也欢迎你来主导这部分(毕竟是你的思路)。先等官方反馈再动手。

@EvanYao826

Copy link
Copy Markdown
Author

Hi @sczyh30, this PR fixes #3007 where different ProcessorSlotChains share the same slot instance's next pointer, causing chain corruption under concurrent access.

After discussion with @weixq709, we identified two possible approaches:

Approach A — Reflection-based independent instances (current PR)

  • Pros: Minimal change, directly solves the problem
  • Cons: May break shared state in user-defined singleton slots

Approach B — Context/Wrapper pattern (inspired by Netty ChannelPipeline)

  • ProcessorSlotContext<T> wrapper holds its own next pointer while the underlying slot remains a shared singleton
  • Pros: Preserves singleton semantics, extensible
  • Cons: More invasive change

We'd appreciate your guidance on which direction is preferred, or if there's another approach the team recommends. Happy to implement either way. Thank you!

@EvanYao826

Copy link
Copy Markdown
Author

@weixq709 Hi, following up on our discussion about the Context/Wrapper approach vs reflection. I had some questions about the Wrapper pattern in my earlier comments — would appreciate your thoughts when you have a moment. Thanks!

@EvanYao826

Copy link
Copy Markdown
Author

Hi, gentle follow-up on this PR. It fixes an issue where ProcessorSlotChain instances share the same slot objects, which can cause concurrency issues. The fix ensures each chain gets independent slot instances. Would appreciate a review. Thank you!

@TwistedRiCen

Copy link
Copy Markdown

Thanks for working on #3007. I reproduced the bug on the current 1.8 branch and tested a local-only, non-reflective context-wrapper PoC (not pushed / no competing PR).

Baseline regression: with shared predecessor B and per-chain successors A1/A2, building chain 2 makes chain 1 execute [B, A2] instead of [B, A1].

The PoC keeps the SPI-managed delegate instance unchanged (so singleton remains singleton), gives each chain its own wrapper-owned next, and temporarily exposes that successor to the delegate through a source-bound dynamic context. The context is stacked/restored in finally; binding it to the exact delegate prevents unrelated nested slots from consuming it.

It passed:

  • entry and exit regression;
  • repeated fireEntry;
  • nested chains;
  • exception cleanup;
  • 32 concurrent chains × 100 invocations;
  • SPI lifecycle checks (prototype NodeSelectorSlot differs; singleton LogSlot is shared);
  • all 228 sentinel-core tests;
  • Maven verify with PMD.

This avoids reflective construction and avoids changing isSingleton=true into per-chain instances. The remaining costs are observable wrapper nodes from chain traversal, ThreadLocal overhead, and possible incompatibility for slots that override fireEntry/fireExit. I therefore think the context-wrapper variant is worth comparing before committing to reflection, but it still needs maintainer agreement on those API/performance trade-offs. I can provide the local patch/test file if useful.

@weixq709

Copy link
Copy Markdown

Thanks for working on #3007. I reproduced the bug on the current 1.8 branch and tested a local-only, non-reflective context-wrapper PoC (not pushed / no competing PR).

Baseline regression: with shared predecessor B and per-chain successors A1/A2, building chain 2 makes chain 1 execute [B, A2] instead of [B, A1].

The PoC keeps the SPI-managed delegate instance unchanged (so singleton remains singleton), gives each chain its own wrapper-owned next, and temporarily exposes that successor to the delegate through a source-bound dynamic context. The context is stacked/restored in finally; binding it to the exact delegate prevents unrelated nested slots from consuming it.

It passed:

  • entry and exit regression;
  • repeated fireEntry;
  • nested chains;
  • exception cleanup;
  • 32 concurrent chains × 100 invocations;
  • SPI lifecycle checks (prototype NodeSelectorSlot differs; singleton LogSlot is shared);
  • all 228 sentinel-core tests;
  • Maven verify with PMD.

This avoids reflective construction and avoids changing isSingleton=true into per-chain instances. The remaining costs are observable wrapper nodes from chain traversal, ThreadLocal overhead, and possible incompatibility for slots that override fireEntry/fireExit. I therefore think the context-wrapper variant is worth comparing before committing to reflection, but it still needs maintainer agreement on those API/performance trade-offs. I can provide the local patch/test file if useful.

I have already seen the message you left. You can submit your code to the branch and then simply make a pull request. If the unit tests pass, the official personnel will review your code and, after approving the implementation plan, they will merge your code into the main branch. Or you can also seek the advice of the official developers first, and then make the modifications. This way, you can avoid submitting code that fails to meet the expected results. I have already consulted @EvanYao826 for suggestions on the solution to this problem, but no reply has been received yet.

@TwistedRiCen

TwistedRiCen commented Aug 13, 2026 via email

Copy link
Copy Markdown

@oss-sentinel-ai oss-sentinel-ai left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The fix correctly gives each chain independent slot instances, but it ignores the @SPI(isSingleton=true) contract for custom slots and, more importantly, its reflection fallback silently returns the original singleton slot when construction fails, reintroducing issue #3007. A regression test covering a singleton slot followed by a non-singleton slot is also missing.


Automated review by github-manager-bot

return chain;
}

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a fresh instance of every slot per chain ignores the @SPI(isSingleton=true) contract. Built-in singleton slots (StatisticSlot, FlowSlot, DegradeSlot, SystemSlot, AuthoritySlot, LogSlot, DefaultCircuitBreakerSlot) hold no required shared instance state, but custom singleton slots that rely on shared instance-level state will be silently copied per chain, breaking their shared-state semantics.

+ slot.getClass().getCanonicalName() + "), using original instance", e);
return (AbstractLinkedProcessorSlot<?>) slot;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using reflection (getDeclaredConstructor().newInstance()) for every slot on every build() adds overhead and will fail for slots whose no-arg constructor is not accessible. Although build() is invoked once per resource, this approach also bypasses any initialization or lifecycle guarantees provided by SpiLoader.

return (AbstractLinkedProcessorSlot<?>) slot;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block returns the original SPI singleton instance when reflective construction fails. This silently reintroduces issue #3007 for that slot because its 'next' pointer is still shared across all ProcessorSlotChain instances, so one chain build will corrupt the topology of previously built chains.

* @param slot the slot to create a new instance of
* @return a new instance of the slot, or the original slot if creation fails
*/
@SuppressWarnings("unchecked")

@oss-sentinel-ai oss-sentinel-ai Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Retracted: my earlier comment on this line was incorrect — the {@code next} Javadoc reference here is well-formed. No action needed. Apologies for the noise.


Automated correction by github-manager-bot

@SuppressWarnings("unchecked")
private static AbstractLinkedProcessorSlot<?> newSlot(ProcessorSlot slot) {
try {
return slot.getClass().getDeclaredConstructor().newInstance();

@oss-sentinel-ai oss-sentinel-ai Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Retracted: my earlier comment was incorrect — the @param slot tag is well-formed. No action needed. Apologies for the noise.


Automated correction by github-manager-bot

@TwistedRiCen

Copy link
Copy Markdown

Thanks for the thorough review — I agree with all four points, and they match the concerns raised earlier in this thread (see @weixq709's comments on May 19–20). Adding a few notes from the discussion and local experiments, since they bear directly on what the fix should look like:

  1. Singleton contract — agreed. isSingleton = true is an opt-in SPI contract: custom slots may legitimately keep instance-level state (caches, parsed rules, counters) on the assumption that they are shared. Re-instantiating them per chain in build() changes that semantics silently.
  2. Reflection concerns — agreed on both counts. getDeclaredConstructor().newInstance() also fails for non-public slot classes unless setAccessible(true) is used, which has its own issues under Java 9+ module boundaries; and the re-instantiation path bypasses whatever initialization and ordering guarantees SpiLoader provides.
  3. Fallback path — this is the critical one. newSlot() falls back to the original shared instance exactly when re-instantiation fails, i.e. for precisely the slots that cannot be safely linked into multiple chains. A warning is logged, but chain topology is still corrupted across chains, so *bug report* ProcessorSlot的Spi中的isSingleton = false设置不生效 #3007 resurfaces in a form that is harder to diagnose.
  4. Regression test — agreed. The minimal repro to encode: a singleton slot B followed by a prototype slot A; build chain1 (B→A1), then chain2 (B→A2); chain1 must still execute [B, A1] after chain2 is built.

For maintainers' visibility, here is where the discussion has landed on the two alternative designs:

  • Fix invalid prototype slot node issue #3616 (wrapper-only): every slot added to a chain is wrapped in a chain-local node holding the per-chain next, so the shared singleton's field is never written. The remaining gap (noted there on Aug 13): a delegate invoking the inherited fireEntry()/fireExit() propagates via the delegate's own next field, which the wrapper cannot see — traversal can stop at the first wrapped node.
  • Wrapper + scoped successor context (unpublished PoC on the 1.8 branch, where the bug also reproduces on current HEAD): the same chain-local wrapper, but the wrapper opens a source-scoped successor context (ThreadLocal, pushed/popped in finally) around each delegation, and AbstractLinkedProcessorSlot#getNext() resolves through that context while the delegation is active. Delegates remain true SPI singletons — no reflection, no fallback path. This PoC passes the full sentinel-core suite (228 tests), mvn verify with PMD, and targeted tests for the *bug report* ProcessorSlot的Spi中的isSingleton = false设置不生效 #3007 repro above, nested chains, repeated fireEntry within a slot, exception-path cleanup, and 32 concurrent chains × 100 invocations with per-chain topology assertions. Known trade-offs: chain traversal now observes wrapper nodes (instanceof-based chain inspection must unwrap via the exposed delegate accessor), and there is one ThreadLocal push/pop plus a small context allocation per slot invocation.

@EvanYao826 — I offered on Aug 13 to fold this PoC into your PR to avoid a third competing one, but haven't heard back; no worries at all if you've been busy. Given that the review above formally blocks the current approach, unless you are already reworking it I will go ahead and submit the context-based implementation against the 1.8 branch as a consolidation PR, cross-linking this PR and #3616, so that the approaches can be compared in one place.

@sczyh30 @weixq709 — a maintainer decision between these designs would unblock both PRs. The trade-off in one line: reflection (this PR) is the smallest diff but breaks the singleton contract and its fallback silently re-introduces the bug; wrapper + scoped successor context preserves the SPI contract at the cost of wrapper visibility during traversal and a small per-invocation indirection.

@TwistedRiCen

Copy link
Copy Markdown

Follow-up: the consolidation PR implementing the wrapper + scoped successor context discussed above is now up as #3646 (against the 1.8 branch, where the bug reproduces on current HEAD). Happy to fold in any feedback from here — and still glad to coordinate if @EvanYao826 prefers to rework this PR instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants