@juzi/wechaty-puppet-service 1.0.124 → 1.0.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+ /**
3
+ * Regression tests for the FlashStore write-back generation guard.
4
+ *
5
+ * `_payloadStore` (FlashStore, on disk) is a cache layer independent of
6
+ * the puppet-side LRU. Before this fix it had no protection against the
7
+ * "dirty lands during a raw fetch" race:
8
+ *
9
+ * 1. A raw fetch (e.g. `contactRawPayload`) starts a gRPC round-trip.
10
+ * 2. An EVENT_TYPE_DIRTY arrives; `fastDirty` deletes the FlashStore row.
11
+ * 3. The in-flight fetch resolves and writes the *pre-dirty* payload
12
+ * back into the FlashStore -- re-poisoning the row that was just
13
+ * cleared. The value then only refreshes after a *second* dirty.
14
+ *
15
+ * The fix snapshots a per-(type, id) generation counter before the raw
16
+ * fetch and re-checks it with `isFreshWrite` before every FlashStore
17
+ * write-back; the EVENT_TYPE_DIRTY handler bumps that counter before it
18
+ * even awaits `fastDirty`, so the whole delete window is covered.
19
+ */
20
+ import { test } from 'tstest';
21
+ import os from 'os';
22
+ import path from 'path';
23
+ import fs from 'fs';
24
+ import * as PUPPET from '@juzi/wechaty-puppet';
25
+ import { puppet as grpcPuppet } from '@juzi/wechaty-grpc';
26
+ import { PuppetService } from '../src/mod.js';
27
+ const makePuppet = async () => {
28
+ const token = `puppet_service_test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
29
+ const puppet = new PuppetService({ token });
30
+ const accountId = 'acct-test';
31
+ await puppet._payloadStore.start(accountId);
32
+ return { puppet, token };
33
+ };
34
+ const cleanup = async (puppet, token) => {
35
+ await puppet._payloadStore.stop();
36
+ try {
37
+ await fs.promises.rm(path.join(os.homedir(), '.wechaty', 'wechaty-puppet-service', token), { recursive: true, force: true });
38
+ }
39
+ catch (_) { /* ignore */ }
40
+ };
41
+ /**
42
+ * A one-shot gate for a fake gRPC method. The method resolves `entered`
43
+ * as soon as it is invoked (by which point the caller's gen snapshot has
44
+ * already been taken, since the snapshot precedes the fetch call), then
45
+ * holds its callback until `release()` is called. This lets the test slot
46
+ * a dirty deterministically between the fetch start (gen snapshot) and
47
+ * the fetch resolution (write-back) -- no timers, no flakiness.
48
+ */
49
+ const makeGate = () => {
50
+ let release = () => { };
51
+ let markEntered = () => { };
52
+ const gate = new Promise(resolve => { release = resolve; });
53
+ const entered = new Promise(resolve => { markEntered = resolve; });
54
+ return { gate, entered, enter: () => markEntered(), release: () => release() };
55
+ };
56
+ test('contactRawPayload: a dirty mid-fetch skips the stale FlashStore write-back', async (t) => {
57
+ const { puppet, token } = await makePuppet();
58
+ const contactId = 'contact-race';
59
+ const response = new grpcPuppet.ContactPayloadResponse();
60
+ response.setId(contactId);
61
+ response.setName('stale-name-from-inflight-fetch');
62
+ const { gate, entered, enter, release } = makeGate();
63
+ puppet._grpcManager = {
64
+ client: {
65
+ contactPayload: (_req, cb) => {
66
+ enter();
67
+ void (async () => {
68
+ await gate;
69
+ cb(null, response);
70
+ })();
71
+ },
72
+ },
73
+ };
74
+ // Start the fetch: it snapshots the gen, then blocks on the gate.
75
+ const fetchPromise = puppet.contactRawPayload(contactId);
76
+ // Deterministic: once the fake method is entered, the snapshot is taken.
77
+ await entered;
78
+ // A dirty arrives while the fetch is in flight: bump gen + clear store,
79
+ // exactly as the EVENT_TYPE_DIRTY handler does.
80
+ puppet.cache.bumpGen(PUPPET.types.Dirty.Contact, contactId);
81
+ await puppet.fastDirty({ payloadType: PUPPET.types.Dirty.Contact, payloadId: contactId });
82
+ // Now let the stale fetch resolve into the write-back path.
83
+ release();
84
+ const returned = await fetchPromise;
85
+ t.equal(returned.id, contactId, 'raw fetch still returns its payload to the caller');
86
+ const stored = await puppet._payloadStore.contact.get(contactId);
87
+ t.notOk(stored, 'stale in-flight fetch must NOT re-poison the FlashStore after the dirty');
88
+ await cleanup(puppet, token);
89
+ });
90
+ test('contactRawPayload: no dirty means the write-back proceeds as usual', async (t) => {
91
+ const { puppet, token } = await makePuppet();
92
+ const contactId = 'contact-happy';
93
+ const response = new grpcPuppet.ContactPayloadResponse();
94
+ response.setId(contactId);
95
+ response.setName('fresh-name');
96
+ puppet._grpcManager = {
97
+ client: {
98
+ contactPayload: (_req, cb) => cb(null, response),
99
+ },
100
+ };
101
+ const returned = await puppet.contactRawPayload(contactId);
102
+ t.equal(returned.id, contactId, 'raw fetch returns its payload');
103
+ const stored = await puppet._payloadStore.contact.get(contactId);
104
+ t.ok(stored, 'without a racing dirty the payload is written to the FlashStore');
105
+ t.equal(stored && stored.id, contactId, 'stored payload matches the fetched id');
106
+ await cleanup(puppet, token);
107
+ });
108
+ test('EVENT_TYPE_DIRTY bumps gen before awaiting fastDirty so pre-event snapshots are stale', async (t) => {
109
+ const { puppet, token } = await makePuppet();
110
+ const contactId = 'contact-bump';
111
+ // A getter that snapshotted just before the event arrived.
112
+ const snapshot = puppet.cache.snapshotGen(PUPPET.types.Dirty.Contact, contactId);
113
+ const dirtyJson = JSON.stringify({
114
+ payloadType: PUPPET.types.Dirty.Contact,
115
+ payloadId: contactId,
116
+ });
117
+ await puppet.onGrpcStreamEvent({
118
+ getType: () => grpcPuppet.EventType.EVENT_TYPE_DIRTY,
119
+ getPayload: () => dirtyJson,
120
+ getSeq: () => 0,
121
+ });
122
+ t.notOk(puppet.cache.isFreshWrite(PUPPET.types.Dirty.Contact, contactId, snapshot), 'a snapshot taken before the dirty event must be judged stale afterwards');
123
+ await cleanup(puppet, token);
124
+ });
125
+ test('EVENT_TYPE_DIRTY compound RoomMember bumps both the compound and the room-level gen', async (t) => {
126
+ const { puppet, token } = await makePuppet();
127
+ const roomId = 'room-bump';
128
+ const memberId = 'member-bump';
129
+ const compoundId = `${roomId}${PUPPET.STRING_SPLITTER}${memberId}`;
130
+ const compoundSnap = puppet.cache.snapshotGen(PUPPET.types.Dirty.RoomMember, compoundId);
131
+ const roomSnap = puppet.cache.snapshotGen(PUPPET.types.Dirty.RoomMember, roomId);
132
+ const dirtyJson = JSON.stringify({
133
+ payloadType: PUPPET.types.Dirty.RoomMember,
134
+ payloadId: compoundId,
135
+ });
136
+ await puppet.onGrpcStreamEvent({
137
+ getType: () => grpcPuppet.EventType.EVENT_TYPE_DIRTY,
138
+ getPayload: () => dirtyJson,
139
+ getSeq: () => 0,
140
+ });
141
+ t.notOk(puppet.cache.isFreshWrite(PUPPET.types.Dirty.RoomMember, compoundId, compoundSnap), 'the compound (roomId+SEP+memberId) key must be bumped');
142
+ t.notOk(puppet.cache.isFreshWrite(PUPPET.types.Dirty.RoomMember, roomId, roomSnap), 'the bare room-level key must also be bumped so row-level write-backs are guarded');
143
+ await cleanup(puppet, token);
144
+ });
145
+ test('roomMemberRawPayload: a room-level dirty mid-fetch skips the row write-back', async (t) => {
146
+ const { puppet, token } = await makePuppet();
147
+ const roomId = 'room-member-race';
148
+ const contactId = 'member-race';
149
+ const response = new grpcPuppet.RoomMemberPayloadResponse();
150
+ response.setId(contactId);
151
+ response.setName('stale-member-from-inflight-fetch');
152
+ const { gate, entered, enter, release } = makeGate();
153
+ puppet._grpcManager = {
154
+ client: {
155
+ roomMemberPayload: (_req, cb) => {
156
+ enter();
157
+ void (async () => {
158
+ await gate;
159
+ cb(null, response);
160
+ })();
161
+ },
162
+ },
163
+ };
164
+ // Start the fetch: it snapshots the room-level gen before reading the
165
+ // row, then blocks on the gate.
166
+ const fetchPromise = puppet.roomMemberRawPayload(roomId, contactId);
167
+ await entered;
168
+ // A bare-roomId dirty lands mid-fetch: the whole member set is stale.
169
+ puppet.cache.bumpGen(PUPPET.types.Dirty.RoomMember, roomId);
170
+ await puppet.fastDirty({ payloadType: PUPPET.types.Dirty.RoomMember, payloadId: roomId });
171
+ release();
172
+ const returned = await fetchPromise;
173
+ t.equal(returned.id, contactId, 'raw fetch still returns its member payload to the caller');
174
+ const stored = await puppet._payloadStore.roomMember.get(roomId);
175
+ t.notOk(stored, 'stale in-flight member fetch must NOT re-create the row after the dirty');
176
+ await cleanup(puppet, token);
177
+ });
178
+ //# sourceMappingURL=dirty-gen-guard.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dirty-gen-guard.spec.js","sourceRoot":"","sources":["../../../tests/dirty-gen-guard.spec.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAC7B,OAAO,EAAE,MAAM,IAAI,CAAA;AACnB,OAAO,IAAI,MAAM,MAAM,CAAA;AACvB,OAAO,EAAE,MAAM,IAAI,CAAA;AAEnB,OAAO,KAAK,MAAM,MAAM,sBAAsB,CAAA;AAC9C,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEzD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAE7C,MAAM,UAAU,GAAG,KAAK,IAAI,EAAE;IAC5B,MAAM,KAAK,GAAG,uBAAuB,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,CAAA;IACpF,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,KAAK,EAAE,CAAQ,CAAA;IAClD,MAAM,SAAS,GAAG,WAAW,CAAA;IAC7B,MAAM,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IAC3C,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;AAC1B,CAAC,CAAA;AAED,MAAM,OAAO,GAAG,KAAK,EAAE,MAAW,EAAE,KAAa,EAAE,EAAE;IACnD,MAAM,MAAM,CAAC,aAAa,CAAC,IAAI,EAAE,CAAA;IACjC,IAAI;QACF,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAClB,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,wBAAwB,EAAE,KAAK,CAAC,EACpE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CACjC,CAAA;KACF;IAAC,OAAO,CAAC,EAAE,EAAE,YAAY,EAAE;AAC9B,CAAC,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,QAAQ,GAAG,GAAG,EAAE;IACpB,IAAI,OAAO,GAAe,GAAG,EAAE,GAAE,CAAC,CAAA;IAClC,IAAI,WAAW,GAAe,GAAG,EAAE,GAAE,CAAC,CAAA;IACtC,MAAM,IAAI,GAAM,IAAI,OAAO,CAAO,OAAO,CAAC,EAAE,GAAG,OAAO,GAAG,OAAO,CAAA,CAAC,CAAC,CAAC,CAAA;IACnE,MAAM,OAAO,GAAG,IAAI,OAAO,CAAO,OAAO,CAAC,EAAE,GAAG,WAAW,GAAG,OAAO,CAAA,CAAC,CAAC,CAAC,CAAA;IACvE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,CAAA;AAChF,CAAC,CAAA;AAED,IAAI,CAAC,4EAA4E,EAAE,KAAK,EAAC,CAAC,EAAC,EAAE;IAC3F,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,EAAE,CAAA;IAE5C,MAAM,SAAS,GAAG,cAAc,CAAA;IAChC,MAAM,QAAQ,GAAG,IAAI,UAAU,CAAC,sBAAsB,EAAE,CAAA;IACxD,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAA;IAElD,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAA;IACpD,MAAM,CAAC,YAAY,GAAG;QACpB,MAAM,EAAE;YACN,cAAc,EAAE,CAAC,IAAS,EAAE,EAA8B,EAAE,EAAE;gBAC5D,KAAK,EAAE,CAAA;gBACP,KAAK,CAAC,KAAK,IAAI,EAAE;oBACf,MAAM,IAAI,CAAA;oBACV,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;gBACpB,CAAC,CAAC,EAAE,CAAA;YACN,CAAC;SACF;KACF,CAAA;IAED,kEAAkE;IAClE,MAAM,YAAY,GAAG,MAAM,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAA;IACxD,yEAAyE;IACzE,MAAM,OAAO,CAAA;IAEb,wEAAwE;IACxE,gDAAgD;IAChD,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;IAC3D,MAAM,MAAM,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC,CAAA;IAEzF,4DAA4D;IAC5D,OAAO,EAAE,CAAA;IACT,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAA;IAEnC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,mDAAmD,CAAC,CAAA;IACpF,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IAChE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,yEAAyE,CAAC,CAAA;IAE1F,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAC,CAAA;AAEF,IAAI,CAAC,oEAAoE,EAAE,KAAK,EAAC,CAAC,EAAC,EAAE;IACnF,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,EAAE,CAAA;IAE5C,MAAM,SAAS,GAAG,eAAe,CAAA;IACjC,MAAM,QAAQ,GAAG,IAAI,UAAU,CAAC,sBAAsB,EAAE,CAAA;IACxD,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,YAAY,CAAC,CAAA;IAE9B,MAAM,CAAC,YAAY,GAAG;QACpB,MAAM,EAAE;YACN,cAAc,EAAE,CAAC,IAAS,EAAE,EAA8B,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC;SAClF;KACF,CAAA;IAED,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAA;IAC1D,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,+BAA+B,CAAC,CAAA;IAEhE,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IAChE,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,iEAAiE,CAAC,CAAA;IAC/E,CAAC,CAAC,KAAK,CAAC,MAAM,IAAI,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,uCAAuC,CAAC,CAAA;IAEhF,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAC,CAAA;AAEF,IAAI,CAAC,uFAAuF,EAAE,KAAK,EAAC,CAAC,EAAC,EAAE;IACtG,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,EAAE,CAAA;IAE5C,MAAM,SAAS,GAAG,cAAc,CAAA;IAChC,2DAA2D;IAC3D,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;IAEhF,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO;QACvC,SAAS,EAAI,SAAS;KACvB,CAAC,CAAA;IACF,MAAM,MAAM,CAAC,iBAAiB,CAAC;QAC7B,OAAO,EAAK,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,gBAAgB;QACvD,UAAU,EAAE,GAAG,EAAE,CAAC,SAAS;QAC3B,MAAM,EAAM,GAAG,EAAE,CAAC,CAAC;KACpB,CAAC,CAAA;IAEF,CAAC,CAAC,KAAK,CACL,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,CAAC,EAC1E,yEAAyE,CAC1E,CAAA;IAED,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAC,CAAA;AAEF,IAAI,CAAC,qFAAqF,EAAE,KAAK,EAAC,CAAC,EAAC,EAAE;IACpG,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,EAAE,CAAA;IAE5C,MAAM,MAAM,GAAO,WAAW,CAAA;IAC9B,MAAM,QAAQ,GAAK,aAAa,CAAA;IAChC,MAAM,UAAU,GAAG,GAAG,MAAM,GAAG,MAAM,CAAC,eAAe,GAAG,QAAQ,EAAE,CAAA;IAElE,MAAM,YAAY,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,UAAU,CAAC,CAAA;IACxF,MAAM,QAAQ,GAAO,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IAEpF,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU;QAC1C,SAAS,EAAI,UAAU;KACxB,CAAC,CAAA;IACF,MAAM,MAAM,CAAC,iBAAiB,CAAC;QAC7B,OAAO,EAAK,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,gBAAgB;QACvD,UAAU,EAAE,GAAG,EAAE,CAAC,SAAS;QAC3B,MAAM,EAAM,GAAG,EAAE,CAAC,CAAC;KACpB,CAAC,CAAA;IAEF,CAAC,CAAC,KAAK,CACL,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,UAAU,EAAE,YAAY,CAAC,EAClF,uDAAuD,CACxD,CAAA;IACD,CAAC,CAAC,KAAK,CACL,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,CAAC,EAC1E,kFAAkF,CACnF,CAAA;IAED,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAC,CAAA;AAEF,IAAI,CAAC,6EAA6E,EAAE,KAAK,EAAC,CAAC,EAAC,EAAE;IAC5F,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,EAAE,CAAA;IAE5C,MAAM,MAAM,GAAM,kBAAkB,CAAA;IACpC,MAAM,SAAS,GAAG,aAAa,CAAA;IAE/B,MAAM,QAAQ,GAAG,IAAI,UAAU,CAAC,yBAAyB,EAAE,CAAA;IAC3D,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAA;IAEpD,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAA;IACpD,MAAM,CAAC,YAAY,GAAG;QACpB,MAAM,EAAE;YACN,iBAAiB,EAAE,CAAC,IAAS,EAAE,EAA8B,EAAE,EAAE;gBAC/D,KAAK,EAAE,CAAA;gBACP,KAAK,CAAC,KAAK,IAAI,EAAE;oBACf,MAAM,IAAI,CAAA;oBACV,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;gBACpB,CAAC,CAAC,EAAE,CAAA;YACN,CAAC;SACF;KACF,CAAA;IAED,sEAAsE;IACtE,gCAAgC;IAChC,MAAM,YAAY,GAAG,MAAM,CAAC,oBAAoB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACnE,MAAM,OAAO,CAAA;IAEb,sEAAsE;IACtE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IAC3D,MAAM,MAAM,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAA;IAEzF,OAAO,EAAE,CAAA;IACT,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAA;IAEnC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,0DAA0D,CAAC,CAAA;IAC3F,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IAChE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,yEAAyE,CAAC,CAAA;IAE1F,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC9B,CAAC,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juzi/wechaty-puppet-service",
3
- "version": "1.0.124",
3
+ "version": "1.0.125",
4
4
  "description": "Puppet Service for Wechaty",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  "@chatie/eslint-config": "^1.0.4",
52
52
  "@chatie/semver": "^0.4.7",
53
53
  "@chatie/tsconfig": "^4.6.3",
54
- "@juzi/wechaty-puppet": "^1.0.148",
54
+ "@juzi/wechaty-puppet": "^1.0.150",
55
55
  "@juzi/wechaty-puppet-mock": "^1.0.1",
56
56
  "@swc/core": "1.3.39",
57
57
  "@types/google-protobuf": "^3.15.5",
@@ -71,7 +71,7 @@
71
71
  "why-is-node-running": "^2.2.1"
72
72
  },
73
73
  "peerDependencies": {
74
- "@juzi/wechaty-puppet": "^1.0.148"
74
+ "@juzi/wechaty-puppet": "^1.0.150"
75
75
  },
76
76
  "dependencies": {
77
77
  "@juzi/wechaty-grpc": "^1.0.106",
@@ -385,6 +385,35 @@ class PuppetService extends PUPPET.Puppet {
385
385
  break
386
386
  case grpcPuppet.EventType.EVENT_TYPE_DIRTY: {
387
387
  const dirtyPayload = JSON.parse(payload) as PUPPET.payloads.EventDirty
388
+ /**
389
+ * Bump the generation counter *before* awaiting fastDirty so the
390
+ * whole fastDirty window is covered: any raw fetch that started
391
+ * before this dirty and resolves while fastDirty is deleting the
392
+ * FlashStore row must be judged stale by `isFreshWrite` and skip
393
+ * its write-back -- otherwise it re-poisons the row we just
394
+ * cleared and the value only refreshes after a *second* dirty.
395
+ *
396
+ * Upstream cache-mixin.onDirty bumps the same (type, id) again
397
+ * after `emit('dirty')` below; double-bumping is harmless because
398
+ * the counter only moves forward and `isFreshWrite` compares by
399
+ * equality.
400
+ */
401
+ this.cache.bumpGen(dirtyPayload.payloadType, dirtyPayload.payloadId)
402
+ /**
403
+ * A compound RoomMember dirty (`${roomId}${SEP}${memberId}`) must
404
+ * also bump the bare roomId key: roomMember write-backs are
405
+ * row-level read-modify-writes keyed by roomId, so a single-member
406
+ * dirty has to move that key too or an in-flight roomMember fetch
407
+ * could still write the whole row back stale.
408
+ */
409
+ if (dirtyPayload.payloadType === PUPPET.types.Dirty.RoomMember
410
+ && dirtyPayload.payloadId.includes(PUPPET.STRING_SPLITTER)
411
+ ) {
412
+ const [ roomId ] = dirtyPayload.payloadId.split(PUPPET.STRING_SPLITTER)
413
+ if (roomId) {
414
+ this.cache.bumpGen(PUPPET.types.Dirty.RoomMember, roomId)
415
+ }
416
+ }
388
417
  await this.fastDirty(dirtyPayload)
389
418
  this.emit('dirty', dirtyPayload)
390
419
  break
@@ -855,6 +884,10 @@ class PuppetService extends PUPPET.Puppet {
855
884
  return cachedPayload
856
885
  }
857
886
 
887
+ // Snapshot the gen before the raw fetch so a dirty that lands while
888
+ // the gRPC round-trip is in flight makes the write-back below stale.
889
+ const gen = this.cache.snapshotGen(PUPPET.types.Dirty.Contact, id)
890
+
858
891
  const request = new grpcPuppet.ContactPayloadRequest()
859
892
  request.setId(id)
860
893
 
@@ -897,8 +930,12 @@ class PuppetService extends PUPPET.Puppet {
897
930
  aka : response.getAka(),
898
931
  }
899
932
 
900
- await this._payloadStore.contact?.set(id, payload)
901
- this.log.silly('PuppetService', 'contactRawPayload(%s) cache SET', id)
933
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.Contact, id, gen)) {
934
+ await this._payloadStore.contact?.set(id, payload)
935
+ this.log.silly('PuppetService', 'contactRawPayload(%s) cache SET', id)
936
+ } else {
937
+ this.log.silly('PuppetService', 'contactRawPayload(%s) cache SET skipped: dirty landed during raw fetch', id)
938
+ }
902
939
 
903
940
  return payload
904
941
  }
@@ -926,6 +963,14 @@ class PuppetService extends PUPPET.Puppet {
926
963
 
927
964
  if (needGetSet.size > 0) {
928
965
  try {
966
+ // Snapshot each id's gen before the batch fetch; write-backs below
967
+ // are validated per id so a dirty on one contact mid-flight only
968
+ // skips that contact's stale write, not the whole batch.
969
+ const genSnap = new Map<string, number>()
970
+ for (const contactId of needGetSet) {
971
+ genSnap.set(contactId, this.cache.snapshotGen(PUPPET.types.Dirty.Contact, contactId))
972
+ }
973
+
929
974
  const request = new grpcPuppet.BatchContactPayloadRequest()
930
975
  request.setIdsList(Array.from(needGetSet))
931
976
 
@@ -939,7 +984,11 @@ class PuppetService extends PUPPET.Puppet {
939
984
  const contactId = payload.getId()
940
985
  const puppetPayload = contactPbToPayload(payload)
941
986
  result.set(contactId, puppetPayload)
942
- await this._payloadStore.contact?.set(contactId, puppetPayload)
987
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.Contact, contactId, genSnap.get(contactId) ?? 0)) {
988
+ await this._payloadStore.contact?.set(contactId, puppetPayload)
989
+ } else {
990
+ this.log.silly('PuppetService', 'batchContactRawPayload(%s) cache SET skipped: dirty landed during raw fetch', contactId)
991
+ }
943
992
  }
944
993
  } catch (e) {
945
994
  this.log.error('PuppetService', 'batchContactRawPayload(%s, %s) error: %s, use one by one method', contactIdList, needGetSet, e)
@@ -2303,6 +2352,10 @@ class PuppetService extends PUPPET.Puppet {
2303
2352
  return cachedPayload
2304
2353
  }
2305
2354
 
2355
+ // Snapshot the gen before the raw fetch so a dirty that lands while
2356
+ // the gRPC round-trip is in flight makes the write-back below stale.
2357
+ const gen = this.cache.snapshotGen(PUPPET.types.Dirty.Room, id)
2358
+
2306
2359
  const request = new grpcPuppet.RoomPayloadRequest()
2307
2360
  request.setId(id)
2308
2361
 
@@ -2329,8 +2382,12 @@ class PuppetService extends PUPPET.Puppet {
2329
2382
  payload.createTime = millisecondsFromTimestamp(createTime)
2330
2383
  }
2331
2384
 
2332
- await this._payloadStore.room?.set(id, payload)
2333
- this.log.silly('PuppetService', 'roomRawPayload(%s) cache SET', id)
2385
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.Room, id, gen)) {
2386
+ await this._payloadStore.room?.set(id, payload)
2387
+ this.log.silly('PuppetService', 'roomRawPayload(%s) cache SET', id)
2388
+ } else {
2389
+ this.log.silly('PuppetService', 'roomRawPayload(%s) cache SET skipped: dirty landed during raw fetch', id)
2390
+ }
2334
2391
 
2335
2392
  return payload
2336
2393
  }
@@ -2358,6 +2415,14 @@ class PuppetService extends PUPPET.Puppet {
2358
2415
 
2359
2416
  if (needGetSet.size > 0) {
2360
2417
  try {
2418
+ // Snapshot each id's gen before the batch fetch; write-backs below
2419
+ // are validated per id so a dirty on one room mid-flight only skips
2420
+ // that room's stale write, not the whole batch.
2421
+ const genSnap = new Map<string, number>()
2422
+ for (const roomId of needGetSet) {
2423
+ genSnap.set(roomId, this.cache.snapshotGen(PUPPET.types.Dirty.Room, roomId))
2424
+ }
2425
+
2361
2426
  const request = new grpcPuppet.BatchRoomPayloadRequest()
2362
2427
  request.setIdsList(Array.from(needGetSet))
2363
2428
 
@@ -2386,7 +2451,11 @@ class PuppetService extends PUPPET.Puppet {
2386
2451
  puppetPayload.createTime = millisecondsFromTimestamp(createTime)
2387
2452
  }
2388
2453
  result.set(roomId, puppetPayload)
2389
- await this._payloadStore.room?.set(roomId, puppetPayload)
2454
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.Room, roomId, genSnap.get(roomId) ?? 0)) {
2455
+ await this._payloadStore.room?.set(roomId, puppetPayload)
2456
+ } else {
2457
+ this.log.silly('PuppetService', 'batchRoomRawPayload(%s) cache SET skipped: dirty landed during raw fetch', roomId)
2458
+ }
2390
2459
  }
2391
2460
  } catch (e) {
2392
2461
  this.log.error('PuppetService', 'batchRoomRawPayload(%s, %s) error: %s, use one by one method', roomIdList, needGetSet, e)
@@ -2666,6 +2735,13 @@ class PuppetService extends PUPPET.Puppet {
2666
2735
  override async roomMemberRawPayload (roomId: string, contactId: string): Promise<PUPPET.payloads.RoomMember> {
2667
2736
  this.log.verbose('PuppetService', 'roomMemberRawPayload(%s, %s)', roomId, contactId)
2668
2737
 
2738
+ // Row-level read-modify-write: the write-back merges the fetched
2739
+ // member into the existing row, so snapshot the room-level gen before
2740
+ // even reading the row. A room-level dirty (bare roomId, or the
2741
+ // room-level bump a compound RoomMember dirty triggers) that lands
2742
+ // anywhere in this window then skips the merge write-back.
2743
+ const roomGen = this.cache.snapshotGen(PUPPET.types.Dirty.RoomMember, roomId)
2744
+
2669
2745
  const cachedPayload = await this._payloadStore.roomMember?.get(roomId)
2670
2746
  const cachedRoomMemberPayload = cachedPayload && cachedPayload[contactId]
2671
2747
 
@@ -2694,11 +2770,15 @@ class PuppetService extends PUPPET.Puppet {
2694
2770
  joinTime : response.getJoinTime(),
2695
2771
  }
2696
2772
 
2697
- await this._payloadStore.roomMember?.set(roomId, {
2698
- ...cachedPayload,
2699
- [contactId]: payload,
2700
- })
2701
- this.log.silly('PuppetService', 'roomMemberRawPayload(%s, %s) cache SET', roomId, contactId)
2773
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.RoomMember, roomId, roomGen)) {
2774
+ await this._payloadStore.roomMember?.set(roomId, {
2775
+ ...cachedPayload,
2776
+ [contactId]: payload,
2777
+ })
2778
+ this.log.silly('PuppetService', 'roomMemberRawPayload(%s, %s) cache SET', roomId, contactId)
2779
+ } else {
2780
+ this.log.silly('PuppetService', 'roomMemberRawPayload(%s, %s) cache SET skipped: dirty landed during raw fetch', roomId, contactId)
2781
+ }
2702
2782
 
2703
2783
  return payload
2704
2784
  }
@@ -2715,6 +2795,10 @@ class PuppetService extends PUPPET.Puppet {
2715
2795
  const result = new Map<string, PUPPET.payloads.RoomMember>()
2716
2796
  const contactIdSet = new Set<string>(contactIdList)
2717
2797
  let needGetSet = new Set<string>()
2798
+ // Row-level read-modify-write keyed by roomId: snapshot the room-level
2799
+ // gen before reading the row so a dirty landing anywhere in the window
2800
+ // skips the merged write-back.
2801
+ const roomGen = this.cache.snapshotGen(PUPPET.types.Dirty.RoomMember, roomId)
2718
2802
  const cachedPayload = await this._payloadStore.roomMember?.get(roomId) || {}
2719
2803
  if (Object.keys(cachedPayload).length > 0) {
2720
2804
  for (const contactId of contactIdSet) {
@@ -2747,7 +2831,11 @@ class PuppetService extends PUPPET.Puppet {
2747
2831
  result.set(contactId, puppetPayload)
2748
2832
  cachedPayload[contactId] = puppetPayload
2749
2833
  }
2750
- await this._payloadStore.roomMember?.set(roomId, cachedPayload)
2834
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.RoomMember, roomId, roomGen)) {
2835
+ await this._payloadStore.roomMember?.set(roomId, cachedPayload)
2836
+ } else {
2837
+ this.log.silly('PuppetService', 'batchRoomMemberRawPayload(%s) cache SET skipped: dirty landed during raw fetch', roomId)
2838
+ }
2751
2839
  } catch (e) {
2752
2840
  this.log.error('PuppetService', 'batchRoomMemberRawPayload(%s, %s) error: %s, use one by one method', roomId, needGetSet, e)
2753
2841
  for (const contactId of needGetSet) {
@@ -3393,6 +3481,10 @@ class PuppetService extends PUPPET.Puppet {
3393
3481
  return cachedPayload
3394
3482
  }
3395
3483
 
3484
+ // Snapshot the gen before the raw fetch so a dirty that lands while
3485
+ // the gRPC round-trip is in flight makes the write-back below stale.
3486
+ const gen = this.cache.snapshotGen(PUPPET.types.Dirty.TagGroup, id)
3487
+
3396
3488
  const request = new grpcPuppet.TagGroupPayloadRequest()
3397
3489
  request.setGroupId(id)
3398
3490
 
@@ -3412,8 +3504,12 @@ class PuppetService extends PUPPET.Puppet {
3412
3504
  type: grpcPayload.getType(),
3413
3505
  }
3414
3506
 
3415
- await this._payloadStore.tagGroup?.set(id, payload)
3416
- this.log.silly('PuppetService', 'tagGroupPayloadPuppet(%s) cache SET', id)
3507
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.TagGroup, id, gen)) {
3508
+ await this._payloadStore.tagGroup?.set(id, payload)
3509
+ this.log.silly('PuppetService', 'tagGroupPayloadPuppet(%s) cache SET', id)
3510
+ } else {
3511
+ this.log.silly('PuppetService', 'tagGroupPayloadPuppet(%s) cache SET skipped: dirty landed during raw fetch', id)
3512
+ }
3417
3513
 
3418
3514
  return payload
3419
3515
  }
@@ -3427,6 +3523,10 @@ class PuppetService extends PUPPET.Puppet {
3427
3523
  return cachedPayload
3428
3524
  }
3429
3525
 
3526
+ // Snapshot the gen before the raw fetch so a dirty that lands while
3527
+ // the gRPC round-trip is in flight makes the write-back below stale.
3528
+ const gen = this.cache.snapshotGen(PUPPET.types.Dirty.Tag, tagId)
3529
+
3430
3530
  const request = new grpcPuppet.TagPayloadRequest()
3431
3531
  request.setTagId(tagId)
3432
3532
 
@@ -3447,8 +3547,12 @@ class PuppetService extends PUPPET.Puppet {
3447
3547
  type: grpcPayload.getType(),
3448
3548
  }
3449
3549
 
3450
- await this._payloadStore.tag?.set(tagId, payload)
3451
- this.log.silly('PuppetService', 'tagPayloadPuppet(%s) cache SET', tagId)
3550
+ if (this.cache.isFreshWrite(PUPPET.types.Dirty.Tag, tagId, gen)) {
3551
+ await this._payloadStore.tag?.set(tagId, payload)
3552
+ this.log.silly('PuppetService', 'tagPayloadPuppet(%s) cache SET', tagId)
3553
+ } else {
3554
+ this.log.silly('PuppetService', 'tagPayloadPuppet(%s) cache SET skipped: dirty landed during raw fetch', tagId)
3555
+ }
3452
3556
 
3453
3557
  return payload
3454
3558
  }
@@ -4,7 +4,7 @@
4
4
  import type { PackageJson } from 'type-fest'
5
5
  export const packageJson: PackageJson = {
6
6
  "name": "@juzi/wechaty-puppet-service",
7
- "version": "1.0.124",
7
+ "version": "1.0.125",
8
8
  "description": "Puppet Service for Wechaty",
9
9
  "type": "module",
10
10
  "exports": {
@@ -55,7 +55,7 @@ export const packageJson: PackageJson = {
55
55
  "@chatie/eslint-config": "^1.0.4",
56
56
  "@chatie/semver": "^0.4.7",
57
57
  "@chatie/tsconfig": "^4.6.3",
58
- "@juzi/wechaty-puppet": "^1.0.148",
58
+ "@juzi/wechaty-puppet": "^1.0.150",
59
59
  "@juzi/wechaty-puppet-mock": "^1.0.1",
60
60
  "@swc/core": "1.3.39",
61
61
  "@types/google-protobuf": "^3.15.5",
@@ -75,7 +75,7 @@ export const packageJson: PackageJson = {
75
75
  "why-is-node-running": "^2.2.1"
76
76
  },
77
77
  "peerDependencies": {
78
- "@juzi/wechaty-puppet": "^1.0.148"
78
+ "@juzi/wechaty-puppet": "^1.0.150"
79
79
  },
80
80
  "dependencies": {
81
81
  "@juzi/wechaty-grpc": "^1.0.106",