Skip to content

Commit 428a96a

Browse files
committed
feat: add explicit exchange commit and discard
1 parent 8859bc2 commit 428a96a

10 files changed

Lines changed: 407 additions & 149 deletions

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ func main() {
7272
if err != nil {
7373
panic(err)
7474
}
75+
// Releases the capture on every early-return path. Once Commit succeeds,
76+
// this deferred Discard cannot change the decision.
77+
defer exchange.Discard(context.Background())
7578
// Process response
7679
_, _ = io.Copy(io.Discard, exchange.Response.Body)
7780
_ = exchange.Response.Body.Close()
78-
// Will block until records are actually written to the WARC file
79-
if _, err := exchange.Wait(context.Background()); err != nil {
81+
// Keep the exchange and wait until its records reach the WARC writer.
82+
if _, err := exchange.Commit(context.Background()); err != nil {
8083
panic(err)
8184
}
8285
finalized, err := client.Shutdown(context.Background())
@@ -91,7 +94,9 @@ HTTP/1 captures contain the plaintext HTTP/1 wire bytes seen by the transport.
9194
HTTP/2 and HTTP/3 captures are deterministic `application/http`
9295
serializations of the actual stream headers, body data, and trailers.
9396
Closing a response body early performs a bounded drain; if the message boundary
94-
cannot be reached, `Exchange.Wait` reports a truncated attempt.
97+
cannot be reached, `Exchange.Commit` reports a truncated attempt. To reject a
98+
capture after inspecting its response, call `Exchange.Discard`; it closes any
99+
unread response body and releases the captured temporary data.
95100

96101
HTTP exchanges are written in request-then-response order by default. Set
97102
`rotator.UseInternetArchiveRecordOrder = true` for IA-compatible

capture_recorder.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,21 @@ func (a *transportCaptureAttempt) Finish(result fhttp.CaptureAttemptResult) {
6767
// A capture-sink failure cannot produce a trustworthy WARC record.
6868
// Truncated network responses, however, are retained as partial records.
6969
if result.Outcome == fhttp.CaptureOutcomeFailed {
70-
err := errors.Join(attemptErr, a.request.close(), a.response.close())
71-
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: err})
70+
cleanupErr := errors.Join(a.request.close(), a.response.close())
71+
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: attemptErr, cleanupErr: cleanupErr})
7272
return
7373
}
7474

7575
requestFile, err := a.request.committedFile()
7676
if err != nil {
77-
_ = a.response.close()
78-
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: err})
77+
cleanupErr := errors.Join(a.request.close(), a.response.close())
78+
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: err, cleanupErr: cleanupErr})
7979
return
8080
}
8181
responseFile, err := a.response.committedFile()
8282
if err != nil {
83-
_ = requestFile.Close()
84-
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: err})
83+
cleanupErr := errors.Join(requestFile.Close(), a.response.close())
84+
a.state.finishAttempt(AttemptResult{Protocol: string(a.meta.Protocol), Outcome: result.Outcome, Err: err, cleanupErr: cleanupErr})
8585
return
8686
}
8787

@@ -96,8 +96,13 @@ func (a *transportCaptureAttempt) Finish(result fhttp.CaptureAttemptResult) {
9696
pi.Protocol = "http/1.1"
9797
}
9898
// Record construction and WARC I/O can outlive RoundTrip. Keep them off
99-
// the transport goroutine; Exchange.Wait owns the durable completion.
99+
// the transport goroutine; Exchange.Commit owns durable completion.
100100
go func() {
101+
if a.state.waitForDecision() == exchangeDiscard {
102+
closeErr := errors.Join(requestFile.Close(), responseFile.Close())
103+
a.state.finishAttempt(AttemptResult{Protocol: pi.Protocol, Outcome: result.Outcome, cleanupErr: closeErr})
104+
return
105+
}
101106
events, writeErr := a.owner.writeCapturedExchange(a.meta.Request.Context(), a.meta.Request.URL.Scheme, requestFile, responseFile, pi, result)
102107
a.state.finishAttempt(AttemptResult{Protocol: pi.Protocol, Outcome: result.Outcome, Records: events, Err: errors.Join(attemptErr, writeErr)})
103108
}()

client.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ type CustomHTTPClient struct {
9898
shutdownOnce sync.Once
9999
shutdownDone chan struct{}
100100
shutdownResult FinalizeResult
101+
pendingExchanges map[*exchangeState]struct{}
101102

102103
CDXDedupeTotalBytes *atomic.Int64
103104
DoppelgangerDedupeTotalBytes *atomic.Int64
@@ -131,8 +132,16 @@ func (c *CustomHTTPClient) Shutdown(ctx context.Context) (FinalizeResult, error)
131132
c.shutdownOnce.Do(func() {
132133
c.lifecycleMu.Lock()
133134
c.closing = true
135+
pending := make([]*exchangeState, 0, len(c.pendingExchanges))
136+
for state := range c.pendingExchanges {
137+
pending = append(pending, state)
138+
}
134139
c.lifecycleMu.Unlock()
135-
go c.runShutdown()
140+
stopTransportEarly := false
141+
for _, state := range pending {
142+
stopTransportEarly = state.closeForShutdown() || stopTransportEarly
143+
}
144+
go c.runShutdown(stopTransportEarly)
136145
})
137146
select {
138147
case <-c.shutdownDone:
@@ -144,13 +153,17 @@ func (c *CustomHTTPClient) Shutdown(ctx context.Context) (FinalizeResult, error)
144153
}
145154
}
146155

147-
func (c *CustomHTTPClient) runShutdown() {
156+
func (c *CustomHTTPClient) runShutdown(stopTransportEarly bool) {
148157
defer close(c.shutdownDone)
149158
var wg sync.WaitGroup
150-
if c.protoClient != nil {
159+
if stopTransportEarly && c.protoClient != nil {
151160
c.protoClient.Shutdown()
152161
}
153162
c.WaitGroup.Wait()
163+
c.compatWG.Wait()
164+
if !stopTransportEarly && c.protoClient != nil {
165+
c.protoClient.Shutdown()
166+
}
154167

155168
close(c.WARCWriter)
156169

@@ -171,7 +184,6 @@ func (c *CustomHTTPClient) runShutdown() {
171184
finalizedFiles = append(finalizedFiles, result.FinalizedFiles...)
172185
}
173186

174-
c.compatWG.Wait()
175187
close(c.ErrChan)
176188

177189
if c.randomLocalIP {
@@ -197,14 +209,14 @@ func (c *CustomHTTPClient) Do(req *http.Request) (*http.Response, error) {
197209
}
198210
c.compatWG.Add(1)
199211
c.lifecycleMu.Unlock()
200-
exchange, err := c.Start(req)
212+
exchange, err := c.start(req, exchangeCommit)
201213
if exchange == nil {
202214
c.compatWG.Done()
203215
return nil, err
204216
}
205217
go func() {
206218
defer c.compatWG.Done()
207-
result, _ := exchange.Wait(context.Background())
219+
result, _ := exchange.wait(context.Background())
208220
var archiveErrs []error
209221
for _, attempt := range result.Attempts {
210222
archiveErrs = append(archiveErrs, attempt.Err)
@@ -214,16 +226,20 @@ func (c *CustomHTTPClient) Do(req *http.Request) (*http.Response, error) {
214226
return
215227
}
216228
select {
217-
case c.ErrChan <- &Error{Err: archiveErr, Func: "Exchange.Wait"}:
229+
case c.ErrChan <- &Error{Err: archiveErr, Func: "Exchange.Commit"}:
218230
default:
219231
}
220232
}()
221233
return exchange.Response, err
222234
}
223235

224-
// Start executes req and returns an Exchange whose Wait method reports the
225-
// durable archival result independently from receiving response headers.
236+
// Start executes req and returns an Exchange that the caller must finish with
237+
// Commit or Discard after inspecting the response.
226238
func (c *CustomHTTPClient) Start(req *http.Request) (*Exchange, error) {
239+
return c.start(req, exchangeUndecided)
240+
}
241+
242+
func (c *CustomHTTPClient) start(req *http.Request, decision exchangeDecision) (*Exchange, error) {
227243
if req == nil {
228244
return nil, errors.New("warc: nil request")
229245
}
@@ -245,18 +261,26 @@ func (c *CustomHTTPClient) Start(req *http.Request) (*Exchange, error) {
245261
return nil, errors.New("warc: feedback channel must be buffered")
246262
}
247263
}
248-
state := newExchangeState(c, feedback)
264+
state := newExchangeState(c, feedback, decision)
265+
c.pendingExchanges[state] = struct{}{}
249266
ctx := context.WithValue(req.Context(), exchangeContextKey{}, state)
250267
req = req.Clone(ctx)
251268
req.URL.Scheme = strings.ToLower(req.URL.Scheme)
252269
c.WaitGroup.Add(1)
253270
c.lifecycleMu.Unlock()
254271
resp, err := c.protoClient.Do(ctx, req)
255272
c.WaitGroup.Done()
273+
state.attachResponse(resp)
256274
exchange := &Exchange{Response: resp, state: state}
257275
return exchange, err
258276
}
259277

278+
func (c *CustomHTTPClient) unregisterExchange(state *exchangeState) {
279+
c.lifecycleMu.Lock()
280+
delete(c.pendingExchanges, state)
281+
c.lifecycleMu.Unlock()
282+
}
283+
260284
func (c *CustomHTTPClient) Get(url string) (*http.Response, error) {
261285
req, err := http.NewRequest(http.MethodGet, url, nil)
262286
if err != nil {
@@ -303,6 +327,7 @@ func NewWARCWritingHTTPClient(HTTPClientSettings HTTPClientSettings) (httpClient
303327
}
304328
httpClient = new(CustomHTTPClient)
305329
httpClient.shutdownDone = make(chan struct{})
330+
httpClient.pendingExchanges = make(map[*exchangeState]struct{})
306331

307332
httpClient.DataTotal = &DataTotal
308333

client_test.go

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,71 +1220,6 @@ func TestHTTPClientDedupeEmptyPayload(t *testing.T) {
12201220
}
12211221
}
12221222

1223-
func TestHTTPClientSaveChannel(t *testing.T) {
1224-
var (
1225-
rotatorSettings = defaultRotatorSettings(t)
1226-
errWg sync.WaitGroup
1227-
err error
1228-
)
1229-
1230-
// init test HTTP endpoint
1231-
server := newTestImageServer(t, http.StatusTooManyRequests)
1232-
defer server.Close()
1233-
1234-
// init the HTTP client responsible for recording HTTP(s) requests / responses
1235-
httpClient, err := NewWARCWritingHTTPClient(HTTPClientSettings{
1236-
RotatorSettings: rotatorSettings,
1237-
})
1238-
if err != nil {
1239-
t.Fatalf("Unable to init WARC writing HTTP client: %s", err)
1240-
}
1241-
1242-
errWg.Add(1)
1243-
go func() {
1244-
defer errWg.Done()
1245-
for err := range httpClient.ErrChan {
1246-
t.Errorf("Unexpected error: %v", err)
1247-
}
1248-
}()
1249-
1250-
saveChan := make(chan bool, 1)
1251-
1252-
req, err := http.NewRequest("GET", server.URL, nil)
1253-
if err != nil {
1254-
t.Fatal(err)
1255-
}
1256-
1257-
req = req.WithContext(WithSaveChannel(req.Context(), saveChan))
1258-
1259-
resp, err := httpClient.Do(req)
1260-
if err != nil {
1261-
t.Fatal(err)
1262-
}
1263-
1264-
if resp.StatusCode == http.StatusTooManyRequests {
1265-
close(saveChan)
1266-
} else {
1267-
saveChan <- true
1268-
}
1269-
1270-
_, err = io.Copy(io.Discard, resp.Body)
1271-
if err != nil {
1272-
t.Fatalf("Unexpected error reading response body: %v", err)
1273-
}
1274-
1275-
httpClient.Close()
1276-
1277-
files, err := filepath.Glob(rotatorSettings.OutputDirectory + "/*")
1278-
if err != nil {
1279-
t.Fatal(err)
1280-
}
1281-
1282-
for _, path := range files {
1283-
// note: we are actually expecting nothing here, as such, 0 for expected total. This may error if 429s aren't being filtered correctly!
1284-
testFileSingleHashCheck(t, path, "n/a", []string{"0"}, 0, server.URL+"/")
1285-
}
1286-
}
1287-
12881223
func TestHTTPClientPayloadLargerThan2MB(t *testing.T) {
12891224
var (
12901225
rotatorSettings = defaultRotatorSettings(t)

dialer.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,12 @@ type contextKey string
1919

2020
const (
2121
ContextKeyFeedback contextKey = "feedback"
22-
ContextKeySave contextKey = "save"
2322
)
2423

2524
func WithFeedbackChannel(ctx context.Context, feedbackChan chan FeedbackEvent) context.Context {
2625
return context.WithValue(ctx, ContextKeyFeedback, feedbackChan)
2726
}
2827

29-
func WithSaveChannel(ctx context.Context, ch chan bool) context.Context {
30-
return context.WithValue(ctx, ContextKeySave, ch)
31-
}
32-
33-
var errDiscarded = errors.New("response discarded")
34-
3528
type dnsExchanger interface {
3629
ExchangeContext(ctx context.Context, m *dns.Msg, address string) (r *dns.Msg, rtt time.Duration, err error)
3730
}

0 commit comments

Comments
 (0)