diff --git a/db/crud.go b/db/crud.go index 122b935fcc..a23e8f1bc8 100644 --- a/db/crud.go +++ b/db/crud.go @@ -222,6 +222,62 @@ func (c *DatabaseCollection) GetDocSyncData(ctx context.Context, docid string) ( } +type ChannelHistory map[string]map[uint64]struct{} + +func (ch ChannelHistory) addChannelHistoryEntry(name string, seq uint64) { + if _, ok := ch[name]; !ok { + ch[name] = make(map[uint64]struct{}) + } + if _, ok := ch[name][seq]; !ok { + ch[name][seq] = struct{}{} + } +} + +func (ch ChannelHistory) getChannelHistoryAsMap() map[string][]uint64 { + response := make(map[string][]uint64) + for chanName, chanEntry := range ch { + response[chanName] = make([]uint64, 0) + for seq, _ := range chanEntry { + response[chanName] = append(response[chanName], seq) + } + slices.Sort(response[chanName]) + slices.Reverse(response[chanName]) + } + return response +} + +// GetDocChannelHistory returns the channel revocation history for the given document as a map +// from channel name to the sequences at which the document was removed from that channel. +// It collects revocation sequences from the active Channels map, the ChannelSet, and the +// ChannelSetHistory (overflow). Only channels that have been revoked at least once appear in +// the result; active memberships with no revocation history are omitted, even though a currently +// assigned channel can still appear if it was revoked and later re-added. +func (c *DatabaseCollection) GetDocChannelHistory(ctx context.Context, docid string) (map[string][]uint64, error) { + + chanHistory := make(ChannelHistory) + syncData, err := c.GetDocSyncData(ctx, docid) + if err != nil { + return nil, err + } + for chanName, chanVal := range syncData.Channels { + if chanVal != nil && chanVal.Seq != 0 { + chanHistory.addChannelHistoryEntry(chanName, chanVal.Seq) + } + } + for _, chanSetEntry := range syncData.ChannelSet { + if chanSetEntry.End != 0 { + chanHistory.addChannelHistoryEntry(chanSetEntry.Name, chanSetEntry.End) + } + } + for _, chanSetEntry := range syncData.ChannelSetHistory { + if chanSetEntry.End != 0 { + chanHistory.addChannelHistoryEntry(chanSetEntry.Name, chanSetEntry.End) + } + } + + return chanHistory.getChannelHistoryAsMap(), nil +} + // CompactDocChannelHistory removes channel history entries that ended at or before the given sequence number. // This is used to prune stale channel assignment history to reduce storage overhead. func (c *DatabaseCollection) CompactDocChannelHistory(ctx context.Context, docid string, seq uint64) ([]string, error) { @@ -258,12 +314,12 @@ func (c *DatabaseCollection) CompactDocChannelHistory(ctx context.Context, docid cas = doc.Cas } - compactedChannels := make([]string, 0) + compactedChannels := make(base.Set) doc.SyncData.ChannelSetHistory = slices.DeleteFunc(doc.SyncData.ChannelSetHistory, func(channel ChannelSetEntry) bool { del := channel.End <= seq if del { - compactedChannels = append(compactedChannels, channel.Name) + compactedChannels.Add(channel.Name) } return del }) @@ -271,14 +327,14 @@ func (c *DatabaseCollection) CompactDocChannelHistory(ctx context.Context, docid doc.SyncData.ChannelSet = slices.DeleteFunc(doc.SyncData.ChannelSet, func(channel ChannelSetEntry) bool { del := channel.End != 0 && channel.End <= seq if del { - compactedChannels = append(compactedChannels, channel.Name) + compactedChannels.Add(channel.Name) } return del }) for chanName, chanEntry := range doc.SyncData.Channels { if chanEntry != nil && chanEntry.Seq <= seq { - compactedChannels = append(compactedChannels, chanName) + compactedChannels.Add(chanName) delete(doc.SyncData.Channels, chanName) } } @@ -320,7 +376,9 @@ func (c *DatabaseCollection) CompactDocChannelHistory(ctx context.Context, docid base.MouXattrName: rawMouXattr, } _, err = c.dataStore.UpdateXattrs(ctx, key, 0, cas, updatedXattr, opts) - return compactedChannels, err + compactedChannelArray := compactedChannels.ToArray() + slices.Sort(compactedChannelArray) + return compactedChannelArray, err } // unmarshalDocumentWithXattrs populates individual xattrs on unmarshalDocumentWithXattrs from a provided xattrs map diff --git a/docs/api/admin.yaml b/docs/api/admin.yaml index adcedeea4c..5eeb893cd7 100644 --- a/docs/api/admin.yaml +++ b/docs/api/admin.yaml @@ -125,10 +125,10 @@ paths: $ref: './paths/admin/db-_index_init.yaml' '/{keyspace}/_purge': $ref: './paths/admin/keyspace-_purge.yaml' - '/{keyspace}/_history': - $ref: './paths/admin/keyspace-_history.yaml' - '/{keyspace}/_history/compact': - $ref: './paths/admin/keyspace-_history-compact.yaml' + '/{keyspace}/_channel_history/{docid}': + $ref: './paths/admin/keyspace-_channel_history.yaml' + '/{keyspace}/_channel_history/{docid}/compact': + $ref: './paths/admin/keyspace-_channel_history-compact.yaml' '/{db}/_flush': $ref: './paths/admin/db-_flush.yaml' '/{db}/_online': diff --git a/docs/api/paths/admin/keyspace-_history-compact.yaml b/docs/api/paths/admin/keyspace-_channel_history-compact.yaml similarity index 59% rename from docs/api/paths/admin/keyspace-_history-compact.yaml rename to docs/api/paths/admin/keyspace-_channel_history-compact.yaml index 3ae2a18a8a..01efbbba51 100644 --- a/docs/api/paths/admin/keyspace-_history-compact.yaml +++ b/docs/api/paths/admin/keyspace-_channel_history-compact.yaml @@ -7,14 +7,15 @@ # the file licenses/APL2.txt. parameters: - $ref: ../../components/parameters.yaml#/keyspace + - $ref: ../../components/parameters.yaml#/docid post: summary: Compact Channel History of Document description: |- - Compacts inactive channel history entries from one or more documents before a specified sequence number. + Compacts channel history for a specified document. Channel history older than the specified sequence will be removed. - This endpoint removes all inactive channel entries (for sequences before the specified sequence number where the document left the channel), + This endpoint removes all channel entries (for sequences before the specified sequence number where the document left the channel), effectively cleaning up historical channel membership information while preserving active channels and recent changes. - This is useful for reducing storage overhead when maintaining large document histories. + This can be useful for reducing metadata size for documents that frequently gain and lose access to channels. Required Sync Gateway RBAC roles: @@ -25,32 +26,22 @@ post: schema: type: object required: - - doc_ids - seq properties: - doc_ids: - description: |- - List of document IDs whose inactive channels should be compacted. - type: array - items: - type: string - example: - - doc1 - - doc2 seq: description: |- - Channel history for inactive channels having end sequences earlier than this sequence will be removed from the specified document's metadata. + Channel history having end sequences earlier than this sequence will be removed from the specified document's metadata. type: integer format: int64 - minimum: 0 + minimum: 1 example: 12345 responses: '200': description: |- - Successfully compacted channel history from the specified documents. - Returns a mapping of document IDs to the list of channels that were compacted. + Successfully compacted channel history from the specified document. + Returns a list of channels that were compacted. - If a document ID has an empty array, it means no channels were pruned for that document. + If the response has an empty array, it means either no channels were compacted. content: application/json: @@ -64,16 +55,13 @@ post: description: |- Array of channel names that were compacted. description: |- - A mapping of document IDs (keys) to arrays of compacted channels (values). + A array of all the compacted channels example: - doc1: + compacted_channels: - channel1 - channel2 - doc2: - - channel2 - - channel3 '400': - description: 'Bad request. This could be due to invalid request parameters such as missing or malformed doc_ids array or invalid seq value.' + description: 'Bad request. This could be due to invalid request parameters such as invalid seq value.' content: application/json: schema: diff --git a/docs/api/paths/admin/keyspace-_channel_history.yaml b/docs/api/paths/admin/keyspace-_channel_history.yaml new file mode 100644 index 0000000000..869ae9d531 --- /dev/null +++ b/docs/api/paths/admin/keyspace-_channel_history.yaml @@ -0,0 +1,62 @@ +# Copyright 2026-Present Couchbase, Inc. +# +# Use of this software is governed by the Business Source License included +# in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +# in that file, in accordance with the Business Source License, use of this +# software will be governed by the Apache License, Version 2.0, included in +# the file licenses/APL2.txt. +parameters: + - $ref: ../../components/parameters.yaml#/keyspace + - $ref: ../../components/parameters.yaml#/docid +get: + summary: Get Channel History of Document + description: |- + Returns the channel revocation history for the specified document as a map of channel + names to the array of sequences at which the document was removed from each channel. Only channels + that have been revoked at least once are included; channels the document is currently + assigned may be included if they were previously revoked. + + Multiple sequences for a given channel indicate that the document has lost access to that channel + more than once — each sequence represents a point in time at which the document was removed from + the channel. + + Required Sync Gateway RBAC roles: + + * Sync Gateway Application + * Sync Gateway Application Read Only + responses: + '200': + description: |- + Successfully retrieved the channel revocation history for the specified document. + Returns a JSON object mapping each channel name to an array of sequences at which + the document was removed from that channel. + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + type: integer + format: int64 + minimum: 1 + description: Sequences at which the document was removed from this channel. + description: Map of channel names to their revocation sequences. + example: + channel1: + - 3 + - 7 + channel2: + - 5 + '404': + description: Document not found. + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/HTTP-Error + '403': + $ref: ../../components/responses.yaml#/Unauthorized-database + + tags: + - Document + operationId: get_keyspace-_channel_history diff --git a/docs/api/paths/admin/keyspace-_history.yaml b/docs/api/paths/admin/keyspace-_history.yaml deleted file mode 100644 index 54b507d2e8..0000000000 --- a/docs/api/paths/admin/keyspace-_history.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2026-Present Couchbase, Inc. -# -# Use of this software is governed by the Business Source License included -# in the file licenses/BSL-Couchbase.txt. As of the Change Date specified -# in that file, in accordance with the Business Source License, use of this -# software will be governed by the Apache License, Version 2.0, included in -# the file licenses/APL2.txt. -parameters: - - $ref: ../../components/parameters.yaml#/keyspace -get: - summary: Get Channel History of Document - description: |- - Retrieves all inactive channels in the channel history before a particular sequence number. - - Required Sync Gateway RBAC roles: - - * Sync Gateway Application - * Sync Gateway Application Read Only - parameters: - - name: doc_id - in: query - description: The document ID whose channel history should be retrieved. - required: true - schema: - type: string - example: doc1 - - name: seq - in: query - description: |- - The sequence number threshold. All inactive channels with history entries for sequences before this number - (sequences at which the document left the channel) will be returned. - required: true - schema: - type: integer - minimum: 0 - example: 12345 - responses: - '200': - description: |- - Successfully retrieved inactive channel history for the specified document. - Returns the list of inactive channels that have history before the sequence number for the requested document. - content: - application/json: - schema: - type: object - required: - - channels - properties: - channels: - type: array - items: - type: string - description: |- - Array of inactive channel names that have history entries before the specified sequence number. - description: |- - Inactive channel history for the requested document. - example: - channels: - - channel1 - - channel2 - '400': - description: 'Bad request. This could be due to missing or invalid query parameters such as missing doc_id or invalid seq value.' - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/HTTP-Error - '403': - $ref: ../../components/responses.yaml#/Unauthorized-database - - tags: - - Document - operationId: get_keyspace-_history diff --git a/rest/api_test.go b/rest/api_test.go index a72ba90138..2eef9c5823 100644 --- a/rest/api_test.go +++ b/rest/api_test.go @@ -4023,7 +4023,7 @@ func TestDocumentChannelHistoryCompact(t *testing.T) { compactedChannels, err := collection.CompactDocChannelHistory(ctx, "doc4", docSeq) require.NoError(t, err) - assert.Equal(t, []string{"a", "a"}, compactedChannels) + assert.Equal(t, []string{"a"}, compactedChannels) syncDataAfter, err := collection.GetDocSyncData(ctx, "doc4") require.NoError(t, err) @@ -4052,6 +4052,15 @@ func TestDocumentChannelHistoryCompact(t *testing.T) { // Compacting nonexistent doc should return not found error _, err := collection.CompactDocChannelHistory(ctx, "nonexistent", 1) assert.Error(t, err) + + req := CompactDocChannelHistoryRequest{ + Seq: 999999, + } + + bodyBytes, err := base.JSONMarshal(req) + require.NoError(t, err) + resp := rt.SendAdminRequest("POST", "/{{.keyspace}}/_channel_history/nonexistent/compact", string(bodyBytes)) + RequireStatus(t, resp, http.StatusNotFound) }) t.Run("multiple channels with mixed history", func(t *testing.T) { @@ -4067,7 +4076,7 @@ func TestDocumentChannelHistoryCompact(t *testing.T) { require.NoError(t, err) compactedChannels, err := collection.CompactDocChannelHistory(ctx, "doc7", docSeq) require.NoError(t, err) - assert.Equal(t, []string{"b", "b"}, compactedChannels) + assert.Equal(t, []string{"b"}, compactedChannels) syncData, err := collection.GetDocSyncData(ctx, "doc7") require.NoError(t, err) @@ -4110,6 +4119,58 @@ func TestDocumentChannelHistoryCompact(t *testing.T) { require.NoError(t, err) assert.NotNil(t, syncData) }) + + t.Run("test rest api endpoint", func(t *testing.T) { + + version := rt.PutDoc("doc11", `{"channels": ["test"]}`) + version = rt.UpdateDoc("doc11", version, `{"channels": []}`) + _ = rt.UpdateDoc("doc11", version, `{"channels": ["test", "test2"]}`) + + version = rt.PutDoc("doc12", `{"channels": ["a", "b"]}`) + version = rt.UpdateDoc("doc12", version, `{"channels": ["a"]}`) + _ = rt.UpdateDoc("doc12", version, `{"channels": ["a", "c"]}`) + req := CompactDocChannelHistoryRequest{ + Seq: 999999, + } + + bodyBytes, err := base.JSONMarshal(req) + require.NoError(t, err) + resp := rt.SendAdminRequest("POST", "/{{.keyspace}}/_channel_history/doc11/compact", string(bodyBytes)) + RequireStatus(t, resp, http.StatusOK) + + var chanOutput1 map[string][]string + err = base.JSONUnmarshal(resp.Body.Bytes(), &chanOutput1) + require.NoError(t, err) + + expectedOutput1 := map[string][]string{ + "compacted_channels": []string{"test"}, + } + + assert.Equal(t, expectedOutput1, chanOutput1) + + resp2 := rt.SendAdminRequest("POST", "/{{.keyspace}}/_channel_history/doc12/compact", string(bodyBytes)) + RequireStatus(t, resp2, http.StatusOK) + + var chanOutput2 map[string][]string + err = base.JSONUnmarshal(resp2.Body.Bytes(), &chanOutput2) + require.NoError(t, err) + + expectedOutput2 := map[string][]string{ + "compacted_channels": []string{"b"}, + } + + assert.Equal(t, expectedOutput2, chanOutput2) + }) + + t.Run("seq zero returns 400", func(t *testing.T) { + req := CompactDocChannelHistoryRequest{ + Seq: 0, + } + bodyBytes, err := base.JSONMarshal(req) + require.NoError(t, err) + resp := rt.SendAdminRequest("POST", "/{{.keyspace}}/_channel_history/doc1/compact", string(bodyBytes)) + RequireStatus(t, resp, http.StatusBadRequest) + }) } // TestCompactNonImportedDocWithAutoImport verifies that when CompactDocChannelHistory is called @@ -4211,3 +4272,173 @@ func TestCompactNonImportedDocWithAutoImport(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test", finalBody["type"]) } + +// TestGetDocChannelHistory tests the GetDocChannelHistory function and the +// GET /{keyspace}/_channel_history/{docid} REST endpoint. It verifies that +// the channel revocation history is correctly collected from the Channels map, +// ChannelSet, and ChannelSetHistory, and that the REST endpoint returns the +// same result serialised as JSON. +func TestGetDocChannelHistory(t *testing.T) { + defer db.SuspendSequenceBatching()() + + rt := NewRestTester(t, &RestTesterConfig{SyncFn: channels.DocChannelsSyncFunction}) + defer rt.Close() + + collection, ctx := rt.GetSingleTestDatabaseCollection() + + t.Run("basic channel history", func(t *testing.T) { + // Create doc in chan1, revoke it, re-add with chan2, revoke again, then add chan3+chan2 + version := rt.PutDoc("doc1", `{"channels": ["chan1"]}`) + + version = rt.UpdateDoc("doc1", version, `{"channels": []}`) + chanRevocationSeq1 := rt.GetDocumentSequence("doc1") + + version = rt.UpdateDoc("doc1", version, `{"channels": ["chan1","chan2"]}`) + + version = rt.UpdateDoc("doc1", version, `{"channels": []}`) + chanRevocationSeq2 := rt.GetDocumentSequence("doc1") + + rt.UpdateDoc("doc1", version, `{"channels": ["chan3","chan2"]}`) + + expectedChanHistory := map[string][]uint64{ + "chan1": {chanRevocationSeq2, chanRevocationSeq1}, + "chan2": {chanRevocationSeq2}, + } + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc1") + require.NoError(t, err) + require.Len(t, chanHistory, len(expectedChanHistory)) + for chanName, expectedSeq := range expectedChanHistory { + assert.ElementsMatch(t, expectedSeq, chanHistory[chanName]) + } + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc1", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + require.Len(t, apiResult, len(expectedChanHistory)) + for chanName, expectedSeq := range expectedChanHistory { + assert.ElementsMatch(t, expectedSeq, apiResult[chanName]) + } + }) + + t.Run("nonexistent document", func(t *testing.T) { + chanHistory, err := collection.GetDocChannelHistory(ctx, "nonexistent") + assert.Error(t, err) + assert.Nil(t, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/nonexistent", "") + RequireStatus(t, resp, http.StatusNotFound) + }) + + t.Run("channel never revoked", func(t *testing.T) { + rt.PutDoc("doc2", `{"channels": ["chan1"]}`) + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc2") + require.NoError(t, err) + assert.Empty(t, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc2", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.Empty(t, apiResult) + }) + + t.Run("channel revoked once", func(t *testing.T) { + version := rt.PutDoc("doc3", `{"channels": ["chan1"]}`) + rt.UpdateDoc("doc3", version, `{"channels": []}`) + revocationSeq := rt.GetDocumentSequence("doc3") + expected := map[string][]uint64{"chan1": {revocationSeq}} + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc3") + require.NoError(t, err) + assert.Equal(t, expected, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc3", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.Equal(t, expected, apiResult) + }) + + t.Run("multiple channels revoked simultaneously", func(t *testing.T) { + version := rt.PutDoc("doc4", `{"channels": ["chan1", "chan2", "chan3"]}`) + rt.UpdateDoc("doc4", version, `{"channels": []}`) + revocationSeq := rt.GetDocumentSequence("doc4") + expected := map[string][]uint64{ + "chan1": {revocationSeq}, + "chan2": {revocationSeq}, + "chan3": {revocationSeq}, + } + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc4") + require.NoError(t, err) + assert.Equal(t, expected, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc4", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.Equal(t, expected, apiResult) + }) + + t.Run("history from ChannelSetHistory overflow", func(t *testing.T) { + // Cycle a channel enough times to push entries into ChannelSetHistory + version := rt.PutDoc("doc5", `{"channels": ["chan1"]}`) + for range db.DocumentHistoryMaxEntriesPerChannel { + version = rt.UpdateDoc("doc5", version, `{"channels": []}`) + version = rt.UpdateDoc("doc5", version, `{"channels": ["chan1"]}`) + } + rt.UpdateDoc("doc5", version, `{"channels": []}`) + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc5") + require.NoError(t, err) + assert.NotEmpty(t, chanHistory["chan1"]) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc5", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.NotEmpty(t, apiResult["chan1"]) + }) + + t.Run("partially revoked channels", func(t *testing.T) { + // Doc starts in chan1+chan2, chan1 is removed but chan2 stays active + version := rt.PutDoc("doc6", `{"channels": ["chan1", "chan2"]}`) + rt.UpdateDoc("doc6", version, `{"channels": ["chan2"]}`) + revocationSeq := rt.GetDocumentSequence("doc6") + expected := map[string][]uint64{"chan1": {revocationSeq}} + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc6") + require.NoError(t, err) + // chan1 was revoked; chan2 is still active so it should not appear + assert.Equal(t, expected, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc6", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.Equal(t, expected, apiResult) + }) + + t.Run("channel re-added after revocation still appears in history", func(t *testing.T) { + version := rt.PutDoc("doc8", `{"channels": ["chan1"]}`) + version = rt.UpdateDoc("doc8", version, `{"channels": []}`) + revocationSeq := rt.GetDocumentSequence("doc8") + + // Re-add chan1 — it should still appear in history from the earlier revocation + rt.UpdateDoc("doc8", version, `{"channels": ["chan1"]}`) + expected := map[string][]uint64{"chan1": {revocationSeq}} + + chanHistory, err := collection.GetDocChannelHistory(ctx, "doc8") + require.NoError(t, err) + assert.Equal(t, expected, chanHistory) + + resp := rt.SendAdminRequest("GET", "/{{.keyspace}}/_channel_history/doc8", "") + RequireStatus(t, resp, http.StatusOK) + var apiResult map[string][]uint64 + require.NoError(t, json.Unmarshal(resp.BodyBytes(), &apiResult)) + assert.Equal(t, expected, apiResult) + }) +} diff --git a/rest/doc_api.go b/rest/doc_api.go index 865a871ad7..67cb2f9fd8 100644 --- a/rest/doc_api.go +++ b/rest/doc_api.go @@ -927,3 +927,56 @@ func (h *handler) isReadOnlyGuest() bool { } return false } + +// handleGetDocChannelHistory handles GET /{keyspace}/_channel_history/{docid}. +// It returns the channel revocation history for the document as a JSON object mapping +// channel names to the sequences at which the document was removed from each channel. +func (h *handler) handleGetDocChannelHistory() error { + h.assertAdminOnly() + + docid := h.PathVar("docid") + + chanHistory, err := h.collection.GetDocChannelHistory(h.ctx(), docid) + if err != nil { + return err + } + + h.writeJSON(chanHistory) + return nil +} + +type CompactDocChannelHistoryRequest struct { + Seq uint64 `json:"seq"` +} + +// handleCompactDocChannelHistory handles POST /{keyspace}/_channel_history/{docid}/compact. +// It accepts a JSON body containing a sequence number and removes channel history entries +// for the specified document that ended at or before that sequence, returning the compacted +// channel names for that document. +func (h *handler) handleCompactDocChannelHistory() error { + h.assertAdminOnly() + + var req CompactDocChannelHistoryRequest + + docid := h.PathVar("docid") + + err := h.readJSONInto(&req) + if err != nil { + return base.HTTPErrorf(http.StatusBadRequest, "invalid JSON: %v", err) + } + + if req.Seq == 0 { + return base.HTTPErrorf(http.StatusBadRequest, "missing seq") + } + + channels, err := h.collection.CompactDocChannelHistory(h.ctx(), docid, req.Seq) + if err != nil { + return err + } + res := map[string][]string{ + "compacted_channels": channels, + } + + h.writeJSON(res) + return nil +} diff --git a/rest/routing.go b/rest/routing.go index 9bcf2581ec..09125a508d 100644 --- a/rest/routing.go +++ b/rest/routing.go @@ -155,6 +155,10 @@ func CreateAdminRouter(sc *ServerContext) *mux.Router { makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleGetRevTree)).Methods("GET") keyspace.Handle("/_dumpchannel/{channel}", makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleDumpChannel)).Methods("GET") + keyspace.Handle("/_channel_history/{docid:"+docRegex+"}", + makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleGetDocChannelHistory)).Methods("GET") + keyspace.Handle("/_channel_history/{docid:"+docRegex+"}/compact", + makeHandler(sc, adminPrivs, []Permission{PermWriteAppData}, nil, (*handler).handleCompactDocChannelHistory)).Methods("POST") // Database handlers (multi collection): dbr.Handle("/_resync",