Remove clone progress DB experiment (#61615)

This was an experiment from a while ago about storing the clone progress in the DB instead of making a request to gitserver to retrieve it. This has never been activated and is thus dead code, so removing it.
With the switch to a dbworker we get better logging for free anyways.

## Test plan

Removed dead code, tests should cover any regressions.
This commit is contained in:
Erik Seliger 2024-04-10 21:00:22 +02:00 committed by GitHub
parent 47ab5f5828
commit 3e77fc3b64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 10 additions and 326 deletions

View File

@ -8,7 +8,6 @@ export const FEATURE_FLAGS = [
'admin-onboarding',
'auditlog-expansion',
'blob-page-switch-areas-shortcuts',
'clone-progress-logging',
'cody-chat-mock-test',
'cody-web-search',
'contrast-compliant-syntax-highlighting',

View File

@ -15,7 +15,6 @@ import {
} from '../components/FilteredConnection'
import { usePageSwitcherPagination } from '../components/FilteredConnection/hooks/usePageSwitcherPagination'
import { getFilterFromURL, getUrlQuery } from '../components/FilteredConnection/utils'
import { useFeatureFlag } from '../featureFlags/useFeatureFlag'
import {
type ExternalServiceIDsAndNamesResult,
type ExternalServiceIDsAndNamesVariables,
@ -154,7 +153,6 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
} = useQuery<StatusAndRepoStatsResult>(STATUS_AND_REPO_STATS, {})
const location = useLocation()
const navigate = useNavigate()
const [displayCloneProgress] = useFeatureFlag('clone-progress-logging')
useEffect(() => {
if (alwaysPoll || data?.repositoryStats?.total === 0 || data?.repositoryStats?.cloning !== 0) {
@ -261,9 +259,8 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
corrupted: args.corrupted ?? false,
cloneStatus: args.cloneStatus ?? null,
externalService: args.externalService ?? null,
displayCloneProgress,
} as RepositoriesVariables
}, [searchQuery, filterValues, displayCloneProgress])
}, [searchQuery, filterValues])
const debouncedVariables = useDebounce(variables, 300)

View File

@ -112,7 +112,6 @@ const mirrorRepositoryInfoFieldsFragment = gql`
fragment MirrorRepositoryInfoFields on MirrorRepositoryInfo {
cloned
cloneInProgress
cloneProgress @include(if: $displayCloneProgress)
updatedAt
nextSyncAt
isCorrupted
@ -170,7 +169,6 @@ export const REPOSITORIES_QUERY = gql`
$orderBy: RepositoryOrderBy
$descending: Boolean
$externalService: ID
$displayCloneProgress: Boolean = false
) {
repositories(
first: $first
@ -1005,13 +1003,7 @@ const siteAdminPackageFieldsFragment = gql`
`
export const PACKAGES_QUERY = gql`
query Packages(
$kind: PackageRepoReferenceKind
$name: String
$first: Int!
$after: String
$displayCloneProgress: Boolean = false
) {
query Packages($kind: PackageRepoReferenceKind, $name: String, $first: Int!, $after: String) {
packageRepoReferences(kind: $kind, name: $name, first: $first, after: $after) {
nodes {
...SiteAdminPackageFields

View File

@ -1,7 +1,7 @@
import * as React from 'react'
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
import { Code, Text, Tooltip } from '@sourcegraph/wildcard'
import { Text, Tooltip } from '@sourcegraph/wildcard'
import type { MirrorRepositoryInfoFields } from '../../graphql-operations'
import { prettyBytesBigint } from '../../util/prettyBytesBigint'
@ -35,14 +35,6 @@ export const RepoMirrorInfo: React.FunctionComponent<
</Tooltip>
</>
)}
{mirrorInfo.cloneInProgress && (mirrorInfo.cloneProgress ?? '').trim() !== '' ? (
<>
<br />
<Code>{mirrorInfo.cloneProgress}</Code>
</>
) : (
''
)}
</>
)}
</small>

View File

@ -11,7 +11,6 @@ import (
"github.com/sourcegraph/sourcegraph/cmd/frontend/backend"
"github.com/sourcegraph/sourcegraph/internal/auth"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/featureflag"
"github.com/sourcegraph/sourcegraph/internal/gitserver"
"github.com/sourcegraph/sourcegraph/internal/gqlutil"
"github.com/sourcegraph/sourcegraph/internal/lazyregexp"
@ -126,17 +125,6 @@ func (r *repositoryMirrorInfoResolver) CloneInProgress(ctx context.Context) (boo
}
func (r *repositoryMirrorInfoResolver) CloneProgress(ctx context.Context) (*string, error) {
if featureflag.FromContext(ctx).GetBoolOr("clone-progress-logging", false) {
info, err := r.computeGitserverRepo(ctx)
if err != nil {
return nil, err
}
if info.CloneStatus != types.CloneStatusCloning {
return nil, nil
}
return strptr(info.CloningProgress), nil
}
progress, err := r.gitServerClient.RepoCloneProgress(ctx, r.repository.RepoName())
if err != nil {
return nil, err

View File

@ -10,7 +10,6 @@ import (
"github.com/sourcegraph/sourcegraph/cmd/frontend/backend"
"github.com/sourcegraph/sourcegraph/internal/api"
"github.com/sourcegraph/sourcegraph/internal/database/dbmocks"
"github.com/sourcegraph/sourcegraph/internal/featureflag"
"github.com/sourcegraph/sourcegraph/internal/gitserver"
"github.com/sourcegraph/sourcegraph/internal/gitserver/protocol"
"github.com/sourcegraph/sourcegraph/internal/types"
@ -235,59 +234,3 @@ func TestRepositoryMirrorInfoCloneProgressCallsGitserver(t *testing.T) {
`,
})
}
func TestRepositoryMirrorInfoCloneProgressFetchedFromDatabase(t *testing.T) {
users := dbmocks.NewMockUserStore()
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{SiteAdmin: true}, nil)
featureFlags := dbmocks.NewMockFeatureFlagStore()
featureFlags.GetGlobalFeatureFlagsFunc.SetDefaultReturn(map[string]bool{"clone-progress-logging": true}, nil)
gitserverRepos := dbmocks.NewMockGitserverRepoStore()
gitserverRepos.GetByIDFunc.SetDefaultReturn(&types.GitserverRepo{
CloneStatus: types.CloneStatusCloning,
CloningProgress: "cloning progress from the db",
}, nil)
db := dbmocks.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.FeatureFlagsFunc.SetDefaultReturn(featureFlags)
db.GitserverReposFunc.SetDefaultReturn(gitserverRepos)
backend.Mocks.Repos.GetByName = func(ctx context.Context, name api.RepoName) (*types.Repo, error) {
return &types.Repo{
ID: 4752134,
Name: "repo-name",
CreatedAt: time.Now(),
Sources: map[string]*types.SourceInfo{"1": {}},
}, nil
}
t.Cleanup(func() {
backend.Mocks = backend.MockServices{}
})
ctx := featureflag.WithFlags(context.Background(), db.FeatureFlags())
RunTest(t, &Test{
Context: ctx,
Schema: mustParseGraphQLSchemaWithClient(t, db, &fakeGitserverClient{}),
Query: `
{
repository(name: "my/repo") {
mirrorInfo {
cloneProgress
}
}
}
`,
ExpectedResult: `
{
"repository": {
"mirrorInfo": {
"cloneProgress": "cloning progress from the db"
}
}
}
`,
})
}

View File

@ -47,7 +47,6 @@ go_library(
"//internal/env",
"//internal/errcode",
"//internal/extsvc/gitolite",
"//internal/featureflag",
"//internal/fileutil",
"//internal/gitserver",
"//internal/gitserver/gitdomain",

View File

@ -12,7 +12,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@ -20,7 +19,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"golang.org/x/sync/errgroup"
"golang.org/x/time/rate"
"github.com/sourcegraph/log"
@ -36,7 +34,6 @@ import (
"github.com/sourcegraph/sourcegraph/internal/conf"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/env"
"github.com/sourcegraph/sourcegraph/internal/featureflag"
"github.com/sourcegraph/sourcegraph/internal/fileutil"
"github.com/sourcegraph/sourcegraph/internal/gitserver/protocol"
"github.com/sourcegraph/sourcegraph/internal/goroutine"
@ -800,7 +797,7 @@ func (s *Server) doClone(
// produced, the ideal solution would be that readCloneProgress stores it in
// chunks.
output := &linebasedBufferedWriter{}
eg := readCloneProgress(s.db, logger, lock, io.TeeReader(progressReader, output), repo)
eg := readCloneProgress(logger, lock, io.TeeReader(progressReader, output), repo)
cloneTimeout := conf.GitLongCommandTimeout()
cloneCtx, cancel := context.WithTimeout(ctx, cloneTimeout)
@ -966,15 +963,9 @@ func postRepoFetchActions(
}
// readCloneProgress scans the reader and saves the most recent line of output
// as the lock status, writes to a log file if siteConfig.cloneProgressLog is
// enabled, and optionally to the database when the feature flag `clone-progress-logging`
// as the lock status, and optionally writes to a log file if siteConfig.cloneProgressLog
// is enabled.
func readCloneProgress(db database.DB, logger log.Logger, lock RepositoryLock, pr io.Reader, repo api.RepoName) *errgroup.Group {
// Use a background context to ensure we still update the DB even if we
// time out. IE we intentionally don't take an input ctx.
ctx := featureflag.WithFlags(context.Background(), db.FeatureFlags())
enableExperimentalDBCloneProgress := featureflag.FromContext(ctx).GetBoolOr("clone-progress-logging", false)
func readCloneProgress(logger log.Logger, lock RepositoryLock, pr io.Reader, repo api.RepoName) *errgroup.Group {
var logFile *os.File
if conf.Get().CloneProgressLog {
@ -988,12 +979,10 @@ func readCloneProgress(db database.DB, logger log.Logger, lock RepositoryLock, p
}
}
dbWritesLimiter := rate.NewLimiter(rate.Limit(1.0), 1)
scan := bufio.NewScanner(pr)
scan.Split(scanCRLF)
store := db.GitserverRepos()
eg, ctx := errgroup.WithContext(ctx)
var eg errgroup.Group
eg.Go(func() error {
for scan.Scan() {
progress := scan.Text()
@ -1004,16 +993,6 @@ func readCloneProgress(db database.DB, logger log.Logger, lock RepositoryLock, p
// are issues
_, _ = fmt.Fprintln(logFile, progress)
}
// Only write to the database persisted status if line indicates progress
// which is recognized by presence of a '%'. We filter these writes not to waste
// rate-limit tokens on log lines that would not be relevant to the user.
if enableExperimentalDBCloneProgress {
if strings.Contains(progress, "%") && dbWritesLimiter.Allow() {
if err := store.SetCloningProgress(ctx, repo, progress); err != nil {
logger.Error("error updating cloning progress in the db", log.Error(err))
}
}
}
}
if err := scan.Err(); err != nil {
return err
@ -1022,7 +1001,7 @@ func readCloneProgress(db database.DB, logger log.Logger, lock RepositoryLock, p
return nil
})
return eg
return &eg
}
// scanCRLF is similar to bufio.ScanLines except it splits on both '\r' and '\n'

View File

@ -350,9 +350,6 @@ func TestCloneRepo(t *testing.T) {
repoName := api.RepoName("example.com/foo/bar")
db := database.NewDB(logger, dbtest.NewDB(t))
if _, err := db.FeatureFlags().CreateBool(ctx, "clone-progress-logging", true); err != nil {
t.Fatal(err)
}
dbRepo := &types.Repo{
Name: repoName,
Description: "Test",
@ -444,13 +441,6 @@ func TestCloneRepo(t *testing.T) {
if wantCommit != gotCommit {
t.Fatal("failed to clone:", gotCommit)
}
gitserverRepo, err := db.GitserverRepos().GetByName(ctx, repoName)
if err != nil {
t.Fatal(err)
}
if gitserverRepo.CloningProgress == "" {
t.Error("want non-empty CloningProgress")
}
}
func TestCloneRepoRecordsFailures(t *testing.T) {
@ -535,7 +525,6 @@ var ignoreVolatileGitserverRepoFields = cmpopts.IgnoreFields(
"RepoSizeBytes",
"UpdatedAt",
"CorruptionLogs",
"CloningProgress",
)
func TestHandleRepoUpdate(t *testing.T) {

View File

@ -37624,9 +37624,6 @@ type MockGitserverRepoStore struct {
// SetCloneStatusFunc is an instance of a mock function object
// controlling the behavior of the method SetCloneStatus.
SetCloneStatusFunc *GitserverRepoStoreSetCloneStatusFunc
// SetCloningProgressFunc is an instance of a mock function object
// controlling the behavior of the method SetCloningProgress.
SetCloningProgressFunc *GitserverRepoStoreSetCloningProgressFunc
// SetLastErrorFunc is an instance of a mock function object controlling
// the behavior of the method SetLastError.
SetLastErrorFunc *GitserverRepoStoreSetLastErrorFunc
@ -37714,11 +37711,6 @@ func NewMockGitserverRepoStore() *MockGitserverRepoStore {
return
},
},
SetCloningProgressFunc: &GitserverRepoStoreSetCloningProgressFunc{
defaultHook: func(context.Context, api.RepoName, string) (r0 error) {
return
},
},
SetLastErrorFunc: &GitserverRepoStoreSetLastErrorFunc{
defaultHook: func(context.Context, api.RepoName, string, string) (r0 error) {
return
@ -37822,11 +37814,6 @@ func NewStrictMockGitserverRepoStore() *MockGitserverRepoStore {
panic("unexpected invocation of MockGitserverRepoStore.SetCloneStatus")
},
},
SetCloningProgressFunc: &GitserverRepoStoreSetCloningProgressFunc{
defaultHook: func(context.Context, api.RepoName, string) error {
panic("unexpected invocation of MockGitserverRepoStore.SetCloningProgress")
},
},
SetLastErrorFunc: &GitserverRepoStoreSetLastErrorFunc{
defaultHook: func(context.Context, api.RepoName, string, string) error {
panic("unexpected invocation of MockGitserverRepoStore.SetLastError")
@ -37908,9 +37895,6 @@ func NewMockGitserverRepoStoreFrom(i database.GitserverRepoStore) *MockGitserver
SetCloneStatusFunc: &GitserverRepoStoreSetCloneStatusFunc{
defaultHook: i.SetCloneStatus,
},
SetCloningProgressFunc: &GitserverRepoStoreSetCloningProgressFunc{
defaultHook: i.SetCloningProgress,
},
SetLastErrorFunc: &GitserverRepoStoreSetLastErrorFunc{
defaultHook: i.SetLastError,
},
@ -39156,118 +39140,6 @@ func (c GitserverRepoStoreSetCloneStatusFuncCall) Results() []interface{} {
return []interface{}{c.Result0}
}
// GitserverRepoStoreSetCloningProgressFunc describes the behavior when the
// SetCloningProgress method of the parent MockGitserverRepoStore instance
// is invoked.
type GitserverRepoStoreSetCloningProgressFunc struct {
defaultHook func(context.Context, api.RepoName, string) error
hooks []func(context.Context, api.RepoName, string) error
history []GitserverRepoStoreSetCloningProgressFuncCall
mutex sync.Mutex
}
// SetCloningProgress delegates to the next hook function in the queue and
// stores the parameter and result values of this invocation.
func (m *MockGitserverRepoStore) SetCloningProgress(v0 context.Context, v1 api.RepoName, v2 string) error {
r0 := m.SetCloningProgressFunc.nextHook()(v0, v1, v2)
m.SetCloningProgressFunc.appendCall(GitserverRepoStoreSetCloningProgressFuncCall{v0, v1, v2, r0})
return r0
}
// SetDefaultHook sets function that is called when the SetCloningProgress
// method of the parent MockGitserverRepoStore instance is invoked and the
// hook queue is empty.
func (f *GitserverRepoStoreSetCloningProgressFunc) SetDefaultHook(hook func(context.Context, api.RepoName, string) error) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// SetCloningProgress method of the parent MockGitserverRepoStore instance
// invokes the hook at the front of the queue and discards it. After the
// queue is empty, the default hook function is invoked for any future
// action.
func (f *GitserverRepoStoreSetCloningProgressFunc) PushHook(hook func(context.Context, api.RepoName, string) error) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *GitserverRepoStoreSetCloningProgressFunc) SetDefaultReturn(r0 error) {
f.SetDefaultHook(func(context.Context, api.RepoName, string) error {
return r0
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *GitserverRepoStoreSetCloningProgressFunc) PushReturn(r0 error) {
f.PushHook(func(context.Context, api.RepoName, string) error {
return r0
})
}
func (f *GitserverRepoStoreSetCloningProgressFunc) nextHook() func(context.Context, api.RepoName, string) error {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *GitserverRepoStoreSetCloningProgressFunc) appendCall(r0 GitserverRepoStoreSetCloningProgressFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// GitserverRepoStoreSetCloningProgressFuncCall objects describing the
// invocations of this function.
func (f *GitserverRepoStoreSetCloningProgressFunc) History() []GitserverRepoStoreSetCloningProgressFuncCall {
f.mutex.Lock()
history := make([]GitserverRepoStoreSetCloningProgressFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// GitserverRepoStoreSetCloningProgressFuncCall is an object that describes
// an invocation of method SetCloningProgress on an instance of
// MockGitserverRepoStore.
type GitserverRepoStoreSetCloningProgressFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 api.RepoName
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 string
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c GitserverRepoStoreSetCloningProgressFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c GitserverRepoStoreSetCloningProgressFuncCall) Results() []interface{} {
return []interface{}{c.Result0}
}
// GitserverRepoStoreSetLastErrorFunc describes the behavior when the
// SetLastError method of the parent MockGitserverRepoStore instance is
// invoked.

View File

@ -67,8 +67,6 @@ type GitserverRepoStore interface {
TotalErroredCloudDefaultRepos(ctx context.Context) (int, error)
// UpdateRepoSizes sets repo sizes according to input map. Key is repoID, value is repo_size_bytes.
UpdateRepoSizes(ctx context.Context, logger log.Logger, shardID string, repos map[api.RepoName]int64) (int, error)
// SetCloningProgress updates a piece of text description from how cloning proceeds.
SetCloningProgress(context.Context, api.RepoName, string) error
// GetLastSyncOutput returns the last stored output from a repo sync (clone or fetch), or ok: false if
// no log is found.
GetLastSyncOutput(ctx context.Context, name api.RepoName) (output string, ok bool, err error)
@ -307,7 +305,6 @@ SELECT
gr.repo_id,
repo.name,
gr.clone_status,
gr.cloning_progress,
gr.shard_id,
gr.last_error,
gr.last_fetched,
@ -340,7 +337,6 @@ SELECT
-- We don't need this here, but the scanner needs it.
'' as name,
gr.clone_status,
gr.cloning_progress,
gr.shard_id,
gr.last_error,
gr.last_fetched,
@ -370,7 +366,6 @@ SELECT
-- We don't need this here, but the scanner needs it.
'' as name,
gr.clone_status,
gr.cloning_progress,
gr.shard_id,
gr.last_error,
gr.last_fetched,
@ -394,7 +389,6 @@ SELECT
gr.repo_id,
r.name,
gr.clone_status,
gr.cloning_progress,
gr.shard_id,
gr.last_error,
gr.last_fetched,
@ -437,7 +431,6 @@ func scanGitserverRepo(scanner dbutil.Scanner) (*types.GitserverRepo, api.RepoNa
&gr.RepoID,
&repoName,
&cloneStatus,
&gr.CloningProgress,
&gr.ShardID,
&dbutil.NullString{S: &gr.LastError},
&gr.LastFetched,
@ -754,24 +747,3 @@ func sanitizeToUTF8(s string) string {
// Sanitize to a valid UTF-8 string and return it.
return strings.ToValidUTF8(t, "")
}
func (s *gitserverRepoStore) SetCloningProgress(ctx context.Context, repoName api.RepoName, progressLine string) error {
res, err := s.ExecResult(ctx, sqlf.Sprintf(setCloningProgressQueryFmtstr, progressLine, repoName))
if err != nil {
return errors.Wrap(err, "failed to set cloning progress")
}
if nrows, err := res.RowsAffected(); err != nil {
return errors.Wrap(err, "failed to set cloning progress, cannot verify rows updated")
} else if nrows != 1 {
return errors.Newf("failed to set cloning progress, repo %q not found", repoName)
}
return nil
}
const setCloningProgressQueryFmtstr = `
UPDATE gitserver_repos
SET
cloning_progress = %s,
updated_at = NOW()
WHERE repo_id = (SELECT id FROM repo WHERE name = %s)
`

View File

@ -587,43 +587,6 @@ func TestSetCloneStatus(t *testing.T) {
}
}
func TestCloningProgress(t *testing.T) {
if testing.Short() {
t.Skip()
}
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(t))
ctx := context.Background()
t.Run("Default", func(t *testing.T) {
repo, _ := createTestRepo(ctx, t, db, "github.com/sourcegraph/defaultcloningprogress")
gotRepo, err := db.GitserverRepos().GetByName(ctx, repo.Name)
if err != nil {
t.Fatalf("GetByName: %s", err)
}
if got := gotRepo.CloningProgress; got != "" {
t.Errorf("GetByName.CloningProgress, got %q, want empty string", got)
}
})
t.Run("Set", func(t *testing.T) {
repo, gitserverRepo := createTestRepo(ctx, t, db, "github.com/sourcegraph/updatedcloningprogress")
gitserverRepo.CloningProgress = "Receiving objects: 97% (97/100)"
if err := db.GitserverRepos().SetCloningProgress(ctx, repo.Name, gitserverRepo.CloningProgress); err != nil {
t.Fatalf("SetCloningProgress: %s", err)
}
gotRepo, err := db.GitserverRepos().GetByName(ctx, repo.Name)
if err != nil {
t.Fatalf("GetByName: %s", err)
}
if diff := cmp.Diff(gitserverRepo, gotRepo, cmpopts.IgnoreFields(types.GitserverRepo{}, "UpdatedAt")); diff != "" {
t.Errorf("SetCloningProgress->GetByName -want+got: %s", diff)
}
})
}
func TestLogCorruption(t *testing.T) {
if testing.Short() {
t.Skip()

View File

@ -583,9 +583,8 @@ func ParseCloneStatusFromGraphQL(s string) CloneStatus {
type GitserverRepo struct {
RepoID api.RepoID
// Usually represented by a gitserver hostname
ShardID string
CloneStatus CloneStatus
CloningProgress string
ShardID string
CloneStatus CloneStatus
// The last error that occurred or empty if the last action was successful
LastError string
// The last time fetch was called.