|
40 | 40 | import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; |
41 | 41 | import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; |
42 | 42 | import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict; |
| 43 | +import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock; |
43 | 44 | import io.questdb.client.cutlass.qwp.client.sf.cursor.UnreplayableSlotException; |
44 | 45 | import io.questdb.client.impl.ConfStringParser; |
45 | 46 | import io.questdb.client.impl.ConfigString; |
@@ -1012,6 +1013,8 @@ final class LineSenderBuilder { |
1012 | 1013 | private static final int PROTOCOL_TCP = 0; |
1013 | 1014 | private static final int PROTOCOL_UDP = 3; |
1014 | 1015 | private static final int PROTOCOL_WEBSOCKET = 2; |
| 1016 | + @TestOnly |
| 1017 | + private static volatile Runnable quarantineAfterCloseHook; |
1015 | 1018 | // Suffix for a slot set aside by quarantineTornSlot. Deliberately NOT the |
1016 | 1019 | // sender's own slot name, so a restarted sender does not re-adopt it as its own; |
1017 | 1020 | // quarantineTornSlot then marks it .failed, so the orphan drainer skips it too and |
@@ -1559,91 +1562,101 @@ public Sender build() { |
1559 | 1562 | sfAppendDeadlineMillis == PARAMETER_NOT_SET_EXPLICITLY |
1560 | 1563 | ? CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS |
1561 | 1564 | : sfAppendDeadlineMillis * 1_000_000L; |
1562 | | - CursorSendEngine cursorEngine = new CursorSendEngine( |
1563 | | - slotPath, actualSfMaxBytes, |
1564 | | - actualSfMaxTotalBytes, actualSfAppendDeadlineNanos); |
1565 | | - int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY |
1566 | | - ? errorInboxCapacity |
1567 | | - : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY; |
1568 | | - int actualConnectionListenerInboxCapacity = connectionListenerInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY |
1569 | | - ? connectionListenerInboxCapacity |
1570 | | - : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher.DEFAULT_CAPACITY; |
1571 | | - List<QwpWebSocketSender.Endpoint> wsEndpoints = |
1572 | | - new ArrayList<>(hosts.size()); |
1573 | | - for (int i = 0, n = hosts.size(); i < n; i++) { |
1574 | | - wsEndpoints.add(new QwpWebSocketSender.Endpoint(hosts.getQuick(i), ports.getQuick(i))); |
1575 | | - } |
1576 | | - // The recovery seed inside connect() is the authority on whether a recovered |
1577 | | - // slot can be replayed: it rebuilds the dictionary from its intact prefix and |
1578 | | - // then from the surviving frames' own delta sections, and throws |
1579 | | - // UnreplayableSlotException only once neither source holds the missing ids. |
1580 | | - // Quarantining on anything weaker would set aside slots that recovery can |
1581 | | - // still rescue, so build() waits for that verdict rather than pre-judging it. |
1582 | 1565 | QwpWebSocketSender connected = null; |
1583 | | - boolean quarantined = false; |
1584 | | - while (connected == null) { |
1585 | | - try { |
1586 | | - connected = QwpWebSocketSender.connect( |
1587 | | - wsEndpoints, |
1588 | | - wsTlsConfig, |
1589 | | - actualAutoFlushRows, |
1590 | | - actualAutoFlushBytes, |
1591 | | - actualAutoFlushIntervalNanos, |
1592 | | - wsAuthHeader, |
1593 | | - requestDurableAck, |
1594 | | - cursorEngine, |
1595 | | - actualCloseFlushTimeoutMillis, |
1596 | | - actualReconnectMaxDurationMillis, |
1597 | | - actualReconnectInitialBackoffMillis, |
1598 | | - actualReconnectMaxBackoffMillis, |
1599 | | - actualInitialConnectMode, |
1600 | | - errorHandler, |
1601 | | - actualErrorInboxCapacity, |
1602 | | - actualDurableAckKeepaliveIntervalMillis, |
1603 | | - authTimeoutMillis, |
1604 | | - connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis, |
1605 | | - connectionListener, |
1606 | | - actualConnectionListenerInboxCapacity, |
1607 | | - actualMaxFrameRejections, |
1608 | | - actualPoisonMinEscalationWindowMillis, |
1609 | | - actualCatchUpCapGapMinEscalationWindowMillis |
1610 | | - ); |
1611 | | - } catch (UnreplayableSlotException e) { |
1612 | | - // The one failure build() recovers from. The slot's frames reference ids |
1613 | | - // that nothing still holds, so they can never go on the wire -- but that is |
1614 | | - // no reason to take the producer down with them. Before this, the throw |
1615 | | - // escaped build() and, because senderId is stable and a not-fully-drained |
1616 | | - // slot is retained on close, every retry re-recovered the same slot and |
1617 | | - // threw again: the application could not construct a Sender at all, so it |
1618 | | - // could not even BUFFER new rows. An already-lost batch became an unbounded |
1619 | | - // outage of everything after it. |
1620 | | - // |
1621 | | - // Set the slot aside instead, keep its bytes for forensics and resend, and |
1622 | | - // start the producer on a clean one. Once only: a second such failure would |
1623 | | - // mean the FRESH slot is unreplayable, which cannot happen, so let it out |
1624 | | - // rather than loop. |
1625 | | - if (quarantined || slotPath == null) { |
| 1566 | + // The parent-anchored logical lock is stable across a slot rename. Keep it |
| 1567 | + // from before the directory-local lock is acquired until connect() has either |
| 1568 | + // adopted that engine or quarantine has closed, renamed and recreated it. |
| 1569 | + // This closes the inode-swap window in which an already-queued orphan drainer |
| 1570 | + // could otherwise acquire the renamed directory's old .lock and later operate |
| 1571 | + // on the fresh slot through the original pathname. |
| 1572 | + try (SlotLock logicalSlotLock = slotPath == null |
| 1573 | + ? null |
| 1574 | + : SlotLock.acquireLogical(slotPath)) { |
| 1575 | + CursorSendEngine cursorEngine = new CursorSendEngine( |
| 1576 | + slotPath, actualSfMaxBytes, |
| 1577 | + actualSfMaxTotalBytes, actualSfAppendDeadlineNanos); |
| 1578 | + int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY |
| 1579 | + ? errorInboxCapacity |
| 1580 | + : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY; |
| 1581 | + int actualConnectionListenerInboxCapacity = connectionListenerInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY |
| 1582 | + ? connectionListenerInboxCapacity |
| 1583 | + : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher.DEFAULT_CAPACITY; |
| 1584 | + List<QwpWebSocketSender.Endpoint> wsEndpoints = |
| 1585 | + new ArrayList<>(hosts.size()); |
| 1586 | + for (int i = 0, n = hosts.size(); i < n; i++) { |
| 1587 | + wsEndpoints.add(new QwpWebSocketSender.Endpoint(hosts.getQuick(i), ports.getQuick(i))); |
| 1588 | + } |
| 1589 | + // The recovery seed inside connect() is the authority on whether a recovered |
| 1590 | + // slot can be replayed: it rebuilds the dictionary from its intact prefix and |
| 1591 | + // then from the surviving frames' own delta sections, and throws |
| 1592 | + // UnreplayableSlotException only once neither source holds the missing ids. |
| 1593 | + // Quarantining on anything weaker would set aside slots that recovery can |
| 1594 | + // still rescue, so build() waits for that verdict rather than pre-judging it. |
| 1595 | + boolean quarantined = false; |
| 1596 | + while (connected == null) { |
1626 | 1597 | try { |
1627 | | - cursorEngine.close(); |
1628 | | - } catch (Throwable ignored) { |
1629 | | - // best-effort |
| 1598 | + connected = QwpWebSocketSender.connect( |
| 1599 | + wsEndpoints, |
| 1600 | + wsTlsConfig, |
| 1601 | + actualAutoFlushRows, |
| 1602 | + actualAutoFlushBytes, |
| 1603 | + actualAutoFlushIntervalNanos, |
| 1604 | + wsAuthHeader, |
| 1605 | + requestDurableAck, |
| 1606 | + cursorEngine, |
| 1607 | + actualCloseFlushTimeoutMillis, |
| 1608 | + actualReconnectMaxDurationMillis, |
| 1609 | + actualReconnectInitialBackoffMillis, |
| 1610 | + actualReconnectMaxBackoffMillis, |
| 1611 | + actualInitialConnectMode, |
| 1612 | + errorHandler, |
| 1613 | + actualErrorInboxCapacity, |
| 1614 | + actualDurableAckKeepaliveIntervalMillis, |
| 1615 | + authTimeoutMillis, |
| 1616 | + connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis, |
| 1617 | + connectionListener, |
| 1618 | + actualConnectionListenerInboxCapacity, |
| 1619 | + actualMaxFrameRejections, |
| 1620 | + actualPoisonMinEscalationWindowMillis, |
| 1621 | + actualCatchUpCapGapMinEscalationWindowMillis |
| 1622 | + ); |
| 1623 | + } catch (UnreplayableSlotException e) { |
| 1624 | + // The one failure build() recovers from. The slot's frames reference ids |
| 1625 | + // that nothing still holds, so they can never go on the wire -- but that is |
| 1626 | + // no reason to take the producer down with them. Before this, the throw |
| 1627 | + // escaped build() and, because senderId is stable and a not-fully-drained |
| 1628 | + // slot is retained on close, every retry re-recovered the same slot and |
| 1629 | + // threw again: the application could not construct a Sender at all, so it |
| 1630 | + // could not even BUFFER new rows. An already-lost batch became an unbounded |
| 1631 | + // outage of everything after it. |
| 1632 | + // |
| 1633 | + // Set the slot aside instead, keep its bytes for forensics and resend, and |
| 1634 | + // start the producer on a clean one. Once only: a second such failure would |
| 1635 | + // mean the FRESH slot is unreplayable, which cannot happen, so let it out |
| 1636 | + // rather than loop. |
| 1637 | + if (quarantined || slotPath == null) { |
| 1638 | + try { |
| 1639 | + cursorEngine.close(); |
| 1640 | + } catch (Throwable ignored) { |
| 1641 | + // best-effort |
| 1642 | + } |
| 1643 | + throw e; |
| 1644 | + } |
| 1645 | + quarantined = true; |
| 1646 | + cursorEngine = quarantineTornSlot( |
| 1647 | + cursorEngine, e, sfDir, senderId, slotPath, actualSfMaxBytes, |
| 1648 | + actualSfMaxTotalBytes, actualSfAppendDeadlineNanos); |
| 1649 | + } catch (Throwable t) { |
| 1650 | + // connect() failed before ownership of cursorEngine |
| 1651 | + // transferred — close it ourselves. |
| 1652 | + try { |
| 1653 | + cursorEngine.close(); |
| 1654 | + } catch (Throwable ignored) { |
| 1655 | + // best-effort |
| 1656 | + } |
| 1657 | + throw t; |
1630 | 1658 | } |
1631 | | - throw e; |
1632 | 1659 | } |
1633 | | - quarantined = true; |
1634 | | - cursorEngine = quarantineTornSlot( |
1635 | | - cursorEngine, e, sfDir, senderId, slotPath, actualSfMaxBytes, |
1636 | | - actualSfMaxTotalBytes, actualSfAppendDeadlineNanos); |
1637 | | - } catch (Throwable t) { |
1638 | | - // connect() failed before ownership of cursorEngine |
1639 | | - // transferred — close it ourselves. |
1640 | | - try { |
1641 | | - cursorEngine.close(); |
1642 | | - } catch (Throwable ignored) { |
1643 | | - // best-effort |
1644 | | - } |
1645 | | - throw t; |
1646 | | - } |
1647 | 1660 | } |
1648 | 1661 | // connect() succeeded — `connected` now owns cursorEngine |
1649 | 1662 | // via setCursorEngine(engine, true). From here on, ANY |
@@ -3047,6 +3060,10 @@ private static CursorSendEngine quarantineTornSlot( |
3047 | 3060 | // path already closed the engine; close() is idempotent, so make it explicit rather |
3048 | 3061 | // than depend on that. |
3049 | 3062 | torn.close(); |
| 3063 | + Runnable hook = quarantineAfterCloseHook; |
| 3064 | + if (hook != null) { |
| 3065 | + hook.run(); |
| 3066 | + } |
3050 | 3067 |
|
3051 | 3068 | String quarantinePath = null; |
3052 | 3069 | for (int i = 0; i < MAX_QUARANTINE_SLOT_ATTEMPTS; i++) { |
@@ -3074,6 +3091,11 @@ private static CursorSendEngine quarantineTornSlot( |
3074 | 3091 | return new CursorSendEngine(slotPath, sfMaxBytes, sfMaxTotalBytes, sfAppendDeadlineNanos); |
3075 | 3092 | } |
3076 | 3093 |
|
| 3094 | + @TestOnly |
| 3095 | + public static void setQuarantineAfterCloseHookForTest(Runnable hook) { |
| 3096 | + quarantineAfterCloseHook = hook; |
| 3097 | + } |
| 3098 | + |
3077 | 3099 | private static int resolveIPv4(String host) { |
3078 | 3100 | try { |
3079 | 3101 | byte[] addr = InetAddress.getByName(host).getAddress(); |
|
0 commit comments