|
1 | 1 | require('../testUtils'); |
2 | 2 | const assert = require('assert'); |
3 | | -const { RecordEncoder } = require('#src/resources/RecordEncoder'); |
| 3 | +const { RecordEncoder, isMissingStructureError } = require('#src/resources/RecordEncoder'); |
| 4 | +const harperLogger = require('#src/utility/logging/harper_logger'); |
4 | 5 | const { Encoder } = require('msgpackr'); |
5 | 6 |
|
6 | 7 | // Shared structures persisted as an encoded buffer (mirrors how a DBI stores them under |
@@ -83,3 +84,71 @@ describe('RecordEncoder struct-mode gating', () => { |
83 | 84 | assert.strictEqual(decoded.indexed, record.indexed); |
84 | 85 | }); |
85 | 86 | }); |
| 87 | + |
| 88 | +describe('RecordEncoder missing-structure handling (harper#1163)', () => { |
| 89 | + let warnings, errors, restoreWarn, restoreError; |
| 90 | + beforeEach(() => { |
| 91 | + warnings = []; |
| 92 | + errors = []; |
| 93 | + restoreWarn = harperLogger.warn; |
| 94 | + restoreError = harperLogger.error; |
| 95 | + harperLogger.warn = (...args) => warnings.push(args); |
| 96 | + harperLogger.error = (...args) => errors.push(args); |
| 97 | + }); |
| 98 | + afterEach(() => { |
| 99 | + harperLogger.warn = restoreWarn; |
| 100 | + harperLogger.error = restoreError; |
| 101 | + }); |
| 102 | + |
| 103 | + it('returns null (non-fatal) and warns distinctly when a typed structure is absent on this node', () => { |
| 104 | + // A record references a typed (random-access) structure that this node's structures buffer does |
| 105 | + // not contain. structon's readStruct reloads from the (still-empty) store and then throws; |
| 106 | + // RecordEncoder must keep internal reads non-fatal (return null) while surfacing the dropped |
| 107 | + // record distinctly rather than via the generic error path. |
| 108 | + const writer = makeEncoder(true, sharedStore()); |
| 109 | + const bytes = Buffer.from(writer.encode(record)); |
| 110 | + |
| 111 | + // Reader on a different node that never received the structure-buffer update. |
| 112 | + const reader = makeEncoder(true, sharedStore()); |
| 113 | + assert.strictEqual(reader.decode(bytes), null, 'missing structure should decode to null, not throw'); |
| 114 | + assert.strictEqual(warnings.length, 1, 'should emit exactly one distinct warning'); |
| 115 | + assert.match(warnings[0][0], /shared structure missing/); |
| 116 | + assert.strictEqual(errors.length, 0, 'should not use the generic error path for a missing structure'); |
| 117 | + }); |
| 118 | + |
| 119 | + it('recovers (decodes, no throw) once the typed structure is present on this node', () => { |
| 120 | + // Writer and reader share the same structures store, so the reader's on-miss reload finds it. |
| 121 | + const store = sharedStore(); |
| 122 | + const writer = makeEncoder(true, store); |
| 123 | + const bytes = Buffer.from(writer.encode(record)); |
| 124 | + const reader = makeEncoder(true, store); |
| 125 | + const decoded = reader.decode(bytes); |
| 126 | + assert.ok(decoded, 'record should decode when the structure is available'); |
| 127 | + assert.strictEqual(decoded.name, record.name); |
| 128 | + }); |
| 129 | + |
| 130 | + it('detects both typed and classic missing-structure errors, and only those', () => { |
| 131 | + // classic-structure miss (msgpackr createSecondByteReader) is the relevant variant on 5.1 where |
| 132 | + // typed structs are off by default; we cannot easily manufacture a real classic shared-structure |
| 133 | + // miss in this harness, so assert the detection contract directly against the dependency's |
| 134 | + // terminal error messages. |
| 135 | + assert.ok(isMissingStructureError(new Error('Could not find typed structure 1'))); |
| 136 | + assert.ok(isMissingStructureError(new Error('Record id is not defined for 42'))); |
| 137 | + assert.ok(!isMissingStructureError(new Error('Data read, but end of buffer not reached 64'))); |
| 138 | + assert.ok(!isMissingStructureError(new RangeError('Offset is outside the bounds of the DataView'))); |
| 139 | + assert.ok(!isMissingStructureError(undefined)); |
| 140 | + }); |
| 141 | + |
| 142 | + it('still returns null (tolerant) for a decode failure that is not a missing structure', () => { |
| 143 | + // Truncate a valid struct-mode record mid-body: the structure IS present, but the buffer is too |
| 144 | + // short, so decoding throws a non-missing-structure error (e.g. out-of-bounds). That genuine |
| 145 | + // corruption keeps the existing log-and-null behavior. |
| 146 | + const store = sharedStore(); |
| 147 | + const enc = makeEncoder(true, store); |
| 148 | + const bytes = Buffer.from(enc.encode(record)); |
| 149 | + const truncated = bytes.subarray(0, 2); |
| 150 | + assert.strictEqual(enc.decode(truncated), null, 'corrupt (non-structure) decode should still return null'); |
| 151 | + assert.strictEqual(errors.length, 1, 'genuine corruption should use the generic error path'); |
| 152 | + assert.strictEqual(warnings.length, 0, 'genuine corruption should not use the missing-structure warning'); |
| 153 | + }); |
| 154 | +}); |
0 commit comments